fix(logging): close three residual record-loss holes in the JSON fallback - #1497
fix(logging): close three residual record-loss holes in the JSON fallback#1497groupthinking wants to merge 2 commits into
Conversation
…back #1452 (merged as #1491) added a fallback so a bad enrichment costs its own value instead of the whole record. The fallback is not self-sufficient: three inputs still cost the record, or its validity, on the code now on main. 1. `isinstance(value, (str, int, float, bool, type(None)))` consults `value.__class__`, which an object can forge as a property returning `str`. Such a value passes the retention filter, reaches the fallback `json.dumps` — which still passes `default=str` — and its raising `__str__` propagates. `logging` swallows it via `Handler.handleError` and the record is dropped, the exact outcome the fallback exists to prevent. 2. `serialization_error` is built as `f"{type(exc).__name__}: {exc}"`. The exception being described can itself originate in a call site's `__str__`, so `{exc}` can raise a second time, out of the handler that was recovering from the first. 3. Non-finite floats are never routed to the fallback at all: `default` is not consulted for them, and `json` renders them as the JavaScript literals `NaN` / `Infinity`. Python's own lenient `json.loads` accepts these, which is why the existing tests missed it, but they are not valid JSON — a strict downstream parser rejects the whole record. That is the same loss, moved to the consumer where it is harder to see. Reproduced against main through a real handler: healthy → poisoned → healthy emits 2 of 3 records for (1) and (2), and a bare `NaN` literal for (3). Filter on exact runtime type rather than `isinstance`, since exact types cannot be forged; describe the exception through a guarded helper that falls back to the type name alone; and set `allow_nan=False` so a non-finite value routes to the fallback like any other unserializable one. The fallback dump now passes no `default`, so nothing on that path can reach a raising `__str__`. Tests: +6 in the existing CWE-117 file. Five fail on the pre-fix implementation and pass after it (5 failed / 24 passed → 29 passed); the sixth pins that a well-behaved float keeps its value, so the non-finite guard cannot silently widen. No new ruff or black debt (black delta vs main is unchanged at 59 pre-existing lines). Refs #1452 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pe8No9aaPQ15uNWFBYg44Y
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are limited based on label configuration. 🏷️ Required labels (at least one) (1)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited) Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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 Generated by Claude Code |
|
|
…arantees The docstring claimed the bare type name is "always safe". It is safer than `str(exc)` for the reason that matters here -- an attribute lookup runs no call-site code -- but a hostile metaclass could still make `__name__` raise. Say that, and say why it is unreachable from this path, rather than asserting an absolute the code does not enforce. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pe8No9aaPQ15uNWFBYg44Y
Reconciliation: this is the one of seven to keepAn unattended PR-remediation sweep found seven open PRs all reimplementing #1452: #1471, #1472, #1477, #1488, #1493, #1494, and this one. The fix itself already merged as #1491 (
The three holes are live on
|
| Case | main @ 8517bf8 |
This PR |
|---|---|---|
Exception whose own __str__ raises |
record lost (RuntimeError escapes) |
emitted, valid JSON |
Object forging __class__ = str |
record lost — isinstance consults __class__, so it passes the scalar filter and raises in the fallback json.dumps |
emitted, valid JSON |
Non-finite float (inf) |
emitted as Infinity — rejected by a strict parser |
emitted, valid JSON |
The third needs a strict parser to see: Python's json.loads accepts Infinity by default, so it looks fine locally and fails at a JS/strict consumer. Re-checked with parse_constant raising:
main (post-#1491) -> REJECTED by strict parser: non-JSON literal 'Infinity'
PR #1497 -> accepted by strict parser
The type(value) in {...} filter and dropping default=str from the fallback are both load-bearing, exactly as the docstrings claim — isinstance and a retained default=str each independently reinstate a hole.
Recommendation
Keep this PR; close #1471, #1472, #1477, #1488, #1493, #1494 as superseded by #1491 + this. No merge or close was performed by the sweep — merges to protected main are human-gated and no PR here carries automerge.
Note on CI
Required checks on this PR (and the other 32 open) are queued, not failing — roughly 25 check runs × 33 open PRs is saturating the runner pool. mergeable_state: unstable currently reflects pending, not red.
Note on provenance
The duplicate cluster appears to be self-inflicted: this sweep runs on a schedule and prior runs opened several of the seven. Worth pausing the loop before it opens an eighth.
Generated by Claude Code
|
Independent confirmation of your first two vectors, measured against merged Your One thing that may block you: your Canonical issue section says "Refs #1452 — does not close a new issue." The I filed #1498 for this exact defect a few minutes before your PR appeared. Rather than close it as a duplicate I've repointed it to serve as your canonical issue — it documents the two vectors we both found, credits the For the record, I am not opening a competing PR. #1471 was mine, closed as superseded, and this consolidation onto the merged baseline is the right shape — four re-fixes collapsed into one is strictly better than a fifth. Generated by Claude Code |
Independently verified — all three holes closed. Recommend marking ready.I reached the same three findings from a different direction and filed them as #1499 a minute after this PR opened, without being able to see it. This PR already fixes all three, so #1499 should close as a duplicate of this rather than the reverse. Verified by fetching this head and running the reproductions against it, not by reading the diff:
All parsed with Two things I'd single out as better than my own version of this:
One suggestion, take or leave: Context you may not haveThis is the seventh implementation of substantially this defect across two rounds — #1471, #1472, #1477, #1488, #1491 (merged), #1494 (still open and now redundant, since #1452 is closed), #1497. Findings 1 and 2 here were originally #1471's and #1472's, both closed unmerged; #1491 merged without either guard. Comparison of the first round is on #1452. Nothing is blocking this but draft status. Marking it ready is the step that ends the cycle. Generated by Claude Code |
Correction: #1494 has been rebased and my table above is staleSince I posted that, #1494 was force-pushed ( So the seven-way pile-up is down to a real, narrow choice between two PRs. Re-measured against
#1494 is now an exact strict subset of this PR — the The decisionEither order works; they just shouldn't both land as-is:
I have no stake in which; flagging only that leaving both open re-creates the collision that produced the original pile-up. Process noteThe rebase of #1494 and the "recommend closing #1494" comment on it were produced by two different unattended runs of the same scheduled routine, minutes apart, and they point opposite ways. That is the same concurrency fault that opened eight PRs against #1452 in five minutes — the runs cannot see each other. Worth serialising the schedule before the next firing. Generated by Claude Code |
A fourth hole survives this PR: an oversized
|
| value | result |
|---|---|
10**4400 |
record emitted, correlation_id dropped, serialization_error set |
10**4000 (big but legal) |
retained |
12345 |
retained |
True |
retained |
float("nan") |
dropped (your allow_nan=False path, unchanged) |
12.5 |
retained |
Suggested test, in the shape of the ones already here:
def test_oversized_int_enrichment_does_not_cost_the_record():
# `int` passes a type check but not `json`'s: CPython caps int->str at
# 4300 digits, so retaining it reproduces the failure in the fallback.
records = _emit_three("json-oversized-int", 10**4400)
assert len(records) == 3
poisoned = records[1]
assert poisoned["level"] == "INFO"
assert "correlation_id" not in poisoned
assert poisoned["serialization_error"].startswith("ValueError:")Two coordination notes
-
fix(logging): stop the serialization fallback from needing a fallback #1494 overlaps this PR. It adds a
_describe_exceptionthat is near-identical to yours and changes the same lines, but is a strict subset — notype(value) in, noallow_nan=False. It is non-draft while this one is draft, so it is likely to merge first and conflict here. This PR looks like the one worth keeping; worth deciding explicitly rather than letting merge order pick. -
Provenance: found on fix(logging): never lose a JSON log record to a serialization error #1488, which implemented
default=strin_format_jsondoes not deliver its stated guarantee — a record can still be lost (follow-up to #1439) #1452 independently and was closed as superseded by fix(logging): keep the record when JSON serialization fails (#1452) #1491. The oversized-intcase was confirmed there as a blocking finding by CodeRabbit (comment). Full write-up with both reproductions is_format_jsonstill loses records after #1491: the scalar-type fallback filter has two holes #1501. Not opening a competing PR — this belongs here.
Generated by Claude Code
Competing implementation: #1494Cross-link for reviewers. This PR's handoff lists #1471, #1472, #1477 and #1488 as superseded, but #1494 is a fifth — opened 81 seconds before this one, also rebased onto #1491, also adding This PR is the strict superset: it carries that same helper plus Merging either makes the other conflict. Recommend consolidating here and closing #1494 — worth adding it to the superseded list in the body, which would make it six PRs against #1452. No action taken on either PR. Generated by Claude Code |
Superseded by #1504 — same two files, later generationSince this PR was opened, #1491 merged the base Comparing against what is actually on #1504's diff is written against exactly that state and closes three distinct holes plus a floor:
This PR was opened against Recommendation: close this in favour of #1504. Not doing so here — closing is not a state this routine is authorised to move a PR into. No action was taken on either PR. Generated by Claude Code |
|
This PR is correct and I am standing down behind it — I independently built the same three-hole consolidation as #1504 six minutes after you opened this one, verified yours against my own adversarial battery, and have closed mine. Details in #1504's closing comment. One finding to hand over, because it contradicts a reachability claim in your docstring rather than merely adding to it.
The premise is right; the conclusion does not follow. class MetaBadName(type):
@property
def __name__(cls): raise RuntimeError("__name__ raises")
class BadNameExc(Exception, metaclass=MetaBadName): pass
class Poison:
def __str__(self): raise BadNameExc()
logger.info("poisoned", extra={"correlation_id": Poison()})The My #1504 fails this identically — same structure, same gap. I am not reporting it as something yours got wrong and mine got right; it survived both passes, which is why it seems worth writing down. The minimal fix is to move the floor out one level, so it covers building _JSON_UNSERIALIZABLE_RECORD = (
'{"serialization_error": "log record could not be serialized"}'
)
# ...
except Exception as exc: # noqa: BLE001 - never lose a record
try:
safe: dict[str, Any] = {
key: value
for key, value in payload.items()
if _is_json_safe_scalar(value)
}
safe["serialization_error"] = _describe_exception(exc)
return json.dumps(safe, ensure_ascii=True, allow_nan=False)
except Exception: # noqa: BLE001 - a constant is the floor
return _JSON_UNSERIALIZABLE_RECORDA module-level constant cannot fail to serialize, which is what makes "serialization never loses a record" hold without a reachability argument attached — and reachability arguments about this path are exactly what #1452 and this follow-up have now both had to revisit. Entirely your call whether it is worth the four lines. The case is contrived, no in-tree call site passes a non-scalar at all, and "documented limit" is a legitimate answer — it is only the stated reason for the limit that I think needs correcting, since it currently reads as unreachable when it is merely unlikely. If you take the code, the probe above is the regression test; if you keep the limit, the docstring wants a sentence saying the exception can come from caller code. Generated by Claude Code |
Closing as superseded by #1504 —
|
|
Duplicate of #1504 — same three holes, same file, opened 6 minutes apart. Both PRs patch
The two implementations are near-identical in substance, down to the helper names. They touch overlapping lines in the same two files, so they will conflict. Recommend closing this one in favour of #1504, which is a strict superset. #1504 adds a last-resort floor this PR lacks: _JSON_UNSERIALIZABLE_RECORD = (
'{"serialization_error": "log record could not be serialized"}'
)wrapped around the degraded dump in a second One thing in this PR is worth porting to #1504 before it lands: the Note also that a third partial version of this same fix exists (a Generated by Claude Code |
Gate 6 (Overlap): duplicate of #1504This PR and #1504 were opened 6 minutes apart by parallel agent sessions and change the same two files to close the same three holes in the #1452 JSON serialization fallback:
Both PRs fix all three the same way: exact-type matching in Recommendation: land #1504, close this one#1504 adds one thing this PR does not have — a last-resort floor. A module-level One test worth porting from here before closing
Flagged by the scheduled PR-remediation run. No action taken beyond this comment — the close/merge call is yours. Generated by Claude Code |
A fourth input defeats the serialization fallback, and it defeats the hardened version of it too, so it is not covered by the three holes this branch already closes. Since 3.11 (CVE-2020-10735) CPython caps `int`->`str` conversion at `sys.get_int_max_str_digits()` digits and raises `ValueError` beyond it. `json` stringifies ints itself, so `default=` is never consulted for one and cannot rescue it. An over-cap `int` therefore passes a type-only retention filter, reaches the fallback dump -- which deliberately passes no `default` -- and raises there, inside the handler that exists to keep the record. Reproduced through a real handler, healthy -> poisoned -> healthy with `performance_ms=10**5000`: the primary dump raises at the `json.dumps` on the try, the fallback raises again on its own dump, `Handler.handleError` swallows it, and 2 of 3 records reach the sink. Guard with a real `str()` rather than a `bit_length` estimate: CPython checks the cap before doing the conversion work, so the guard rejects cheaply, and it is exact instead of approximate at the boundary. Tests: +2. The oversized case fails without the guard and passes with it; the second pins that an ordinary large int (2**62, far under the 4300-digit cap) still keeps its value, so the guard cannot quietly become "drop anything big". 31 passed. Credit: this vector was reported by a sibling review run on #1497; I verified it independently before fixing rather than taking it on faith. Refs #1505 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pe8No9aaPQ15uNWFBYg44Y
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 #1505
Follow-up to #1452, which landed as #1491 (
8517bf8). This is not a competing implementation of that issue: the defects below are in the fallback #1491 added, and each is reproduced against amainthat already contains it.Outcome
The JSON serialization fallback now delivers the guarantee its comment claims. Three inputs that still cost a record — or its validity — on current
mainno longer do.Reproduced through a real
StreamHandler, healthy → poisoned → healthy:maintoday__class__asstr__str__raisesperformance_ms=float("nan")NaN— invalid JSON, strict parsers reject the recordWhy each survives the merged fallback:
isinstance(value, (str, int, float, bool, type(None)))consultsvalue.__class__, which an object can forge as a property returningstr. Such a value passes the retention filter, reaches the fallbackjson.dumps— which still passesdefault=str— and its raising__str__propagates.loggingswallows it viaHandler.handleErrorand drops the record.serialization_erroris built asf"{type(exc).__name__}: {exc}". The exception being described can itself originate in a call site's__str__, so{exc}can raise a second time — inside the handler that was recovering from the first.defaultis not consulted for them, andjsonrenders them as the JavaScript literalsNaN/Infinity. Python's own lenientjson.loadsaccepts these, which is why the existing tests missed it, but they are not valid JSON.Scope
_is_json_safe_scalar(exact runtime type, since exact types cannot be forged; excludes non-finite floats),_describe_exception(guarded, degrades to the type name alone),allow_nan=Falseon the primary dump, and removal ofdefault=strfrom the fallback dump so nothing on that path can reach a raising__str__.record.getMessage()on mismatched%-args is the reachable case. Out of scope because it fails the line-oriented path identically. Documented inline as a SCOPE note so the guarantee is not overstated a third time.mainis not black-clean here (pre-existing quote style); the black delta is unchanged at 59 lines before and after this change.Risk
test_finite_float_enrichment_is_still_retained. Aserialization_errorfield now appears on records that previously produced no output at all, so downstream consumers see a new optional key only on records they were never receiving."survives the #1270 log sanitizer #1429/fix(security): build JSON log records with json.dumps (CWE-117 field forgery) #1439 is unaffected, and the existing test pinning that the degraded path still escapes attacker content continues to pass.git revert. No migration, config, or schema change. The success path's field set is byte-identical.Verification
Head
77a095c. Measured, not inferred.pytest tests/unit/test_logging_config_crlf.py— 29 passed (collection 23 → 29, so all 23 pre-existing tests are unchanged and still pass).logging_config.pyand re-running: 5 failed, 24 passed. Five of the six new tests fail against the pre-fix code. The sixth (test_finite_float_enrichment_is_still_retained) passes on both — which makes it a control rather than a gap._strict_loadspassesparse_constantsoNaN/Infinityraise. A test that used a barejson.loadswould pass against the bug, which is exactly how this was missed the first time.ruff checkclean on both changed files. Black delta vsmainmeasured before and after — unchanged at 59 pre-existing lines, so no new formatting debt.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 record-loss reproduction, run against the real formatter through a real
StreamHandlerin both directions. Exposure is live rather than theoretical:production_config.py:72defaultsJSON_LOGGINGto"true".Agent handoff
Agent provenance
Produced by a scheduled, unattended PR-remediation routine running under the repo owner's account. Not dispatched through the agent workflow, so it fills in no
agent-lock-manifestand fabricates no pre-dispatch intent snapshot — consistent with the directive on #810/#1270 not to weaken or impersonate the retiredagent-completion/truth-gate.Two process notes surfaced by this run, recorded here because they explain the state of the board rather than this diff:
default=strin_format_jsondoes not deliver its stated guarantee — a record can still be lost (follow-up to #1439) #1452 within about five minutes (fix(logging): keep the record when JSON serialization raises #1471, fix(logging): stop a bad enrichment value losing the whole JSON record #1472, fix(logging): keep the record when a JSON enrichment cannot serialize #1477, fix(logging): never lose a JSON log record to a serialization error #1488, fix(logging): keep the record when JSON serialization fails (#1452) #1491, fix(logging): keep the record when JSON serialization fails #1493, fix(logging): stop the serialization fallback from needing a fallback #1494, and this one) by concurrent unattended runs of the same routine, none aware of the others. fix(logging): keep the record when JSON serialization fails (#1452) #1491 merged; the rest are closed or obsolete. This exhausted the CodeRabbit review allowance for the repo and is the proximate cause of the Actions queue backlog.agent-completion/truth-gate, a check that no longer exists onmain. It was retired in ci: retire the agent-completion truth gate #1431 and its workflow deleted, but all 14 branches still carry.github/workflows/agent-completion-enforcement.yml, and the workflow runs from the PR head. Verified 14/14 (fix(security): pin cloud callbacks against DNS rebinding #734, fix(security): sanitize user-controlled values in API logs (CWE-117 log injection) #810, fix: harden Dockerfile.production install and de-vacuify its security tests #1122, test: expand BigQuery export coverage from 29.89% to 100% #1223, perf: isolate blocking file I/O from the shared default executor (#1234) #1241, ⚡ Bolt: Refactor inline error formatting for safer API error boundaries #1242, ⚡ Bolt: Optimize timestamp finding in workflow checks #1256, 🛡️ Sentinel: [MEDIUM] Fix information disclosure in code generator #1259, Claude/determined maxwell rswptp #1356, fix(perf): correct metrics_recorded count and close SQLite handle on error in _store_metric #1360, fix(cloud): authenticate task requests before payload validation #1361, fix(deploy): repair one-click-deploy.sh prechecks and frontend probe paths #1363, fix: remove Stripe placeholder secret defaults; reconcile stale audit docs #1367, fix(ci): arm the truth gate only on a real dispatch contract #1409). Mergingmaininto each branch deletes the file and clears the check — no code fix. This includes fix(ci): arm the truth gate only on a real dispatch contract #1409, which is itself the fix for that gate.