diff --git a/headroom/proxy/helpers.py b/headroom/proxy/helpers.py index 8e48aac03..e03634c7d 100644 --- a/headroom/proxy/helpers.py +++ b/headroom/proxy/helpers.py @@ -24,7 +24,7 @@ from typing import TYPE_CHECKING, Any, Literal, cast from headroom import paths as _paths from headroom._subprocess import run -from headroom.proxy import request_limit_policy +from headroom.proxy import request_limit_policy, wire_debug_redaction_policy from headroom.proxy.body_forwarding import ( BodyMutationTracker as BodyMutationTracker, # noqa: F401 - compatibility export ) @@ -57,24 +57,8 @@ logger = logging.getLogger("headroom.proxy") _CODEX_WIRE_DEBUG_ENV = "HEADROOM_CODEX_WIRE_DEBUG" _CODEX_WIRE_DEBUG_DIR_ENV = "HEADROOM_CODEX_WIRE_DEBUG_DIR" -_CODEX_WIRE_REDACTED = "[REDACTED]" -_CODEX_WIRE_SECRET_KEYS = ( - "authorization", - "cookie", - "set-cookie", - "api-key", - "x-api-key", - "openai-api-key", - "anthropic-api-key", - "access_token", - "refresh_token", - "id_token", - "bearer", - "password", - "secret", - "token", - "credential", -) +_CODEX_WIRE_REDACTED = wire_debug_redaction_policy.WIRE_DEBUG_REDACTED +_CODEX_WIRE_SECRET_KEYS = wire_debug_redaction_policy.WIRE_DEBUG_SECRET_KEYS def codex_wire_debug_enabled() -> bool: @@ -96,33 +80,16 @@ def _codex_wire_debug_dir() -> Path: def _should_redact_key(key: str) -> bool: - normalized = key.lower().replace("-", "_") - if normalized in {marker.replace("-", "_") for marker in _CODEX_WIRE_SECRET_KEYS}: - return True - return ( - normalized.endswith("_api_key") - or normalized.endswith("_secret") - or normalized.endswith("_password") - or normalized.endswith("_access_token") - or normalized.endswith("_refresh_token") - ) + return wire_debug_redaction_policy.should_redact_key(key) def _redact_value(value: Any) -> Any: - if isinstance(value, dict): - return { - k: (_CODEX_WIRE_REDACTED if _should_redact_key(str(k)) else _redact_value(v)) - for k, v in value.items() - } - if isinstance(value, list): - return [_redact_value(item) for item in value] - return value + return wire_debug_redaction_policy.redact_for_wire_debug(value) def redact_for_wire_debug(value: Any) -> Any: """Redact obvious secrets while preserving request/response shape.""" - - return _redact_value(value) + return wire_debug_redaction_policy.redact_for_wire_debug(value) def _safe_event_name(event: str) -> str: diff --git a/headroom/proxy/wire_debug_redaction_policy.py b/headroom/proxy/wire_debug_redaction_policy.py new file mode 100644 index 000000000..5b3c40813 --- /dev/null +++ b/headroom/proxy/wire_debug_redaction_policy.py @@ -0,0 +1,52 @@ +"""Secret redaction policy for opt-in proxy wire debug capture.""" + +from __future__ import annotations + +from typing import Any + +WIRE_DEBUG_REDACTED = "[REDACTED]" +WIRE_DEBUG_SECRET_KEYS = ( + "authorization", + "cookie", + "set-cookie", + "api-key", + "x-api-key", + "openai-api-key", + "anthropic-api-key", + "access_token", + "refresh_token", + "id_token", + "bearer", + "password", + "secret", + "token", + "credential", +) + + +def should_redact_key(key: str) -> bool: + """Return whether a wire-debug field name should be redacted.""" + normalized = key.lower().replace("-", "_") + if normalized in {marker.replace("-", "_") for marker in WIRE_DEBUG_SECRET_KEYS}: + return True + return ( + normalized.endswith("_api_key") + or normalized.endswith("_secret") + or normalized.endswith("_password") + or normalized.endswith("_access_token") + or normalized.endswith("_refresh_token") + ) + + +def redact_for_wire_debug(value: Any) -> Any: + """Redact obvious secrets while preserving request/response shape.""" + if isinstance(value, dict): + return { + key: ( + WIRE_DEBUG_REDACTED if should_redact_key(str(key)) else redact_for_wire_debug(item) + ) + for key, item in value.items() + } + if isinstance(value, list): + return [redact_for_wire_debug(item) for item in value] + return value diff --git a/tests/test_wire_debug_redaction_policy.py b/tests/test_wire_debug_redaction_policy.py new file mode 100644 index 000000000..e271a2969 --- /dev/null +++ b/tests/test_wire_debug_redaction_policy.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from headroom.proxy.wire_debug_redaction_policy import ( + WIRE_DEBUG_REDACTED, + redact_for_wire_debug, + should_redact_key, +) + + +def test_wire_debug_redacts_direct_secret_keys() -> None: + redacted = redact_for_wire_debug( + { + "Authorization": "Bearer test-token", + "x-api-key": "sk-test", + "safe": "visible", + } + ) + + assert redacted == { + "Authorization": WIRE_DEBUG_REDACTED, + "x-api-key": WIRE_DEBUG_REDACTED, + "safe": "visible", + } + + +def test_wire_debug_redacts_nested_secret_suffixes() -> None: + redacted = redact_for_wire_debug( + { + "messages": [ + {"content": "visible", "service_access_token": "secret-token"}, + {"metadata": {"database_password": "secret-password", "trace_id": "abc"}}, + ] + } + ) + + assert redacted["messages"][0]["content"] == "visible" + assert redacted["messages"][0]["service_access_token"] == WIRE_DEBUG_REDACTED + assert redacted["messages"][1]["metadata"]["database_password"] == WIRE_DEBUG_REDACTED + assert redacted["messages"][1]["metadata"]["trace_id"] == "abc" + + +def test_wire_debug_key_matching_normalizes_dashes_and_case() -> None: + assert should_redact_key("Anthropic-API-Key") + assert should_redact_key("custom-refresh-token") + assert not should_redact_key("token_count")