fix(python-client): redact operator password from AcknowledgeAlarmReply errors
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import AsyncIterator, Sequence
|
from collections.abc import AsyncIterator, Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from .auth import redact_secret
|
from .auth import redact_secret
|
||||||
from .errors import MalformedReplyError, MxGatewayError, ensure_mxaccess_success
|
from .errors import MalformedReplyError, MxGatewayError, ensure_mxaccess_success
|
||||||
@@ -943,22 +944,26 @@ def _redact_error(error: MxGatewayError, secrets: Sequence[str | None]) -> None:
|
|||||||
error.raw_reply = _redact_reply(error.raw_reply, scrubbed)
|
error.raw_reply = _redact_reply(error.raw_reply, scrubbed)
|
||||||
|
|
||||||
|
|
||||||
def _redact_reply(reply: pb.MxCommandReply, secrets: Sequence[str]) -> pb.MxCommandReply:
|
def _redact_reply(reply: Any, secrets: Sequence[str]) -> Any:
|
||||||
"""Return a deep copy of *reply* with credential text scrubbed from diagnostics.
|
"""Return a deep copy of *reply* with credential text scrubbed from diagnostics.
|
||||||
|
|
||||||
Operates on a clone so the caller's original reply object is never mutated.
|
Operates on a clone so the caller's original reply object is never mutated.
|
||||||
Only the free-text diagnostic fields that can echo a client-supplied secret
|
Only the free-text diagnostic fields that can echo a client-supplied secret
|
||||||
are scrubbed; the structured/enum fields the gateway itself sets are left as-is.
|
are scrubbed; the structured/enum fields the gateway itself sets are left as-is.
|
||||||
|
Works for any reply message (``MxCommandReply``, ``AcknowledgeAlarmReply``, ...):
|
||||||
|
each field is scrubbed only when the message type declares it.
|
||||||
"""
|
"""
|
||||||
clone = type(reply)()
|
clone = type(reply)()
|
||||||
clone.CopyFrom(reply)
|
clone.CopyFrom(reply)
|
||||||
if clone.protocol_status.message:
|
fields = clone.DESCRIPTOR.fields_by_name
|
||||||
|
if "protocol_status" in fields and clone.protocol_status.message:
|
||||||
clone.protocol_status.message = redact_secret(clone.protocol_status.message, secrets)
|
clone.protocol_status.message = redact_secret(clone.protocol_status.message, secrets)
|
||||||
if clone.diagnostic_message:
|
if "diagnostic_message" in fields and clone.diagnostic_message:
|
||||||
clone.diagnostic_message = redact_secret(clone.diagnostic_message, secrets)
|
clone.diagnostic_message = redact_secret(clone.diagnostic_message, secrets)
|
||||||
for status in clone.statuses:
|
if "statuses" in fields:
|
||||||
if status.diagnostic_text:
|
for status in clone.statuses:
|
||||||
status.diagnostic_text = redact_secret(status.diagnostic_text, secrets)
|
if status.diagnostic_text:
|
||||||
|
status.diagnostic_text = redact_secret(status.diagnostic_text, secrets)
|
||||||
return clone
|
return clone
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,40 @@ async def test_acknowledge_alarm_error_redacts_operator_password() -> None:
|
|||||||
assert "pw-secret" not in str(excinfo.value)
|
assert "pw-secret" not in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_acknowledge_alarm_protocol_failure_redacts_operator_password() -> None:
|
||||||
|
from zb_mom_ww_mxgateway.errors import MxGatewayError
|
||||||
|
|
||||||
|
stub = FakeGatewayStub()
|
||||||
|
stub.acknowledge_alarm.replies = [
|
||||||
|
pb.AcknowledgeAlarmReply(
|
||||||
|
protocol_status=pb.ProtocolStatus(
|
||||||
|
code=pb.PROTOCOL_STATUS_CODE_INVALID_REQUEST,
|
||||||
|
message="rejected credential pw-secret",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
client = await GatewayClient.connect(
|
||||||
|
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||||
|
stub=stub,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(MxGatewayError) as excinfo:
|
||||||
|
await client.acknowledge_alarm(
|
||||||
|
pb.AcknowledgeAlarmRequest(
|
||||||
|
alarm_full_reference="Tank01.Level.HiHi",
|
||||||
|
operator_user="alice",
|
||||||
|
operator_password="pw-secret",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "pw-secret" not in str(excinfo.value)
|
||||||
|
raw = excinfo.value.raw_reply
|
||||||
|
assert isinstance(raw, pb.AcknowledgeAlarmReply)
|
||||||
|
assert "pw-secret" not in raw.protocol_status.message
|
||||||
|
assert "pw-secret" not in str(raw)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_acknowledge_alarm_unauthenticated_raises_typed_error() -> None:
|
async def test_acknowledge_alarm_unauthenticated_raises_typed_error() -> None:
|
||||||
stub = FakeGatewayStub()
|
stub = FakeGatewayStub()
|
||||||
|
|||||||
Reference in New Issue
Block a user