Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
63d9132
fix(logging): redact cache keys at the shared error sink (LAB-304)
27Bslash6 Aug 30, 2026
9eab94f
fix(logging): redact remaining raw-key log sites tree-wide (LAB-304)
27Bslash6 Aug 30, 2026
238f4a4
chore: remove stray review-scratch diff file (LAB-304)
27Bslash6 Aug 30, 2026
fdafd01
fix(logging): cover error-path redaction with tests; bump pip floor f…
27Bslash6 Aug 30, 2026
352f828
fix: address coderabbit review — redact BackendError key in exception…
27Bslash6 Aug 30, 2026
f34f042
fix(logging): close residual raw-key channels found by panel review (…
27Bslash6 Aug 30, 2026
9f5c390
fix(logging): share one redaction policy between both log sinks (LAB-…
27Bslash6 Aug 30, 2026
b05c7ee
fix(logging): expert-panel remediation on the shared redaction guard …
27Bslash6 Aug 30, 2026
551d50e
fix(logging): scope the CWE-532 guarantee and guard it with an archit…
27Bslash6 Sep 3, 2026
545f335
fix(logging): sanitise exception text on error sinks; extend logger d…
27Bslash6 Sep 3, 2026
f2a7e5a
fix(logging): close redis/http BackendError.message key leak (LAB-304)
27Bslash6 Sep 3, 2026
a8241a3
fix(logging): redact_error_for_log renders BackendError structurally,…
27Bslash6 Sep 3, 2026
1810c57
merge: main into lab-304-redact-error-sink (LAB-304)
Sep 11, 2026
6d4d421
fix(logging): render exceptions structurally at every log sink; guard…
Sep 12, 2026
acbc605
fix(logging): guard directly-imported logging functions; cover the as…
Sep 12, 2026
fd65fe2
test(logging): drive the L2 read sinks and wrapper error paths for re…
Sep 12, 2026
c48643f
test(logging): pin the L1 sink and harden the redaction helpers — pan…
Sep 12, 2026
9c76386
fix: address coderabbit review — propagate log aliases through nested…
27Bslash6 Sep 12, 2026
cbee140
merge: main into lab-304-redact-error-sink (LAB-304)
Sep 13, 2026
d34f4fd
merge: main into lab-304-redact-error-sink (LAB-304)
Sep 14, 2026
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
4 changes: 2 additions & 2 deletions .secrets.baseline

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

16 changes: 16 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,22 @@ When using `@cache.io` (CachekitIOBackend), the SDK includes built-in Server-Sid

See [SSRF Protection](docs/features/ssrf-protection.md) for full details, including custom host configuration for development environments.

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

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.

**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:

```python
import logging

logging.getLogger("httpx").setLevel(logging.WARNING)
```

The same applies to any HTTP-layer capture between the SDK and `api.cachekit.io` — see the lock-token paragraph below for why path/query content is treated as logged.

**Digest strength.** The redaction digest is *unkeyed* blake2b, so it is exactly as hard to reverse as the key material is to guess — and the key material is deterministic from the call: `[ns:{ns}:]func:{mod.fn}:args:{blake2b(args)}` for generated keys, or whatever you return from `@cache(key=...)`. Namespace and function name are static application config, so a cache on `get_user(user_id)` is enumerable from its digest by iterating plausible IDs, whether the key was generated (hash the candidate args) or hand-built (`default:user:1234`). A per-installation secret was considered and rejected for a public library (unset it is theatre; set it breaks cross-process log correlation, the property the digest exists for). Treat the digest as a correlation ID, never as a secret: if a log reader must not be able to confirm *which* user an entry belongs to, do not grant that reader the logs.

### Lock Token Transport (CWE-532)

The distributed-lock capability token (`lock_id`) is sent in the `X-CacheKit-Lock-Id` request header when releasing a lock (`DELETE /v1/cache/{key}/lock`), **never** in the URL query string. Query strings are routinely captured by access logs, proxy/CDN logs, and OpenTelemetry `http.url` spans ([CWE-532][cwe-532]); a leaked token could be replayed to release a lock within its short TTL. The CacheKit SaaS backend dual-reads the header and the legacy `?lock_id=` query during migration, preferring the header (removed in protocol 2.0).
Expand Down
3 changes: 2 additions & 1 deletion src/cachekit/backends/cachekitio/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from cachekit.backends.cachekitio.error_handler import classify_http_error
from cachekit.backends.errors import BackendError, BackendErrorType
from cachekit.decorators.stats_context import get_current_function_stats
from cachekit.hash_utils import redact_error_for_log
from cachekit.logging import get_structured_logger

if TYPE_CHECKING:
Expand Down Expand Up @@ -159,7 +160,7 @@ def _inject_metrics_headers(stats: _FunctionStats | None) -> dict[str, str]:
except Exception as e:
# Session header generation failed - continue without session headers
# This ensures backend requests never fail due to session tracking issues
_logger.debug(f"Session header generation failed: {e}")
_logger.debug(f"Session header generation failed: {redact_error_for_log(e)}")
session_headers = {}

# Build metrics headers
Expand Down
17 changes: 11 additions & 6 deletions src/cachekit/backends/cachekitio/error_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,29 +99,34 @@ def classify_http_error(
key=key,
)

# TIMEOUT: Request exceeded time limit
# TIMEOUT: Request exceeded time limit.
# Only the exception TYPE goes in the message: httpx exception text embeds the
# request URL, which carries the raw cache key in its path, and the message reaches
# log sinks via str(e) (CWE-532, LAB-304). Detail stays on original_exception.
if isinstance(exc, httpx.TimeoutException):
return BackendError(
f"Request timeout: {exc}",
f"Request timeout: {type(exc).__name__}",
error_type=BackendErrorType.TIMEOUT,
original_exception=exc,
operation=operation,
key=key,
)

# TRANSIENT: Connection failures (retry)
# TRANSIENT: Connection failures (retry). Type-only message — httpx text can echo
# the request URL (raw key in path), and str(e) reaches log sinks (CWE-532).
if isinstance(exc, (httpx.ConnectError, httpx.NetworkError)):
return BackendError(
f"Connection failed: {exc}",
f"Connection failed: {type(exc).__name__}",
error_type=BackendErrorType.TRANSIENT,
original_exception=exc,
operation=operation,
key=key,
)

# UNKNOWN: Unclassified error
# UNKNOWN: Unclassified error. Type-only message (CWE-532): arbitrary httpx text
# can echo the request URL, which carries the raw key. Detail on original_exception.
return BackendError(
f"Unknown HTTP error: {exc}",
f"Unknown HTTP error: {type(exc).__name__}",
error_type=BackendErrorType.UNKNOWN,
original_exception=exc,
operation=operation,
Expand Down
15 changes: 11 additions & 4 deletions src/cachekit/backends/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from enum import Enum
from typing import Optional

from ..hash_utils import redact_cache_key


class BackendErrorType(str, Enum):
"""Error classification for circuit breaker and retry decisions.
Expand Down Expand Up @@ -52,7 +54,9 @@ class BackendError(Exception):
error_type: Error classification (see BackendErrorType)
original_exception: The original exception that caused this error (if any)
operation: The operation that failed (get, set, delete, exists)
key: The cache key involved in the operation (optional, for debugging)
key: The cache key involved in the operation (optional, for debugging).
Kept raw on the attribute for programmatic access; the formatted
exception text carries only its redacted digest (CWE-532).

Example:
>>> from redis import ConnectionError as RedisConnectionError
Expand Down Expand Up @@ -99,9 +103,12 @@ def _format_message(self) -> str:
if self.operation:
parts.append(f"operation={self.operation}")
if self.key:
# Truncate key for security/readability
key_display = self.key[:50] + "..." if len(self.key) > 50 else self.key
parts.append(f"key={key_display}")
# Redact, don't truncate: cachekit's own sinks never render str(e)
# (they go through redact_error_for_log), but application code may
# log it, and cache keys embed caller-supplied tenant/user identifiers
# (CWE-532, LAB-304). The fixed-length digest keeps that text
# correlatable with the sinks' own redact_cache_key() output.
parts.append(f"key={redact_cache_key(self.key)}")
if self.error_type:
parts.append(f"type={self.error_type.value}")
return " | ".join(parts)
Expand Down
5 changes: 4 additions & 1 deletion src/cachekit/backends/memcached/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,10 @@ def set(self, key: str, value: bytes, ttl: Optional[int] = None) -> None:
if max_size and len(value) > max_size:
raise BackendError(
message=(
f"Value for key {key!r} is {len(value)} bytes, which exceeds the Memcached "
# No raw key in the message — it reaches log sinks via str(e)
# (CWE-532); the key= segment _format_message appends carries
# the redacted digest for correlation.
f"Value is {len(value)} bytes, which exceeds the Memcached "
f"max item size of {max_size} bytes. Memcached cannot store it. Enable "
f"compression, use a larger-payload backend (Redis/SaaS/File), or raise both "
f"the server's -I limit and CACHEKIT_MEMCACHED_MAX_ITEM_SIZE_BYTES."
Expand Down
26 changes: 18 additions & 8 deletions src/cachekit/backends/memcached/error_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,39 +48,49 @@ def classify_memcached_error(
MemcacheUnexpectedCloseError,
)

# Timeout — socket.timeout or OSError with ETIMEDOUT
# Timeout — socket.timeout or OSError with ETIMEDOUT.
# Only the exception TYPE goes in the message: wrapped provider text has
# unknown provenance and may echo the raw cache key, and the message reaches
# log sinks via str(e) (CWE-532). Full details stay on original_exception.
if isinstance(exc, (socket.timeout, TimeoutError)):
return BackendError(
message=f"Memcached timeout during {operation}: {exc}",
message=f"Memcached timeout during {operation}: {type(exc).__name__}",
error_type=BackendErrorType.TIMEOUT,
original_exception=exc,
operation=operation,
key=key,
)

# Transient — connection closed, server errors (retriable)
# Transient — connection closed, server errors (retriable). Type-only message:
# pymemcache close/server errors can echo the raw key, and str(e) reaches log
# sinks (CWE-532). Detail stays on original_exception.
if isinstance(exc, (MemcacheUnexpectedCloseError, MemcacheServerError, ConnectionError, OSError)):
return BackendError(
message=f"Memcached transient error during {operation}: {exc}",
message=f"Memcached transient error during {operation}: {type(exc).__name__}",
error_type=BackendErrorType.TRANSIENT,
original_exception=exc,
operation=operation,
key=key,
)
Comment thread
kodus-27b[bot] marked this conversation as resolved.

# Permanent — illegal input, client errors (don't retry)
# Permanent — illegal input, client errors (don't retry).
# Only the exception TYPE goes in the message: pymemcache embeds the raw
# cache key in illegal-input error text ("Key is too long: %r"), and the
# message reaches log sinks via str(e) (CWE-532). Full details stay on
# original_exception for programmatic access.
if isinstance(exc, (MemcacheIllegalInputError, MemcacheClientError)):
return BackendError(
message=f"Memcached permanent error during {operation}: {exc}",
message=f"Memcached permanent error during {operation}: {type(exc).__name__}",
error_type=BackendErrorType.PERMANENT,
original_exception=exc,
operation=operation,
key=key,
)

# Unknown — safe default
# Unknown — safe default. Arbitrary exception text has unknown provenance
# and may embed the key, so only the type name goes in the message (CWE-532).
return BackendError(
message=f"Memcached unknown error during {operation}: {exc}",
message=f"Memcached unknown error during {operation}: {type(exc).__name__}",
error_type=BackendErrorType.UNKNOWN,
original_exception=exc,
operation=operation,
Expand Down
18 changes: 10 additions & 8 deletions src/cachekit/backends/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

from typing import TYPE_CHECKING, Optional

from cachekit.hash_utils import redact_key_for_log

if TYPE_CHECKING:
import redis
import redis.asyncio as redis_async
Expand Down Expand Up @@ -59,21 +61,21 @@ def error(self, message: str):
self._logger.error(message)

def cache_hit(self, key: str, source: str = "Redis"):
"""Log cache hits."""
self._logger.debug(f"{source} cache hit for key: {key}")
"""Log cache hits. Keys are redacted — they embed caller identifiers (CWE-532)."""
self._logger.debug(f"{source} cache hit for key: {redact_key_for_log(key)}")

def cache_miss(self, key: str):
"""Log cache misses."""
self._logger.debug(f"Cache miss for key: {key}")
"""Log cache misses. Keys are redacted — they embed caller identifiers (CWE-532)."""
self._logger.debug(f"Cache miss for key: {redact_key_for_log(key)}")

def cache_stored(self, key: str, ttl=None):
"""Log cache storage operations."""
"""Log cache storage operations. Keys are redacted — they embed caller identifiers (CWE-532)."""
ttl_info = f" with TTL {ttl}" if ttl else ""
self._logger.debug(f"Cached result for key: {key}{ttl_info}")
self._logger.debug(f"Cached result for key: {redact_key_for_log(key)}{ttl_info}")

def cache_invalidated(self, key: str, source: str = "Redis"):
"""Log cache invalidation."""
self._logger.debug(f"Invalidated {source} cache for key: {key}")
"""Log cache invalidation. Keys are redacted — they embed caller identifiers (CWE-532)."""
self._logger.debug(f"Invalidated {source} cache for key: {redact_key_for_log(key)}")


class DefaultLoggerProvider(LoggerProvider):
Expand Down
10 changes: 5 additions & 5 deletions src/cachekit/backends/redis/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ def _get_client(self) -> redis.Redis:
return self._client_provider.get_sync_client()
except Exception as e:
raise BackendError(
message=f"Failed to create Redis client: {e}",
message=f"Failed to create Redis client: {type(e).__name__}",
operation="get_client",
) from e

Expand All @@ -139,7 +139,7 @@ def get(self, key: str) -> Optional[bytes]:
return value if isinstance(value, bytes) else None
except Exception as e:
raise BackendError(
message=f"Redis GET failed: {e}",
message=f"Redis GET failed: {type(e).__name__}",
operation="get",
key=key,
) from e
Expand All @@ -165,7 +165,7 @@ def set(self, key: str, value: bytes, ttl: Optional[int] = None) -> None:
client.set(key, value)
except Exception as e:
raise BackendError(
message=f"Redis SET failed: {e}",
message=f"Redis SET failed: {type(e).__name__}",
operation="set",
key=key,
) from e
Expand Down Expand Up @@ -195,7 +195,7 @@ def delete(self, key: str) -> bool:
return result > 0
except Exception as e:
raise BackendError(
message=f"Redis DELETE failed: {e}",
message=f"Redis DELETE failed: {type(e).__name__}",
operation="delete",
key=key,
) from e
Expand Down Expand Up @@ -225,7 +225,7 @@ def exists(self, key: str) -> bool:
return result > 0
except Exception as e:
raise BackendError(
message=f"Redis EXISTS failed: {e}",
message=f"Redis EXISTS failed: {type(e).__name__}",
operation="exists",
key=key,
) from e
Expand Down
Loading
Loading