fix(logging): close the three holes the #1452 fallback shipped with - #1504
fix(logging): close the three holes the #1452 fallback shipped with#1504groupthinking wants to merge 1 commit into
Conversation
#1491 landed the #1452 fallback: when `json.dumps` raises, re-serialize from the scalar fields so a bad enrichment costs its own value rather than the whole record. The retry itself contains three steps that can raise -- inside the handler that exists because raising is the failure mode. Measured against 8517bf8 with a healthy -> poisoned -> healthy probe: 1. `isinstance(value, str)` consults `value.__class__`, which a property can forge. The value passes the filter, reaches a `json.dumps` that still carried `default=str`, and its raising `__str__` kills the record. 2 of 3 records reach the sink. 2. `f"{type(exc).__name__}: {exc}"` renders the caught exception unguarded. An exception whose own `__str__` raises kills the record. 2 of 3. 3. A non-finite float serializes to the bare literal `NaN`, which is not valid JSON. The record reaches the sink and is then rejected by any strict parser -- lost downstream, where nothing can degrade it, instead of at the sink, where the fallback can. `_is_json_safe_scalar` matches the exact runtime type, which cannot be forged and is what `json` itself dispatches on, and excludes non-finite floats. `_describe_exception` names an exception without trusting its `__str__`. The retry drops `default=` entirely, so nothing on that path can reach `str()`; the filter has already excluded everything that would. `allow_nan=False` on the primary dump is what routes a non-finite enrichment to the fallback rather than emitting an unparseable record. A module-level constant is the floor, so the guarantee holds without a qualifier. Consolidates the residual findings from six competing pull requests against #1452 (#1471, #1472, #1477, #1488, #1493, #1494), each of which caught a different subset. Credit to #1471/#1477 for the forged-`__class__` hole, #1477 for the non-finite case, #1493/#1494 for the exception rendering, and #1488 for the constant floor. Verification: 29 passed in tests/unit/test_logging_config_crlf.py. Reverting only logging_config.py fails exactly the 6 new tests and no others (6 failed, 23 passed), so they are non-vacuous and scoped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DDs1q8Pw4i5y3wbBSaUf8V
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Review limit reached
Next review available in: 37 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited) Review profile: ASSERTIVE Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (1)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@coderabbitai full review (Auto-review skipped for want of a qualifying label — the exact failure mode Generated by Claude Code |
|
|
This supersedes #1494 — closing that one in favour of this. One salvageable test.I opened #1494 from a scheduled remediation run at 21:04, about seven minutes before this PR, having independently found hole 2 (the unguarded Two notes worth carrying over. One test #1494 has that this PR does notThe enrichment loop reads two attributes: for attribute in ("performance_ms", "correlation_id"):Every test here and in #1491 poisons def test_exploding_str_performance_ms_does_not_cost_the_record():
# `correlation_id` is covered above, but the enrichment loop reads *two*
# attributes and `performance_ms` is the other one. It arrives by a
# different route — set from `record.duration` in `format()`, or straight
# from `extra=` as here — so covering only `correlation_id` leaves half
# the reachable surface untested.
logger, buf = _make_json_logger("json-exploding-perf")
logger.info("healthy one")
logger.info("poisoned", extra={"performance_ms": _ExplodingStr()})
logger.info("after poison")
records = [json.loads(line) for line in buf.getvalue().splitlines() if line.strip()]
assert len(records) == 3
poisoned = records[1]
assert poisoned["message"] == "poisoned"
assert poisoned["level"] == "INFO"
assert "performance_ms" not in poisonedTo be straight about what it is: this passes on One thing to double-check in
|
Duplicate cluster: #1494 ⊂ #1497 ⊂ #1504Flagging from an unattended PR-remediation sweep. Three open PRs — #1494, #1497 and this one — change the same two files ( Compared by diff, not by description:
Each is a strict subset of the next. The test files overlap the same way — #1497 and #1504 both add a forged- Consequence if left as-is: whichever lands first leaves the other two conflicted on the same hunks, and the two behind it will read as "already fixed" to a reviewer skimming titles. All three also each fire ~25 CI jobs per push into a queue currently 285 runs deep. Suggested resolution: keep this PR as canonical, close #1494 and #1497 as superseded. No unique behaviour is lost — the table above is the complete delta. Flagging rather than closing them myself: which one is canonical is a maintainer call, and the diffs are not identical in comment wording or test naming even where they are identical in behaviour. Not a review of this PR's substance — its own gates still apply. Generated by Claude Code |
|
Closing this as a duplicate of #1497, which is the same fix and opened six minutes earlier (21:05 vs 21:11). I opened this PR after finding that #1491's fallback still dropped records, having scanned the six then-open #1452 PRs and confirmed none of them closed all three holes. What I did not check was whether a seventh had appeared while I was working. #1497 had, and it is the same consolidation: exact-type scalar filter, guarded exception naming, This is the same duplicate-PR failure this sweep was opened to reduce, and I reproduced it. Worth recording plainly rather than quietly closing:
The one thing this pass produced that #1497 does not already have is a finding, now handed over in #1497's thread: Branch Generated by Claude Code |
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Snapshot WarningsEnsure that dependencies are being submitted on PR branches and consider enabling retry-on-snapshot-warnings. See the documentation for more information and troubleshooting advice. Scanned FilesNone |
Canonical issue
Closes #1452
Reopening the scope of #1452 rather than filing a new issue: its acceptance criterion was "a record is never lost to a serialization error." #1491 landed the fallback that makes that mostly true. This PR is what makes it true without a qualifier — the same overclaim #1452 was filed about, one level down.
Outcome
_format_json's serialization fallback can no longer fail the way the serialization it guards failed.#1491 landed the #1452 fallback: when
json.dumpsraises, re-serialize from the scalar fields so a bad enrichment costs its own value rather than the whole record. The retry itself contains three steps that can raise — inside the handler that exists because raising is the failure mode.Measured against
8517bf8(the merged #1491) with the same healthy → poisoned → healthy probe the existing tests use:8517bf8__class__isinstance(value, str)consultsvalue.__class__, which a property can forge. The value passes the filter, reaches ajson.dumpsthat still carrieddefault=str, and its raising__str__kills the record.f"{type(exc).__name__}: {exc}"renders the caught exception unguarded. An exception whose own__str__raises kills the record.NaN, which is not valid JSON. The record reaches the sink and is then rejected by any strict parser.The third is the one worth being precise about: the record is not lost at the sink, it is lost after it, where nothing can degrade it into something parseable. Routing it through the fallback trades an unparseable record for a degraded one.
How each is closed
_is_json_safe_scalarmatches the exact runtime type, neverisinstance.jsonitself dispatches on the real runtime type, which cannot be forged, so matching on it makes the filter agree with the encoder rather than merely resemble it. It also excludes non-finite floats._describe_exceptionnames an exception without trusting its__str__, degrading to the bare class name — an attribute, so naming it cannot itself raise.default=entirely. Anything reachingdefaultthere would reinstate the raising-__str__hole; the filter has already excluded everything that would, sodefaultis not just unnecessary but actively wrong.allow_nan=Falseon the primary dump is what routes a non-finite enrichment to the fallback instead of emitting an unparseable record.Scope
logging_config.py—_describe_exception,_is_json_safe_scalar,_JSON_UNSERIALIZABLE_RECORD, and the reworked retry.tests/unit/test_logging_config_crlf.py— +6 tests in the existing CWE-117 file, in its established idiom."survives the #1270 log sanitizer #1429/fix(security): build JSON log records with json.dumps (CWE-117 field forgery) #1439 CWE-117 field-forgery guarantee is untouched and still pinned by its own 23 tests, including on the degraded path.JSON_LOGGINGdefault mismatch (production_config.py:72says"true",logging_config.pysays"false"). Scoped out of fix(security): build JSON log records with json.dumps (CWE-117 field forgery) #1439 and fix(logging): keep the record when JSON serialization fails (#1452) #1491 for the same reason: a behavioural config decision, not a serialization fix.record.getMessage(). A lazy%sargument whose__str__raises fails while the payload is still being built, above the retry. It fails identically on the line-oriented path, so it is not specific to JSON and not this PR's to fix.Note: this consolidates six competing pull requests
At the time of writing, six open PRs implement #1452 — #1471, #1472, #1477, #1488, #1493 and #1494 — all opened within eleven minutes of each other, after #1491 had already merged the base fix. Each caught a different subset of the residual holes; none caught all three. This PR is their union, verified rather than merged on trust:
__class__hole and thetype()-not-isinstanceargument.Under
MERGE_POLICY.mdgate 6, the six need a reconciliation decision, not six rebases. Recommendation is on the table in the PR thread; they are left open pending it rather than closed unilaterally.Risk
allow_nan=Falseon the primary dump: a record carrying a non-finiteperformance_msnow emits degraded-but-parseable instead of{"performance_ms": NaN}. That is a widening of what survives, and the previous output was not valid JSON, so no conforming consumer can regress. A consumer relying on Python's ownjson.loads(which acceptsNaN) to read that field would see it drop toserialization_error— no such consumer exists in-tree.git revert. No migration, config, or schema change.Verification
Head
7358509. Measured, not inferred.Focused tests —
tests/unit/test_logging_config_crlf.py: 29 passed. The 23 pre-existing tests are unchanged and still pass, so both the fix(security): CWE-117 JSON field forgery via unescaped"survives the #1270 log sanitizer #1429 contract and thedefault=strin_format_jsondoes not deliver its stated guarantee — a record can still be lost (follow-up to #1439) #1452 contract are intact.Non-vacuous, and precisely scoped. Reverting only
logging_config.pytoorigin/mainand keeping the tests fails exactly the six new tests and nothing else:The non-finite test cannot pass against the bug.
json.loadsacceptsNaN/Infinityby default, so asserting "it parses" would pass on unfixed code. The test passes aparse_constantthat fires on exactly those literals — which is what a strict downstream parser rejects.The degraded path is still not a hole in fix(security): CWE-117 JSON field forgery via unescaped
"survives the #1270 log sanitizer #1429.test_serialization_fallback_still_escapes_attacker_content(pre-existing) drives the forgery payload through the fallback and still passes; the new tests additionally assertlevelstays authoritative on every degraded record.except Exceptionis deliberate. A narrow(TypeError, ValueError, RecursionError)walks straight past the exploding-__str__cases; the tests fail against the narrow version.Blast radius checked, not assumed.
tests/unit/test_logging_config_crlf.pyis the only test file in the repo referencinglogging_config.Lint —
ruff checkclean on both changed files.Required CI — pending first run on this head.
Review threads resolved — none open yet.
Stated honestly: the wider
tests/unit/suite could not be run in this sandbox — 61 collection errors, allModuleNotFoundErrorfor project dependencies (fastapi,pydantic,aiohttp,psutil,aiofiles) that are not installed here.logging_configimports none of them, and the file under test collects and runs cleanly. CI covers the rest.Production evidence
Not applicable as a preview — backend logging with no
apps/web/**surface, which is what gate 4 ofMERGE_POLICY.mdscopes previews to.The runtime evidence that matters is the reproduction, run against the real formatter through a real
StreamHandlerin both directions: 2 of 3 records on8517bf8for both raising cases, 3 of 3 on this head, plus strict-parser rejection of the non-finite record before and acceptance after.Agent handoff
default=strin_format_jsondoes not deliver its stated guarantee — a record can still be lost (follow-up to #1439) #1452default=strin_format_jsondoes not deliver its stated guarantee — a record can still be lost (follow-up to #1439) #1452's fourth criterion ("the comment states the real guarantee") is what this PR is about: the comment fix(logging): keep the record when JSON serialization fails (#1452) #1491 shipped described a guarantee its code did not yet provide.Generated by Claude Code