Summary
The top-level MuralError handler in the mural CLI main() writes the exception text to stderr without passing it through _redact. Several MuralAPIError messages are constructed directly from raw OAuth token-endpoint and asset-upload response bodies, so a token-refresh failure can print credential material in clear text.
This is the same failure class as #2460, but at a different sink. It is not covered by CodeQL alert 388 (already fixed), and it is not currently detected by any scanner.
Evidence
The unredacted sink, in scripts/mural/__init__.py:
except MuralError as exc:
print(f"error: {exc}", file=sys.stderr) # <-- not redacted
return 1
except Exception as exc: # noqa: BLE001
print(f"internal error: {_redact(repr(exc))}", file=sys.stderr) # <-- redacted
The broad Exception fallback redacts. The narrower MuralError handler — the one that actually carries API response bodies — does not.
A second MuralError handler earlier in the same file has the same defect:
except MuralError as exc:
print(str(exc), file=sys.stderr) # <-- L1195, not redacted
return EXIT_FAILURE
Both sites must be fixed. Repairing only L1249 leaves the vulnerability reachable through L1195.
Tainted messages reach that handler from scripts/mural/_transport.py:
| Line |
Construction |
Carries |
| L270 |
MuralAPIError(status, "TOKEN_INVALID_JSON", text) |
Raw token-endpoint response body |
| L315-L317 |
MuralAPIError(exc.code, "REFRESH_FAILED", text) |
Raw refresh-endpoint error body |
| L319 |
MuralAPIError(status, "REFRESH_FAILED", json.dumps(data)) |
Full parsed refresh payload |
| L603 |
MuralAPIError(status, "ASSET_UPLOAD_FAILED", payload) |
Azure Blob response (SAS query strings) |
The clearest demonstration is the refresh path, where the same string is handled two different ways:
except urllib.error.HTTPError as exc:
text = _read_response_body(exc).decode("utf-8", errors="replace")
_emit(f"refresh failed: HTTP {exc.code} {text}", level=logging.ERROR) # redacted via _emit
raise MuralAPIError(exc.code, "REFRESH_FAILED", text or "refresh failed") from exc
_emit routes through _redact. The re-raise does not, and main() then prints it verbatim. So the defense holds on the logging path and is bypassed on the exception path.
Why this matters
mural-log-hygiene.instructions.md states that _redact "only protects log output that is actually routed through it. Bare LOGGER.* and print(*) sites bypass it." This is exactly such a site, on a path that handles OAuth refresh tokens and client secrets. The code contradicts its own documented contract.
Why scanners missed it
py/clear-text-logging-sensitive-data traced taint through a local dict in _cli_auth.py (alert 388) but does not follow taint across module boundaries through exception construction and re-raise into a top-level handler. Detection here requires either manual review or a custom query.
Proposed fix
- Wrap the output of both
MuralError handlers (L1195 and L1249) in _redact, matching the adjacent Exception handler.
- Audit the other
print(json.dumps(envelope), file=sys.stderr) handlers in main() and route them through a redacting helper for uniformity.
- Prefer not embedding raw response bodies in exception messages at all in
_transport.py; carry a status/code plus a redacted excerpt.
- Add a regression test alongside
tests/test_redaction.py asserting that a MuralAPIError carrying token-shaped material is masked when surfaced by main().
Acceptance criteria
Related
Summary
The top-level
MuralErrorhandler in the mural CLImain()writes the exception text to stderr without passing it through_redact. SeveralMuralAPIErrormessages are constructed directly from raw OAuth token-endpoint and asset-upload response bodies, so a token-refresh failure can print credential material in clear text.This is the same failure class as #2460, but at a different sink. It is not covered by CodeQL alert 388 (already
fixed), and it is not currently detected by any scanner.Evidence
The unredacted sink, in
scripts/mural/__init__.py:The broad
Exceptionfallback redacts. The narrowerMuralErrorhandler — the one that actually carries API response bodies — does not.A second
MuralErrorhandler earlier in the same file has the same defect:Both sites must be fixed. Repairing only L1249 leaves the vulnerability reachable through L1195.
Tainted messages reach that handler from
scripts/mural/_transport.py:MuralAPIError(status, "TOKEN_INVALID_JSON", text)MuralAPIError(exc.code, "REFRESH_FAILED", text)MuralAPIError(status, "REFRESH_FAILED", json.dumps(data))MuralAPIError(status, "ASSET_UPLOAD_FAILED", payload)The clearest demonstration is the refresh path, where the same string is handled two different ways:
_emitroutes through_redact. The re-raise does not, andmain()then prints it verbatim. So the defense holds on the logging path and is bypassed on the exception path.Why this matters
mural-log-hygiene.instructions.mdstates that_redact"only protects log output that is actually routed through it. BareLOGGER.*andprint(*)sites bypass it." This is exactly such a site, on a path that handles OAuth refresh tokens and client secrets. The code contradicts its own documented contract.Why scanners missed it
py/clear-text-logging-sensitive-datatraced taint through a local dict in_cli_auth.py(alert 388) but does not follow taint across module boundaries through exception construction and re-raise into a top-level handler. Detection here requires either manual review or a custom query.Proposed fix
MuralErrorhandlers (L1195 and L1249) in_redact, matching the adjacentExceptionhandler.print(json.dumps(envelope), file=sys.stderr)handlers inmain()and route them through a redacting helper for uniformity._transport.py; carry a status/code plus a redacted excerpt.tests/test_redaction.pyasserting that aMuralAPIErrorcarrying token-shaped material is masked when surfaced bymain().Acceptance criteria
MuralErroroutput is redacted at both handler sites (L1195 and L1249)main()use a redacting path_transport.pyno longer embeds raw token/asset response bodies in exception messagesmain()exception sinkRelated
_cli_auth.py), alreadyfixedupstream; residual hardening in that file handled separately