diff --git a/common/logging_config.py b/common/logging_config.py index 415144e..b779ca6 100644 --- a/common/logging_config.py +++ b/common/logging_config.py @@ -36,6 +36,41 @@ def _add_trace_context(logger, method_name, event_dict): return event_dict +# Field names whose values never reach a log line. Keys are compared +# lowercased with "-" folded to "_", so header names (X-Api-Key) match too. +# Fragments match anywhere in the key (db_password, refresh_token); the exact +# names are too short or too specific to match as fragments ("pan" would +# catch "company"). +_REDACT_FRAGMENTS = ("password", "passwd", "secret", "token", "authorization", "cookie", "api_key") +_REDACT_EXACT = {"pan", "card_number", "cvv", "cvc", "holder_name"} +_REDACTED = "[REDACTED]" + + +def _is_sensitive(key) -> bool: + k = str(key).lower().replace("-", "_") + return k in _REDACT_EXACT or any(f in k for f in _REDACT_FRAGMENTS) + + +def _scrub(value): + if isinstance(value, dict): + return {k: _REDACTED if _is_sensitive(k) else _scrub(v) for k, v in value.items()} + if isinstance(value, list): + return [_scrub(v) for v in value] + if isinstance(value, tuple): + # Stays a tuple: format_exc_info only trusts an exc_info that is one. + return tuple(_scrub(v) for v in value) + return value + + +def _redact(logger, method_name, event_dict): + """Mask denylisted fields, at any depth (a logged headers dict included). + + This works on field names only, so it is only as good as the call sites: + a value interpolated into the `event` string is invisible to it. + """ + return _scrub(event_dict) + + def configure_logging(service_name: str) -> structlog.stdlib.BoundLogger: """One JSON object per line on stdout, for structlog and stdlib loggers alike. @@ -50,6 +85,7 @@ def configure_logging(service_name: str) -> structlog.stdlib.BoundLogger: structlog.processors.TimeStamper(fmt="iso", utc=True), _add_service(service_name), _add_trace_context, + _redact, ] structlog.configure(