From 0b14af528d7ff8d20eea43ef82c54fa6ee4d2017 Mon Sep 17 00:00:00 2001 From: Mark S Date: Thu, 17 Sep 2026 15:01:32 +1000 Subject: [PATCH 1/5] docs(security): document BackendError traceback redaction gap (LAB-3796) BackendError.original_exception / __cause__ deliberately retains the raw provider exception for programmatic access, and that text can embed the cache key. The SDK never renders it (no logger.exception/exc_info= in src/cachekit/), but application code that logs a caught exception's traceback still can. SECURITY.md now states the boundary and the redact_error_for_log mitigation; no mirrored surface exists on docs.cachekit.io. --- SECURITY.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 22dd6aee..54a62870 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -189,7 +189,9 @@ See [SSRF Protection](docs/features/ssrf-protection.md) for full details, includ ### 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 (``), 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=`), 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. +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 (``), 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=`), while the `.key` attribute keeps the raw caller-supplied key for programmatic use — never log `e.key` (see below for the same caution applied to `e`'s traceback). 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 — application-rendered tracebacks are not covered.** `BackendError.original_exception` (and the `from exc` chain that sets `__cause__`) deliberately keeps the original provider exception for programmatic access, and that exception's own text can embed the raw key — a pymemcache `MemcacheIllegalInputError` echoing an oversized key, a redis `ResponseError` naming it, or an httpx error string carrying the CachekitIO request path. cachekit itself never renders that text: no `cachekit.*` log line calls `logger.exception()` or passes `exc_info=`, so the SDK never prints a traceback, which is the same architecture test enforcing the guarantee above. The remaining path is your own logging code: if application code catches a `BackendError` and calls `logger.exception(e)`, sets `exc_info=True`, calls `traceback.format_exc()`, or hands the exception to an APM/error-tracking SDK, the rendered traceback includes the chained cause and its raw key. Log `redact_error_for_log(e)` (`from cachekit.hash_utils import redact_error_for_log`) or `type(e).__name__` instead of the traceback for a cachekit exception, or scrub `__cause__` before handing it to an error tracker. **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: From 276348f6ceedc0986e5163a37b3340473780e593 Mon Sep 17 00:00:00 2001 From: 27Bslash6 Date: Thu, 17 Sep 2026 18:11:09 +1000 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20address=20coderabbit=20review=20?= =?UTF-8?q?=E2=80=94=20scrub=20original=5Fexception,=20not=20only=20=5F=5F?= =?UTF-8?q?cause=5F=5F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The traceback-scope guidance told operators to scrub `__cause__` before handing a BackendError to an error tracker, but the same paragraph documents that `BackendError.original_exception` also retains the raw provider exception. An error/APM SDK that serialises exception attributes would capture the provider text (and any embedded key) from `original_exception` even with `__cause__` cleared. Widen the guidance to scrub both. CodeRabbit-Resolved: SECURITY.md:194:sanitize or remove BackendError.original_exception --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 54a62870..7b958274 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -191,7 +191,7 @@ See [SSRF Protection](docs/features/ssrf-protection.md) for full details, includ 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 (``), 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=`), while the `.key` attribute keeps the raw caller-supplied key for programmatic use — never log `e.key` (see below for the same caution applied to `e`'s traceback). 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 — application-rendered tracebacks are not covered.** `BackendError.original_exception` (and the `from exc` chain that sets `__cause__`) deliberately keeps the original provider exception for programmatic access, and that exception's own text can embed the raw key — a pymemcache `MemcacheIllegalInputError` echoing an oversized key, a redis `ResponseError` naming it, or an httpx error string carrying the CachekitIO request path. cachekit itself never renders that text: no `cachekit.*` log line calls `logger.exception()` or passes `exc_info=`, so the SDK never prints a traceback, which is the same architecture test enforcing the guarantee above. The remaining path is your own logging code: if application code catches a `BackendError` and calls `logger.exception(e)`, sets `exc_info=True`, calls `traceback.format_exc()`, or hands the exception to an APM/error-tracking SDK, the rendered traceback includes the chained cause and its raw key. Log `redact_error_for_log(e)` (`from cachekit.hash_utils import redact_error_for_log`) or `type(e).__name__` instead of the traceback for a cachekit exception, or scrub `__cause__` before handing it to an error tracker. +**Scope — application-rendered tracebacks are not covered.** `BackendError.original_exception` (and the `from exc` chain that sets `__cause__`) deliberately keeps the original provider exception for programmatic access, and that exception's own text can embed the raw key — a pymemcache `MemcacheIllegalInputError` echoing an oversized key, a redis `ResponseError` naming it, or an httpx error string carrying the CachekitIO request path. cachekit itself never renders that text: no `cachekit.*` log line calls `logger.exception()` or passes `exc_info=`, so the SDK never prints a traceback, which is the same architecture test enforcing the guarantee above. The remaining path is your own logging code: if application code catches a `BackendError` and calls `logger.exception(e)`, sets `exc_info=True`, calls `traceback.format_exc()`, or hands the exception to an APM/error-tracking SDK, the rendered traceback includes the chained cause and its raw key. Log `redact_error_for_log(e)` (`from cachekit.hash_utils import redact_error_for_log`) or `type(e).__name__` instead of the traceback for a cachekit exception. If you must hand the exception itself to an error/APM tracker, scrub **both** the chained cause (`__cause__`) and `BackendError.original_exception` first — an SDK that serialises exception attributes captures the raw provider text (and any key it embeds) from `original_exception` even when `__cause__` has been cleared. **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: From c1afb55975c7249cc1d7a3fb074624f78dd6b4c4 Mon Sep 17 00:00:00 2001 From: 27Bslash6 Date: Thu, 17 Sep 2026 18:25:08 +1000 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20address=20coderabbit=20review=20?= =?UTF-8?q?=E2=80=94=20qualify=20traceback=20guarantee,=20name=20JsonForma?= =?UTF-8?q?tter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guarantee that "no cachekit.* log line renders a traceback" scopes to cachekit's own logging calls. cachekit also ships JsonFormatter, which renders any supplied record.exc_info via traceback.format_exception — so an application that wires JsonFormatter and logs a BackendError with exc_info renders the chained cause and its raw key, exactly like the other application paths. Note that path explicitly so the guarantee is not read as covering the shipped formatter. CodeRabbit-Resolved: SECURITY.md:194:Qualify the traceback guarantee to internal cachekit logging paths --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 7b958274..d93b6038 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -191,7 +191,7 @@ See [SSRF Protection](docs/features/ssrf-protection.md) for full details, includ 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 (``), 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=`), while the `.key` attribute keeps the raw caller-supplied key for programmatic use — never log `e.key` (see below for the same caution applied to `e`'s traceback). 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 — application-rendered tracebacks are not covered.** `BackendError.original_exception` (and the `from exc` chain that sets `__cause__`) deliberately keeps the original provider exception for programmatic access, and that exception's own text can embed the raw key — a pymemcache `MemcacheIllegalInputError` echoing an oversized key, a redis `ResponseError` naming it, or an httpx error string carrying the CachekitIO request path. cachekit itself never renders that text: no `cachekit.*` log line calls `logger.exception()` or passes `exc_info=`, so the SDK never prints a traceback, which is the same architecture test enforcing the guarantee above. The remaining path is your own logging code: if application code catches a `BackendError` and calls `logger.exception(e)`, sets `exc_info=True`, calls `traceback.format_exc()`, or hands the exception to an APM/error-tracking SDK, the rendered traceback includes the chained cause and its raw key. Log `redact_error_for_log(e)` (`from cachekit.hash_utils import redact_error_for_log`) or `type(e).__name__` instead of the traceback for a cachekit exception. If you must hand the exception itself to an error/APM tracker, scrub **both** the chained cause (`__cause__`) and `BackendError.original_exception` first — an SDK that serialises exception attributes captures the raw provider text (and any key it embeds) from `original_exception` even when `__cause__` has been cleared. +**Scope — application-rendered tracebacks are not covered.** `BackendError.original_exception` (and the `from exc` chain that sets `__cause__`) deliberately keeps the original provider exception for programmatic access, and that exception's own text can embed the raw key — a pymemcache `MemcacheIllegalInputError` echoing an oversized key, a redis `ResponseError` naming it, or an httpx error string carrying the CachekitIO request path. cachekit itself never renders that text: no `cachekit.*` log line calls `logger.exception()` or passes `exc_info=`, so the SDK never prints a traceback, which is the same architecture test enforcing the guarantee above. That guarantee scopes to cachekit's own logging *calls*, not to the `JsonFormatter` cachekit ships (`cachekit.logging.JsonFormatter`): that formatter renders whatever `record.exc_info` a caller supplies via `traceback.format_exception`, so wiring it into your application's logging and emitting a `BackendError` with `exc_info` set renders the chained cause and its raw key exactly like the application paths below. The remaining path is your own logging code: if application code catches a `BackendError` and calls `logger.exception(e)`, sets `exc_info=True`, calls `traceback.format_exc()`, or hands the exception to an APM/error-tracking SDK, the rendered traceback includes the chained cause and its raw key. Log `redact_error_for_log(e)` (`from cachekit.hash_utils import redact_error_for_log`) or `type(e).__name__` instead of the traceback for a cachekit exception. If you must hand the exception itself to an error/APM tracker, scrub **both** the chained cause (`__cause__`) and `BackendError.original_exception` first — an SDK that serialises exception attributes captures the raw provider text (and any key it embeds) from `original_exception` even when `__cause__` has been cleared. **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: From a9d1e05b7682bfb361f60adf7406f5ceba4d1b1e Mon Sep 17 00:00:00 2001 From: Winston Date: Sat, 19 Sep 2026 01:17:55 +1000 Subject: [PATCH 4/5] docs(security): name __context__ as the third provider-exception reference (LAB-3796) `raise classify_*(exc) from exc` inside the handling `except` sets `__context__` as well as `__cause__`, so the tracker-submission guidance that cleared only `__cause__` and `original_exception` still left the raw provider text reachable to attribute-walking SDKs. Name all three and lead with the robust option: submit a freshly constructed sanitised exception. --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 782ff494..60b0ad1f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -191,7 +191,7 @@ See [SSRF Protection](docs/features/ssrf-protection.md) for full details, includ 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 (``), 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=`), while the `.key` attribute keeps the raw caller-supplied key for programmatic use — never log `e.key` (see below for the same caution applied to `e`'s traceback). 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 — application-rendered tracebacks are not covered.** `BackendError.original_exception` (and the `from exc` chain that sets `__cause__`) deliberately keeps the original provider exception for programmatic access, and that exception's own text can embed the raw key — a pymemcache `MemcacheIllegalInputError` echoing an oversized key, a redis `ResponseError` naming it, or an httpx error string carrying the CachekitIO request path. cachekit itself never renders that text: no `cachekit.*` log line calls `logger.exception()` or passes `exc_info=`, so the SDK never prints a traceback, which is the same architecture test enforcing the guarantee above. That guarantee scopes to cachekit's own logging *calls*, not to the `JsonFormatter` cachekit ships (`cachekit.logging.JsonFormatter`): that formatter renders whatever `record.exc_info` a caller supplies via `traceback.format_exception`, so wiring it into your application's logging and emitting a `BackendError` with `exc_info` set renders the chained cause and its raw key exactly like the application paths below. The remaining path is your own logging code: if application code catches a `BackendError` and calls `logger.exception(e)`, sets `exc_info=True`, calls `traceback.format_exc()`, or hands the exception to an APM/error-tracking SDK, the rendered traceback includes the chained cause and its raw key. Log `redact_error_for_log(e)` (`from cachekit.hash_utils import redact_error_for_log`) or `type(e).__name__` instead of the traceback for a cachekit exception. If you must hand the exception itself to an error/APM tracker, scrub **both** the chained cause (`__cause__`) and `BackendError.original_exception` first — an SDK that serialises exception attributes captures the raw provider text (and any key it embeds) from `original_exception` even when `__cause__` has been cleared. +**Scope — application-rendered tracebacks are not covered.** `BackendError.original_exception` (and the `from exc` chain that sets `__cause__`) deliberately keeps the original provider exception for programmatic access, and that exception's own text can embed the raw key — a pymemcache `MemcacheIllegalInputError` echoing an oversized key, a redis `ResponseError` naming it, or an httpx error string carrying the CachekitIO request path. cachekit itself never renders that text: no `cachekit.*` log line calls `logger.exception()` or passes `exc_info=`, so the SDK never prints a traceback, which is the same architecture test enforcing the guarantee above. That guarantee scopes to cachekit's own logging *calls*, not to the `JsonFormatter` cachekit ships (`cachekit.logging.JsonFormatter`): that formatter renders whatever `record.exc_info` a caller supplies via `traceback.format_exception`, so wiring it into your application's logging and emitting a `BackendError` with `exc_info` set renders the chained cause and its raw key exactly like the application paths below. The remaining path is your own logging code: if application code catches a `BackendError` and calls `logger.exception(e)`, sets `exc_info=True`, calls `traceback.format_exc()`, or hands the exception to an APM/error-tracking SDK, the rendered traceback includes the chained cause and its raw key. Log `redact_error_for_log(e)` (`from cachekit.hash_utils import redact_error_for_log`) or `type(e).__name__` instead of the traceback for a cachekit exception. If you must hand the exception itself to an error/APM tracker, submit a freshly constructed exception carrying only `redact_error_for_log(e)`; failing that, clear **all three** references to the provider exception on `e` first — `__cause__`, `__context__`, and `BackendError.original_exception`. The backends raise the classified `BackendError` from inside the `except` block that caught the provider exception, so Python sets `__context__` as well as `__cause__`; clearing `__cause__` alone hides the provider text from `traceback` but leaves it reachable to an SDK that walks exception attributes, and `original_exception` is a third reference. **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: From 144f73c52aada1952697caf3defba46361ca5df4 Mon Sep 17 00:00:00 2001 From: Mark S Date: Sat, 19 Sep 2026 12:01:28 +1000 Subject: [PATCH 5/5] docs(security): name __traceback__ as a fourth scrub target (LAB-3796) Clearing __cause__, __context__ and original_exception removes every reference to the provider exception's text, but not the raw key itself: the backends raise the classified BackendError from inside the except block, so the traceback retains the backend frame whose locals still hold `key`. A tracker that captures frame locals reads it straight off the frame with all three references cleared. Verified against the real raise sites: a BackendError raised the way MemcachedBackend.get raises it still exposes `get.key` via __traceback__ frame locals after the three references are scrubbed, and clearing __traceback__ closes both the rendered path and the sys.exc_info() path (CPython reads exc.__traceback__ for both). Co-authored-by: Mark S --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 60b0ad1f..b2d16282 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -191,7 +191,7 @@ See [SSRF Protection](docs/features/ssrf-protection.md) for full details, includ 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 (``), 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=`), while the `.key` attribute keeps the raw caller-supplied key for programmatic use — never log `e.key` (see below for the same caution applied to `e`'s traceback). 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 — application-rendered tracebacks are not covered.** `BackendError.original_exception` (and the `from exc` chain that sets `__cause__`) deliberately keeps the original provider exception for programmatic access, and that exception's own text can embed the raw key — a pymemcache `MemcacheIllegalInputError` echoing an oversized key, a redis `ResponseError` naming it, or an httpx error string carrying the CachekitIO request path. cachekit itself never renders that text: no `cachekit.*` log line calls `logger.exception()` or passes `exc_info=`, so the SDK never prints a traceback, which is the same architecture test enforcing the guarantee above. That guarantee scopes to cachekit's own logging *calls*, not to the `JsonFormatter` cachekit ships (`cachekit.logging.JsonFormatter`): that formatter renders whatever `record.exc_info` a caller supplies via `traceback.format_exception`, so wiring it into your application's logging and emitting a `BackendError` with `exc_info` set renders the chained cause and its raw key exactly like the application paths below. The remaining path is your own logging code: if application code catches a `BackendError` and calls `logger.exception(e)`, sets `exc_info=True`, calls `traceback.format_exc()`, or hands the exception to an APM/error-tracking SDK, the rendered traceback includes the chained cause and its raw key. Log `redact_error_for_log(e)` (`from cachekit.hash_utils import redact_error_for_log`) or `type(e).__name__` instead of the traceback for a cachekit exception. If you must hand the exception itself to an error/APM tracker, submit a freshly constructed exception carrying only `redact_error_for_log(e)`; failing that, clear **all three** references to the provider exception on `e` first — `__cause__`, `__context__`, and `BackendError.original_exception`. The backends raise the classified `BackendError` from inside the `except` block that caught the provider exception, so Python sets `__context__` as well as `__cause__`; clearing `__cause__` alone hides the provider text from `traceback` but leaves it reachable to an SDK that walks exception attributes, and `original_exception` is a third reference. +**Scope — application-rendered tracebacks are not covered.** `BackendError.original_exception` (and the `from exc` chain that sets `__cause__`) deliberately keeps the original provider exception for programmatic access, and that exception's own text can embed the raw key — a pymemcache `MemcacheIllegalInputError` echoing an oversized key, a redis `ResponseError` naming it, or an httpx error string carrying the CachekitIO request path. cachekit itself never renders that text: no `cachekit.*` log line calls `logger.exception()` or passes `exc_info=`, so the SDK never prints a traceback, which is the same architecture test enforcing the guarantee above. That guarantee scopes to cachekit's own logging *calls*, not to the `JsonFormatter` cachekit ships (`cachekit.logging.JsonFormatter`): that formatter renders whatever `record.exc_info` a caller supplies via `traceback.format_exception`, so wiring it into your application's logging and emitting a `BackendError` with `exc_info` set renders the chained cause and its raw key exactly like the application paths below. The remaining path is your own logging code: if application code catches a `BackendError` and calls `logger.exception(e)`, sets `exc_info=True`, calls `traceback.format_exc()`, or hands the exception to an APM/error-tracking SDK, the rendered traceback includes the chained cause and its raw key. Log `redact_error_for_log(e)` (`from cachekit.hash_utils import redact_error_for_log`) or `type(e).__name__` instead of the traceback for a cachekit exception. If you must hand the exception itself to an error/APM tracker, submit a freshly constructed exception carrying only `redact_error_for_log(e)`; failing that, clear **all three** references to the provider exception on `e` first — `__cause__`, `__context__`, and `BackendError.original_exception` — **and `e.__traceback__` with them**. The backends raise the classified `BackendError` from inside the `except` block that caught the provider exception, so Python sets `__context__` as well as `__cause__`; clearing `__cause__` alone hides the provider text from `traceback` but leaves it reachable to an SDK that walks exception attributes, and `original_exception` is a third reference. `__traceback__` leaks by a different route than the other three: they carry the provider exception's *text*, whereas the traceback carries the backend *frame* that raised — and that frame's locals still hold the raw key (`MemcachedBackend.get` raises `classify_memcached_error(exc, operation="get", key=key) from exc`), so a tracker that captures frame locals reads the key off the frame even with all three references cleared. Clearing `e.__traceback__` also clears what `sys.exc_info()` reports for that exception, so it closes the `capture_exception()`-style path as well as the rendered one. **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: