Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .secrets.baseline

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 37 additions & 2 deletions ibm_cloud_sdk_core/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,51 @@
"thumbprint",
"token_uri",
]
# Names (lower-case) of HTTP headers whose values must never be logged.
# In addition to these, any header whose name starts with REDACTED_HEADER_PREFIX is also redacted.
# NOTE: keep this list consistent with the other SDK cores (Go, Java, Node).
REDACTED_HEADER_NAMES = frozenset(
[
"authorization",
"proxy-authorization",
"cookie",
"set-cookie",
"secret",
]
)
REDACTED_HEADER_PREFIX = "x-auth"


def _is_redacted_header(name: str) -> bool:
"""Returns True iff the header 'name' (compared case-insensitively) is one whose value must be redacted."""
lower_name = name.lower()
return lower_name in REDACTED_HEADER_NAMES or lower_name.startswith(REDACTED_HEADER_PREFIX)


class LoggingFilter:
"""Functions used to filter messages before they are logged."""

redacted_tokens = "|".join(REDACTED_KEYWORDS)
auth_header_pattern = re.compile(r"(?m)(Authorization|X-Auth\S*): ((.*?)(\r\n.*)|(.*))")
# Matches a single "Name: value" header, found either at the start of a line, directly after
# an escaped newline (the 'http.client' debug output contains the literal characters "\r\n"),
# or after the "header: " prefix that 'http.client' adds to each received header.
# The value extends up to the next (real or escaped) line terminator.
header_pattern = re.compile(
r"(?im)(?:^|(?<=\\n))"
r"(?P<prefix>header: )?"
r"(?P<name>[!#$%&'*+.^_`|~0-9A-Za-z-]+):[ \t]*"
r"(?:(?!\\r\\n|\\n)[^\r\n])*"
)
property_settings_pattern = re.compile(r"(?im)(" + redacted_tokens + r")=[^&\n]*(&|$)")
json_field_pattern = re.compile(r'(?i)"([^"]*(' + redacted_tokens + r')[^"_]*)":\s*"[^"\n]*"')

@classmethod
def _redact_header(cls, match: re.Match) -> str:
"""Replaces the value of a matched header if it is a credential-bearing header."""
if _is_redacted_header(match.group("name")):
return (match.group("prefix") or "") + match.group("name") + ": [redacted]"
return match.group(0)

@classmethod
def redact_secrets(cls, text: str) -> str:
"""Replaces values of potential secret keywords with a placeholder value.
Expand All @@ -63,7 +98,7 @@ def redact_secrets(cls, text: str) -> str:
"""

placeholder = "[redacted]"
redacted = cls.auth_header_pattern.sub(r"\1: " + placeholder + r"\4", text)
redacted = cls.header_pattern.sub(cls._redact_header, text)
redacted = cls.property_settings_pattern.sub(r"\1=" + placeholder + r"\2", redacted)
redacted = cls.json_field_pattern.sub(r'"\1":"' + placeholder + r'"', redacted)
return redacted
Expand Down
84 changes: 83 additions & 1 deletion test/test_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ def _pw(val):
assert '"name": "alice"' in result
assert '"role": "admin"' in result

# ── auth_header_pattern ───────────────────────────────────────────────────
# ── header_pattern ────────────────────────────────────────────────────────

# Authorization header at EOL must not consume the line that follows.
result = redact_secrets("Authorization: Bearer tok\nContent-Type: application/json")
Expand All @@ -162,6 +162,88 @@ def _pw(val):
assert "body" in result


def test_redact_secrets_credential_headers():
"""Verify that every credential-bearing header is redacted, regardless of case."""
redact_secrets = LoggingFilter.redact_secrets

headers = [
"Authorization",
"Proxy-Authorization",
"Cookie",
"Set-Cookie",
"Secret",
"X-Auth-Token",
"X-Authorization",
"X-Auth-Foo-Bar",
]
for header in headers:
for name in (header, header.lower(), header.upper()):
result = redact_secrets(f"{name}: s3cr3tvalue")
assert "s3cr3tvalue" not in result
assert result == f"{name}: [redacted]"

# Cookie-style values with multiple attributes.
assert "abc123" not in redact_secrets("Set-Cookie: sid=abc123; Path=/; HttpOnly")
result = redact_secrets("Cookie: a=firstval; b=secondval")
assert "firstval" not in result
assert "secondval" not in result

# A full CRLF-delimited HTTP dump: only credential headers are redacted, CRLF preserved.
dump = (
"POST /v1/token HTTP/1.1\r\n"
"Host: example.com\r\n"
"authorization: Bearer tok1\r\n"
"Proxy-Authorization: Basic tok2\r\n"
"COOKIE: sid=tok3\r\n"
"Content-Type: application/json\r\n"
"X-Auth-Token: tok4\r\n"
"\r\n"
"body\r\n"
)
result = redact_secrets(dump)
for tok in ("tok1", "tok2", "tok3", "tok4"):
assert tok not in result
assert "Host: example.com\r\n" in result
assert "Content-Type: application/json\r\n" in result
assert "authorization: [redacted]\r\n" in result
assert "\r\n\r\nbody\r\n" in result

# Response containing Set-Cookie.
result = redact_secrets("200 OK\nSet-Cookie: session=tok5\nContent-Length: 2\n\n{}")
assert "tok5" not in result
assert "Content-Length: 2" in result

# Non-credential headers, and names that merely resemble credential names, are untouched.
for header in (
"Content-Type: text/plain",
"Accept: */*",
"X-Request-Id: abc",
"Cookies: yes",
"Xx-Auth: keep",
):
assert redact_secrets(header) == header


def test_redact_secrets_http_client_debug_output():
"""Verify redaction of the messages emitted by http.client when debuglevel is enabled."""
redact_secrets = LoggingFilter.redact_secrets

# The 'send:' message is the repr() of the raw bytes, so line terminators are escaped.
sent = (
"send: b'GET / HTTP/1.1\\r\\nHost: localhost\\r\\nCookie: sid=tok1\\r\\n"
"Proxy-Authorization: Basic tok2\\r\\nsecret: tok3\\r\\nAccept: */*\\r\\n\\r\\n'"
)
result = redact_secrets(sent)
for tok in ("tok1", "tok2", "tok3"):
assert tok not in result
assert "Host: localhost" in result
assert "Accept: */*" in result

# Received headers are logged one per message, prefixed by "header: ".
assert redact_secrets("header: Set-Cookie: sid=tok4; HttpOnly") == "header: Set-Cookie: [redacted]"
assert redact_secrets("header: Content-Length: 2") == "header: Content-Length: 2"


# Simulate a real-world scenario.
@local_server(3335)
def test_redact_secrets_log(caplog):
Expand Down
Loading