Skip to content

Commit d27ec29

Browse files
27Bslash6Mark S
andauthored
fix(logging): sanitise error kwarg at the structured cache-operation sinks (LAB-3666) (#301)
* security(logging): sanitise error kwarg at the structured cache-operation sinks (LAB-3666) UltraOptimizedStructuredLogger.cache_operation and FeatureOrchestrator.log_cache_operation splat **kwargs into the structured payload without sanitising an `error` kwarg, so a caller passing `error=e` would emit the provider exception's text, which can carry the raw cache key (CWE-532). Both in-tree callers pre-sanitise today; this is defence in depth so the next caller need not remember. Each sink now replaces a BaseException `error` with redact_error_for_log(value) before it enters the payload. Strings pass through unchanged: they are already rendered, and re-sanitising one would emit the literal "str". redis_operation_failed passes the object and lets the sink render it; the emitted error/error_type fields are byte-identical to before. New direct-call tests drive each sink with a provider exception and a BackendError whose message embeds a sentinel key and assert over the whole emitted payload. * chore: second commit so the squash header uses the PR title This PR had a single commit whose header type is not a conventional commit type. The repository squashes with COMMIT_OR_PR_TITLE, so a single-commit PR squashes under the commit header rather than the linted PR title, and release-please would then skip the change silently. A second commit makes the squash default to the PR title, which the title lint has already checked. No code changes. * style(orchestrator): annotate log_cache_operation signature Public method on FeatureOrchestrator; add **kwargs: Any and -> None so the signature matches the repository's public-API typing rule. No behaviour change. --------- Co-authored-by: Mark S <ray@insighttimer.com>
1 parent 9b96fd2 commit d27ec29

6 files changed

Lines changed: 79 additions & 15 deletions

File tree

‎SECURITY.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ See [SSRF Protection](docs/features/ssrf-protection.md) for full details, includ
189189

190190
### Cache Key Redaction in Logs (CWE-532)
191191

192-
Cache keys can embed caller-supplied tenant/user identifiers, so **the SDK's own loggers** (`cachekit.*`) never emit them verbatim ([CWE-532][cwe-532]). Every cachekit log path — decorator error handling (structured and backwards-compat), cache-operation logs, and SWR/TTL-refresh debug logs — replaces the key with a fixed-length blake2b digest (`<redacted:…>`), keeping log lines correlatable without leaking the key. Error paths are covered centrally at the shared error sink (`FeatureOrchestrator.handle_cache_error` / `log_cache_operation`), so new call sites are redacted by construction. `BackendError` redacts the key in its formatted text (`str(e)` carries `key=<redacted:…>`), while the `.key` attribute keeps the raw caller-supplied key for programmatic use — never log `e.key`. Its free-form `message` is caller-supplied and third-party exception text (a redis `ResponseError` naming the key, a pymemcache illegal-input error echoing it) has unknown provenance — so **no cachekit log line renders `str(e)`**. Every logging call that mentions an exception goes through `redact_error_for_log`, which emits only the exception type plus, for `BackendError`, its `BackendErrorType` classification; the full exception stays on the object (`original_exception`, `.message`) for programmatic access. Operators lose the provider's message text in the log line and keep it on the exception. An architecture test (`tests/unit/test_log_redaction_architecture.py`) walks every logging call in the package — `logger.*()`, `get_logger().*()`, `getattr(logger, level)()` — and fails CI if a key-shaped value reaches one unredacted in the message, `%s` arguments, or `extra=`; if an exception — any name bound by `except ... as`, a conventional name (`e`, `exc`, `err`, `error`, `*_err`), or an attribute of one — reaches one outside `redact_error_for_log`; or if a call emits a traceback (`logger.exception`, `exc_info=`). The guarantee does not depend on the next contributor remembering it. It is flow-insensitive: build log lines inline, not via a pre-formatted variable, and bind exceptions with `except ... as` or a conventional name (an `Exception`-typed parameter called `failure` is invisible to it), or the guard cannot see them.
192+
Cache keys can embed caller-supplied tenant/user identifiers, so **the SDK's own loggers** (`cachekit.*`) never emit them verbatim ([CWE-532][cwe-532]). Every cachekit log path — decorator error handling (structured and backwards-compat), cache-operation logs, and SWR/TTL-refresh debug logs — replaces the key with a fixed-length blake2b digest (`<redacted:…>`), keeping log lines correlatable without leaking the key. Error paths are covered centrally at the shared error sink (`FeatureOrchestrator.handle_cache_error` / `log_cache_operation`), so new call sites are redacted by construction. Both structured cache-operation sinks (`FeatureOrchestrator.log_cache_operation`, `UltraOptimizedStructuredLogger.cache_operation`) also sanitise an exception passed as `error=` themselves — pass the exception object, never `str(e)`, which is emitted as-is. `BackendError` redacts the key in its formatted text (`str(e)` carries `key=<redacted:…>`), while the `.key` attribute keeps the raw caller-supplied key for programmatic use — never log `e.key`. Its free-form `message` is caller-supplied and third-party exception text (a redis `ResponseError` naming the key, a pymemcache illegal-input error echoing it) has unknown provenance — so **no cachekit log line renders `str(e)`**. Every logging call that mentions an exception goes through `redact_error_for_log`, which emits only the exception type plus, for `BackendError`, its `BackendErrorType` classification; the full exception stays on the object (`original_exception`, `.message`) for programmatic access. Operators lose the provider's message text in the log line and keep it on the exception. An architecture test (`tests/unit/test_log_redaction_architecture.py`) walks every logging call in the package — `logger.*()`, `get_logger().*()`, `getattr(logger, level)()` — and fails CI if a key-shaped value reaches one unredacted in the message, `%s` arguments, or `extra=`; if an exception — any name bound by `except ... as`, a conventional name (`e`, `exc`, `err`, `error`, `*_err`), or an attribute of one — reaches one outside `redact_error_for_log`; or if a call emits a traceback (`logger.exception`, `exc_info=`). The guarantee does not depend on the next contributor remembering it. It is flow-insensitive: build log lines inline, not via a pre-formatted variable, and bind exceptions with `except ... as` or a conventional name (an `Exception`-typed parameter called `failure` is invisible to it), or the guard cannot see them.
193193

194194
**Scope — transport logs are not covered.** The CachekitIO backend addresses entries by key in the request path (`GET /v1/cache/{key}`), and `httpx` logs every request line — method, full URL, status — at `INFO` on its own `httpx` logger. An application that enables `INFO` globally (`logging.basicConfig(level=logging.INFO)`) will therefore see raw keys in *httpx's* output on every operation, exactly as it would see any REST resource path. cachekit does not mute a third-party logger on your behalf; if your keys carry identifiers, silence or raise the level of that logger in your logging config:
195195

‎src/cachekit/decorators/orchestrator.py‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -271,13 +271,16 @@ def set_span_attributes(self, span: Any, attributes: dict[str, Any]):
271271
"""Set attributes on a span (no-op)."""
272272
pass
273273

274-
def log_cache_operation(self, **kwargs):
275-
"""Log cache operation with structured logging. Redacts ``key`` (CWE-532)."""
274+
def log_cache_operation(self, **kwargs: Any) -> None:
275+
"""Log cache operation with structured logging. Redacts ``key``, sanitises ``error`` (CWE-532)."""
276276
if self._enable_structured_logging and kwargs:
277277
operation = kwargs.get("operation", "unknown")
278278
# Redact in kwargs itself — it is splatted into the structured payload below.
279279
if "key" in kwargs:
280280
kwargs["key"] = redact_key_for_log(kwargs["key"])
281+
# CWE-532 at the sink: render an exception key-free; a str is already rendered (re-sanitising one emits "str").
282+
if isinstance(kwargs.get("error"), BaseException):
283+
kwargs["error"] = redact_error_for_log(kwargs["error"])
281284
key = kwargs.get("key", "unknown")
282285
self.log_structured("info", f"Cache operation: {operation}", cache_key=key, **kwargs)
283286

@@ -457,8 +460,8 @@ def handle_cache_error(
457460
operation=f"{operation}_failed",
458461
key=cache_key,
459462
namespace=namespace,
460-
# Key-free error text (CWE-532): an arbitrary exception's str() may echo
461-
# the raw key, so only BackendError (self-sanitising) is logged verbatim.
463+
# Key-free error text (CWE-532): type name only, or BackendError(error_type) —
464+
# no exception's str() is trusted. The sink renders a raw object the same way.
462465
error=redact_error_for_log(error),
463466
error_type=type(error).__name__,
464467
duration_ms=duration_ms,

‎src/cachekit/logging.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,10 @@ def cache_operation(self, operation: str, cache_key: str, **kwargs):
261261
# correlation between the two sinks. Sentinels stay readable too.
262262
display_key = redact_key_for_log(cache_key) if cache_key else ""
263263

264+
# CWE-532 at the sink: render an exception key-free; a str is already rendered (re-sanitising one emits "str").
265+
if isinstance(kwargs.get("error"), BaseException):
266+
kwargs["error"] = redact_error_for_log(kwargs["error"])
267+
264268
# Determine log level based on error presence
265269
level = "ERROR" if "error" in kwargs else "INFO"
266270

@@ -412,8 +416,8 @@ def _get_context(self) -> dict[str, Any]:
412416

413417
# Compatibility methods for tests
414418
def redis_operation_failed(self, operation: str, key: str, error: Exception, **kwargs):
415-
"""Log Redis operation failure. Error text is key-free (CWE-532)."""
416-
self.cache_operation(operation, key, error=redact_error_for_log(error), error_type=type(error).__name__, **kwargs)
419+
"""Log Redis operation failure. ``cache_operation`` renders the error key-free (CWE-532)."""
420+
self.cache_operation(operation, key, error=error, error_type=type(error).__name__, **kwargs)
417421

418422
def cache_hit(self, key: str, **kwargs):
419423
"""Log cache hit."""

‎tests/unit/test_error_path_key_redaction.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,53 @@ def test_arbitrary_exception_reduced_to_type_name(self) -> None:
395395
assert TENANT_KEY not in rendered
396396

397397

398+
ERROR_KWARGS = [
399+
pytest.param(ValueError(f"WRONGTYPE for {TENANT_KEY}"), "ValueError", id="provider_exception"),
400+
pytest.param(
401+
BackendError(f"provider failure for {TENANT_KEY}", error_type=BackendErrorType.TIMEOUT, key=TENANT_KEY),
402+
"BackendError(timeout)",
403+
id="backenderror_key_in_message",
404+
),
405+
pytest.param("Connection timeout", "Connection timeout", id="str_passes_through"),
406+
]
407+
408+
409+
class TestErrorKwargSanitisedAtSink:
410+
"""An ``error`` kwarg is sanitised once, at each structured sink (CWE-532, defence in depth).
411+
412+
Three in-tree callers, three shapes: ``handle_cache_error`` pre-renders to a str,
413+
``redis_operation_failed`` passes the exception object, and the circuit-breaker path
414+
in ``wrapper.py`` passes a str literal. The sink must emit all three key-free, and a
415+
str must pass through untouched (re-sanitising one would emit the literal ``"str"``).
416+
The assertion runs over the whole payload, not the ``error`` field alone.
417+
"""
418+
419+
@pytest.mark.parametrize(("error", "rendered"), ERROR_KWARGS)
420+
def test_logging_sink(self, error: object, rendered: str, caplog: pytest.LogCaptureFixture) -> None:
421+
logger = UltraOptimizedStructuredLogger("test.error_kwarg")
422+
423+
with caplog.at_level(logging.INFO, logger="test.error_kwarg"):
424+
logger.cache_operation("set", TENANT_KEY, error=error)
425+
426+
assert not any(TENANT_KEY in m for m in _messages(caplog))
427+
assert caplog.records[-1].structured["error"] == rendered
428+
429+
@pytest.mark.parametrize(("error", "rendered"), ERROR_KWARGS)
430+
def test_orchestrator_sink(self, error: object, rendered: str, caplog: pytest.LogCaptureFixture) -> None:
431+
orchestrator = FeatureOrchestrator(
432+
namespace="test",
433+
circuit_breaker_enabled=False,
434+
backpressure_enabled=False,
435+
enable_structured_logging=True,
436+
)
437+
438+
with caplog.at_level(logging.INFO):
439+
orchestrator.log_cache_operation(operation="set_failed", key=TENANT_KEY, error=error)
440+
441+
assert not any(TENANT_KEY in m for m in _messages(caplog))
442+
assert caplog.records[-1].structured["error"] == rendered
443+
444+
398445
class TestClassifierMessagesAreKeyFree:
399446
"""Every backend classifier must build a key-free BackendError.message (CWE-532).
400447

‎tests/unit/test_log_redaction_architecture.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@
2929
held in a parameter with an unconventional name (``failure: Exception``) is not
3030
recognised. Build log lines inline, and bind exceptions with ``except ... as``
3131
or a conventional name, so the guard can see them. Sink-central redaction is not
32-
exempted: the sinks' own stdlib calls satisfy the rule; callers passing raw keys
33-
*into* ``handle_cache_error`` / ``log_cache_operation`` / ``SimpleLogger.cache_*``
34-
are covered by those sinks' contract tests, not here.
32+
exempted: the sinks' own stdlib calls satisfy the rule; callers passing raw keys or
33+
exceptions *into* ``handle_cache_error`` / ``log_cache_operation`` /
34+
``cache_operation`` / ``SimpleLogger.cache_*`` are covered by those sinks' contract
35+
tests, not here.
3536
"""
3637

3738
from __future__ import annotations

‎tests/unit/test_structured_logging.py‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import pytest
1010

11+
from cachekit.backends.errors import BackendError, BackendErrorType
1112
from cachekit.logging import (
1213
JsonFormatter,
1314
StructuredRedisLogger,
@@ -115,21 +116,29 @@ def test_cache_operation_error_logging(self, mock_log, logger):
115116
assert extra["error"] == "Connection timeout"
116117
assert extra["error_type"] == "TimeoutError"
117118

119+
@pytest.mark.parametrize(
120+
("error", "rendered", "error_type"),
121+
[
122+
(ValueError("Test error"), "ValueError", "ValueError"),
123+
(BackendError("Redis timeout", error_type=BackendErrorType.TIMEOUT), "BackendError(timeout)", "BackendError"),
124+
],
125+
ids=["provider_exception", "backend_error"],
126+
)
118127
@patch("cachekit.logging.logging.Logger.log")
119-
def test_redis_operation_failed_override(self, mock_log, logger):
128+
def test_redis_operation_failed_override(self, mock_log, logger, error, rendered, error_type):
120129
"""redis_operation_failed emits a key-free error representation (CWE-532).
121130
122131
A non-BackendError's str() has unknown provenance and may echo the raw cache
123-
key, so only its type name reaches the log; error_type still carries the type.
132+
key, so only its type name reaches the log; a BackendError renders as
133+
``TypeName(error_type)``. error_type still carries the Python type.
124134
"""
125-
error = ValueError("Test error")
126135
logger.redis_operation_failed("get", "test_key", error)
127136

128137
mock_log.assert_called_once()
129138
extra = mock_log.call_args[1]["extra"]["structured"]
130139
assert extra["operation"] == "get"
131-
assert extra["error"] == "ValueError" # not the raw "Test error" message
132-
assert extra["error_type"] == "ValueError"
140+
assert extra["error"] == rendered # never the raw message
141+
assert extra["error_type"] == error_type
133142

134143
@patch("cachekit.logging.logging.Logger.log")
135144
def test_cache_hit_override(self, mock_log, logger):

0 commit comments

Comments
 (0)