Skip to content

Commit d56a285

Browse files
fix(security): redact Authorization/credential headers before logging
The ArgoCD connector logged its request headers verbatim (`logger.debug(f"Request headers: {headers}")`), leaking a live ArgoCD admin Bearer JWT into the log stream on every API call. Anyone able to read the logs (Loki, `kubectl logs`) could lift a valid token until it expired. Add a shared redact_sensitive_headers() util (masks authorization, proxy-authorization, x-api-key, api-key, x-auth-token, cookie, set-cookie) and use it in argo.py. The HTTP connector already sanitized its header logging; this brings argo into line and gives one tested helper for future use. Regression test asserts the bearer token value never survives redaction.
1 parent 64d7bc4 commit d56a285

3 files changed

Lines changed: 74 additions & 1 deletion

File tree

operations-manager/python/opi/connectors/argo.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
import aiohttp
1414
import requests
1515

16+
from opi.utils.logging_redact import redact_sensitive_headers
17+
1618
logger = logging.getLogger(__name__)
1719

1820

@@ -218,7 +220,7 @@ async def _make_authenticated_request(
218220

219221
async with aiohttp.ClientSession(connector=connector, timeout=request_timeout) as session:
220222
headers = {"Authorization": f"Bearer {self.auth_token}", "Content-Type": "application/json"}
221-
logger.debug(f"Request headers: {headers}")
223+
logger.debug(f"Request headers: {redact_sensitive_headers(headers)}")
222224
logger.debug(f"Making {method} request to: {url}")
223225

224226
async with session.request(method, url, json=json_data or {}, headers=headers) as response:
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""Helpers for keeping secrets out of logs.
2+
3+
Logging request/response headers is useful for debugging, but headers routinely
4+
carry credentials (``Authorization: Bearer …``, ``X-API-Key``, cookies). Logging
5+
them verbatim leaks live tokens into the log stream. Always run header dicts
6+
through :func:`redact_sensitive_headers` before logging them.
7+
"""
8+
9+
# Header names (lower-cased) whose value must never be logged.
10+
_SENSITIVE_HEADERS = frozenset(
11+
{
12+
"authorization",
13+
"proxy-authorization",
14+
"x-api-key",
15+
"api-key",
16+
"x-auth-token",
17+
"cookie",
18+
"set-cookie",
19+
}
20+
)
21+
22+
_REDACTED = "***REDACTED***"
23+
24+
25+
def redact_sensitive_headers(headers: dict[str, str]) -> dict[str, str]:
26+
"""Return a copy of ``headers`` with credential-bearing values masked.
27+
28+
Matching is case-insensitive on the header name. Non-sensitive headers pass
29+
through unchanged so the log still shows the request shape.
30+
"""
31+
return {key: (_REDACTED if str(key).lower() in _SENSITIVE_HEADERS else value) for key, value in headers.items()}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""redact_sensitive_headers must keep credentials out of logs.
2+
3+
Regression guard for the ArgoCD connector leaking a live admin Bearer JWT via
4+
`logger.debug(f"Request headers: {headers}")`.
5+
"""
6+
7+
from opi.utils.logging_redact import redact_sensitive_headers
8+
9+
10+
def test_masks_bearer_token_value() -> None:
11+
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.secret.signature"
12+
out = redact_sensitive_headers({"Authorization": f"Bearer {token}", "Content-Type": "application/json"})
13+
assert token not in str(out)
14+
assert out["Authorization"] == "***REDACTED***"
15+
# Non-sensitive headers pass through so the log still shows request shape.
16+
assert out["Content-Type"] == "application/json"
17+
18+
19+
def test_case_insensitive_and_multiple_sensitive_headers() -> None:
20+
out = redact_sensitive_headers(
21+
{
22+
"authorization": "Basic abc123",
23+
"X-API-Key": "supersecret",
24+
"Cookie": "session=deadbeef",
25+
"Accept": "application/json",
26+
}
27+
)
28+
assert out["authorization"] == "***REDACTED***"
29+
assert out["X-API-Key"] == "***REDACTED***"
30+
assert out["Cookie"] == "***REDACTED***"
31+
assert out["Accept"] == "application/json"
32+
# No secret value survives anywhere in the output.
33+
for leaked in ("abc123", "supersecret", "deadbeef"):
34+
assert leaked not in str(out)
35+
36+
37+
def test_does_not_mutate_input() -> None:
38+
headers = {"Authorization": "Bearer x"}
39+
redact_sensitive_headers(headers)
40+
assert headers == {"Authorization": "Bearer x"} # original untouched

0 commit comments

Comments
 (0)