Skip to content

fix(redaction): tighten Set-Cookie attribute shapes, and make the perf guard able to fail - #20

Merged
andrei-hasna merged 2 commits into
mainfrom
6200c4e4-followup
Aug 1, 2026
Merged

fix(redaction): tighten Set-Cookie attribute shapes, and make the perf guard able to fail#20
andrei-hasna merged 2 commits into
mainfrom
6200c4e4-followup

Conversation

@andrei-hasna

@andrei-hasna andrei-hasna commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #19, which was squash-merged as 8bd4303. Task 6200c4e4.

Read the branch history first, because it matters for what you are reviewing. This work was developed on 6200c4e4 on top of the pre-squash ebfa05d. #19 was then squash-merged, so main contains that content as a new commit and the old branch conflicts with it — git merge-tree --write-tree origin/main origin/6200c4e4 returns rc=1 on src/redaction.ts and docs/redaction.md. Rather than resolve that conflict into an artefact nobody had read, this branch is cut fresh from 8bd4303 with the same work replayed onto it.

Main's request-vs-response mechanism is deliberately untouched. #19 landed (?:set-)?cookie plus prefix.toLowerCase().startsWith("set-cookie"), contributed by another agent. My branch had a different spelling of the same idea; substituting it would relitigate a merged, reviewed decision for no behavioural gain, so this PR keeps main's and layers only what is genuinely additional. Verified: git diff origin/main -- src/redaction.ts | grep -c '(?:set-)?cookie'0, with a positive control confirming the grep matches that line in the file.

git merge-tree --write-tree origin/main HEAD produces a tree identical to HEAD, so the merge introduces nothing a reviewer has not seen. Control: merge-tree of main with itself is clean, so the check discriminates.

Main is NOT already clean on these axes

The request side is closed — that is what #19 fixed. The Set-Cookie side still takes attribute exemptions, and its value shapes were the guard. Measured against 8bd4303 across 8,090 generated cookie shapes:

leaking
merged main 8bd4303 291
this branch 2

The 291 break down as domain 146, expires 144, path 1. Worked example:

Set-Cookie: sid=1; expires=SYNTHETICSESSIONID000000000000AB
  main -> sid=[REDACTED]; expires=SYNTHETICSESSIONID000000000000AB
  here -> sid=[REDACTED]; expires=[REDACTED]

expires was ^[A-Za-z0-9:+-]{1,32}$ — not a date check in any meaningful sense, but precisely the shape of a 32-character session id. domain was length-unbounded and matched an 87-character JWT-shaped token. 12 of 14 attribute-named probes preserved a synthetic credential.

And the comment above the table asserted that Set-Cookie: sid=1; path=<credential> "does not look like a path and is masked" — measured at that exact position, 3 of 4 credential shapes were PRESERVED. It had generalised from the one fixture that happened to work.

Now: expires takes only an RFC 1123 weekday, domain requires bounded labels and a purely alphabetic final label, max-age an integer. MAX_COOKIE_ATTRIBUTE_VALUE (256) means an over-long value never reaches a shape test at all and is masked — which fails closed and removes an engine-dependent backtracking cliff (60× between 64 and 128 KiB on JavaScriptCore, absent on V8) that an earlier "free of nested quantifiers" claim had denied existed. Fixed at the source, not in the sentence.

The perf guard could not fail — this file's own headline defect, inside the tests written to demonstrate it

An adversarial reviewer installed the naive pattern the cookie rule was restructured to avoid, and ran the two shipped assertions against it: 1.97, 2.00, 1.87 — all green, with the ReDoS in place.

  1. growthPerDoubling enlarges input by repeating a fixed unit, so the number of runs grows and no single run ever gets longer — while the quadratic is per run, O(run²). The harness could not express the axis at any size.
  2. "cookiecookiecookie" never triggers the rule at all: no :/= follows the literal.

The replacement grows the run, and both assertions are exercised against the mutant rather than asserted:

shape shipped scan naive mutant discriminates?
"cookie=" + x*N 1.819 4.010 (231.6/927.1/3724.4 ms) yes
"Set-Cookie: " + x*N 1.987 4.004 (231.7/941.7/3714.3 ms) yes
"Cookie: sid=" + x*N 1.876 2.064 (0.1/0.1/0.2 ms) NO — removed

The third row is why the shape matters more than the size. sid= satisfies the literal immediately, so the scan never has to fail. A first draft used it — which would have shipped a second assertion that could not fail, inside the fix for an assertion that could not fail. Both shipped shapes now put the whole run after the header separator and before any =. Sizes are 8/16/32 KiB so the failing case fails in ~29s rather than by timing out — a timeout reports a budget, not a duration.

The original two are kept and relabelled as the outer-regex guards they genuinely are, with the measurement that the naive mutant passes them written beside them.

Two smaller defects, both from review

  • Pairs are counted, not tokens. A valueless Secure ahead of the cookie spent the opening-pair protection on itself, so the exemption then applied to the real first pair: Set-Cookie: Secure; path=/<credential> survived.
  • Masking preserves surrounding structure. {"set-cookie": ["a=1", "sid=<cred>"]} keeps its quotes and brackets. This broke idempotence on its first attempt because [REDACTED] itself ends in ] — a second pass peeled the bracket and grew [REDACTED]] every run. The guard is a marker-followed-by-closers-only test, deliberately not startsWith("[REDACTED]"), which would wave through a value that opens with the marker and continues into a real credential.

Also: medianMillis had no warmup and sampled 5×; it now warms up and defaults to 9, addressing a measured 1-in-15 flake on the pre-existing newline control.

Evidence

  • bun test 41 pass / 0 fail, rc=0 unpiped, two consecutive runs; typecheck rc=0; build rc=0; staged secrets scan 0 matches with a positive control confirming the pattern fires on a planted AKIA….
  • No turbo/nx in this repo — no cache layer, so every run genuinely executes.
  • Differential vs merged main: 8,090 cookie shapes → 0 regressions, 289 newly fixed, 0 idempotence breaks; 90 non-cookie shapes byte-identical. Both controls fire.

Axes varied, and the ones not

Varied: cookie name, header spelling and case, separator, quoting, credential position among pairs, pair count, Set-Cookie attributes present, attribute names reused as cookie names, a valueless flag ahead of the cookie, JSON nesting, and run length.

Not varied: any encoding of the header name or separator (percent-encoding, Unicode/fullwidth, HTML entities) and line folding. Those leak, are a property of every ASCII-literal key pattern in the file rather than of this rule, and are filed as todos 4afd4361.

Known residual — the whole of what still leaks here

2 of 8,090, both by design:

Set-Cookie: a=1; path=/<tok>              -> preserved (a path is not a secret)
Set-Cookie: a=1; domain=<tok>.example     -> preserved (a hostname is not a secret)

In a response header those slots are those attributes. Recorded in docs/redaction.md rather than claimed closed.

Also carried forward as a P3 follow-up, not fixed here because it is main's merged mechanism and out of this PR's scope: (?:set-)?cookie has no left boundary, so reset-cookie:, offset-cookie: and similar are treated as Set-Cookie and get attribute exemptions.

Review framing

This is not an emergency and should not be reviewed as one. The request-side leak is already closed in main. What this adds is 289 measured Set-Cookie-side leak shapes, plus repair of a test instrument that a future reviewer would otherwise lean on while it silently proved nothing.

Refs: todos 6200c4e4, 4afd4361


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…f guard able to fail

Follow-up to #19, rebuilt on current main after that PR was squash-merged. The
branch it was developed on carried the pre-squash commits and conflicted, so this
is the same work replayed onto 8bd4303 and nothing else. Main's request-vs-response
mechanism is deliberately UNTOUCHED -- that decision is merged and reviewed, and
substituting my own spelling of it would be scope I have not earned.

MAIN IS NOT ALREADY CLEAN ON THESE AXES, which is worth stating because the
request side is. Measured against 8bd4303 across 8,090 generated cookie shapes:
main leaks 291, this change leaks 2. The 291 break down as domain 146, expires
144, path 1 -- all on the SET-COOKIE side, which is the side that still takes
attribute exemptions.

  Set-Cookie: sid=1; expires=SYNTHETICSESSIONID000000000000AB
    main -> sid=[REDACTED]; expires=SYNTHETICSESSIONID000000000000AB
    here -> sid=[REDACTED]; expires=[REDACTED]

THE SHAPES WERE THE GUARD AND THEY DID NOT FIT THE JOB. `expires` was
`^[A-Za-z0-9:+-]{1,32}$`, which is not a date check in any meaningful sense but
precisely the shape of a 32-character session id; `domain` was length-unbounded
and matched an 87-character JWT-shaped token. 12 of 14 attribute-named probes
preserved a synthetic credential. The comment above the table asserted that
`Set-Cookie: sid=1; path=<credential>` "does not look like a path and is masked",
and measured at that exact position 3 of 4 credential shapes were PRESERVED -- it
had generalised from the one fixture that happened to work. `expires` now takes
only an RFC 1123 weekday, `domain` requires bounded labels and a purely alphabetic
final label, `max-age` an integer.

MAX_COOKIE_ATTRIBUTE_VALUE (256) means an over-long value never reaches a shape
test at all and is masked instead. That fails closed, and it removes an
engine-dependent backtracking cliff -- 60x between 64 and 128 KiB on
JavaScriptCore, absent on V8 -- which an earlier "free of nested quantifiers"
claim had denied existed. Fixed at the source rather than by rewriting the
sentence.

THE PERF GUARD COULD NOT FAIL, which is this file's own headline defect committed
inside the tests written to demonstrate it. An adversarial reviewer installed the
naive pattern the cookie rule was restructured to avoid and ran the two shipped
assertions against it: 1.97, 2.00, 1.87 -- all green, with the ReDoS in place.
`growthPerDoubling` enlarges input by repeating a fixed unit, so the NUMBER of
runs grows and no single run ever gets longer, while the quadratic is per-run
O(run^2); the harness could not express the axis at any size. And
`"cookiecookiecookie"` never triggers the rule at all, since no `:` or `=` follows
the literal.

The replacement grows the RUN, and both assertions are exercised against the
mutant rather than asserted:

  "cookie=" + x*N        shipped 1.819   naive 4.010 (231.6/927.1/3724.4 ms)  discriminates
  "Set-Cookie: " + x*N   shipped 1.987   naive 4.004 (231.7/941.7/3714.3 ms)  discriminates
  "Cookie: sid=" + x*N   shipped 1.876   naive 2.064 (0.1/0.1/0.2 ms)         DOES NOT -- removed

The third row is why the shape matters more than the size: `sid=` satisfies the
literal immediately so the scan never has to fail. A first draft of this test used
it and would have shipped a second assertion that could not fail, inside the fix
for an assertion that could not fail. Both shipped shapes now put the whole run
after the header separator and before any `=`. Sizes are 8/16/32 KiB so the
failing case fails in ~29s rather than by timing out -- a timeout reports a budget,
not a duration. The original two assertions are kept and relabelled as the outer
regex guards they actually are, with the measurement that the naive mutant passes
them written beside them.

Two smaller defects, both from review:

  Pairs are counted rather than tokens. A valueless `Secure` ahead of the cookie
  spent the opening-pair protection on itself, so the exemption then applied to the
  real first pair and `Set-Cookie: Secure; path=/<credential>` survived.

  Masking re-emits a trailing run of serialization closers instead of swallowing
  them, so `{"set-cookie": ["a=1", "sid=<cred>"]}` keeps its quotes and brackets.
  That contradicted this rule's own invariant that output differs from input only
  where a value was masked. It broke idempotence on its first attempt because
  `[REDACTED]` itself ends in `]` -- a second pass peeled the bracket and grew
  `[REDACTED]]` every run. The guard is a marker-followed-by-closers-only test,
  deliberately not `startsWith("[REDACTED]")`, which would wave through a value
  that opens with the marker and continues into a real credential.

Also: `medianMillis` had no warmup and sampled 5 times. It now warms up and
defaults to 9, which addresses a measured 1-in-15 flake on the pre-existing
newline control.

EVIDENCE. 41 pass / 0 fail, rc=0 measured unpiped on two consecutive runs;
typecheck rc=0; build rc=0; staged secrets scan 0 matches with a firing positive
control. No turbo/nx in this repo, so every run executes. Differential against
merged main: 8,090 cookie shapes with 0 REGRESSIONS, 289 newly fixed, 0
idempotence breaks; 90 non-cookie shapes byte-identical. Both controls fire -- the
comparison can report a difference, and the leak detector finds the literal in
prose.

KNOWN RESIDUAL, and it is the whole of what still leaks here: 2 of 8,090 shapes --
a genuinely path-shaped value in a Set-Cookie `Path=` slot and a genuinely
hostname-shaped value in a `Domain=` slot. In a response header those slots ARE
those attributes, and a path is not a secret. Recorded in docs/redaction.md rather
than claimed closed.

Refs: todos 6200c4e4, 4afd4361

Agent: aemilius
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW] NO_GO — #20 @ 3a04c93 — lens: bypass/correctness, reviewer flavius

You were right to re-cut rather than resolve the squash conflict. Verified fresh against merged main 8bd4303, in the new worktree, by execution. Synthetic values only; exit codes unpiped.

One blocker, and the remedy is one line. Everything you claimed fixed IS fixed — but the trailing-closer re-emission prints a credential in the clear that merged main masks.

FIRST — a correction I owe on my own two prior verdicts

My fuzzer's PRNG was broken, and both my earlier GOs rested partly on a blind corpus. s = (s*1103515245 + 12345) & 0x7fffffff — the multiply exceeds 2^53, so precision is lost and the generator cycles after 10,579 states. "300,000 cases, 0 regressions" at ebfa05d and 38d9be4 was really ~10.5k distinct draw-sequences dressed as six figures. It is the vacuous-corpus failure in its most convincing costume, and I shipped it twice.

I caught it only because this run reported MAIN leaks = 0 while my own hand-written replay showed main leaking on Set-Cookie: Expires=. Two measurements disagreed and the corpus was the one that was wrong.

Re-run with mulberry32 (Math.imul, 32-bit throughout): 300,000 cases → 250,844 distinct strings, and it immediately surfaced 13 regressions the old generator could not express. Everything below uses the corrected generator.

P1 BLOCKER (new in this PR) — a re-emitted quote unbalances the line and a credential prints with NO marker

Delta-debugged to minimal form:

in  : cookie:AUTH=",Zx9Qw3Rt7Yu1Op4As6Df8Gh0Jk2Lm5Nb
MAIN: cookie:AUTH=[REDACTED]                          <- masked
HEAD: cookie:AUTH=",Zx9Qw3Rt7Yu1Op4As6Df8Gh0Jk2Lm5Nb   <- CREDENTIAL IN THE CLEAR

in  : Cookie:TOKEN="\tpassword="Zx9Qw3Rt7Yu1Op4As6Df8Gh0Jk2Lm5Nb"
MAIN: Cookie:TOKEN=[REDACTED]\tpassword="[REDACTED]"   <- masked
HEAD: Cookie:TOKEN="[REDACTED]"Zx9Qw3Rt7Yu1Op4As6Df8Gh0Jk2Lm5Nb"

Set-Cookie: AUTH=",<CRED> behaves the same. 13 occurrences in 300k.

The cookie rule is provably the cause — same payload, prefix toggled:

input MAIN HEAD
AUTH=",<CRED> (no cookie prefix) LEAK LEAK ← pre-existing, not yours
cookie:AUTH=",<CRED> safe LEAK ← the rule made it safe on main and leaks here

Mechanism: bodyEnd walks back over trailing closers and re-emits them, so a value ending in a quote keeps that quote (AUTH=" stays AUTH="; x" becomes [REDACTED]"). That unbalanced quote then breaks the downstream generic rules' (["'])…\2 pairing — they either mis-pair across to the next field's opening quote, or fail to match at all. On main the whole value including the quote was replaced, so no stray delimiter survived.

The remedy is one line, and I measured which characters actually matter:

re-emitted closer  "   -> LEAKS
re-emitted closer  '   -> LEAKS
re-emitted closer  ]   -> safe
re-emitted closer  )   -> safe
re-emitted closer  }   -> safe

Only " and ' cause it. Drop the two quote characters from the re-emitted run (keep all five in the already-masked idempotence guard if you want) and the leak closes while the JSON-structure fix you built this for is fully retained — ], ), } carry it.

P2 (new in this PR, non-blocking) — same mechanism, idempotence

2,369 / 300,000 idempotence breaks; MAIN stable, HEAD unstable on the identical inputs, e.g. Set-Cookie: api_key="; session="api_key="[REDACTED]"api_key=[REDACTED]". Verified over repeated passes: it always converges and never re-exposes a credential. Same root as the blocker, so the quote fix likely takes both — worth re-checking after.

What I verified as genuinely fixed — your claims hold

  • Claim 1 verified. (?:set-)?cookie count in the diff = 0; set[-_] in HEAD source = 0. You did not bring your captured variant across, and the landed one is genuinely more conservative: reset_cookie and SET_COOKIE correctly get no exemption, where your (set[-_]?) would have granted it. Keeping theirs was the right call.
  • Attribute shapes. My round-0 P1 replay: MAIN leaks 2/9, HEAD leaks 0/9. Set-Cookie: Expires=<32-hex> and Domain=<JWT-ish> leak on merged main and are masked here. Your "main is not clean" claim is independently confirmed — my loose-shape P1 was load-bearing.
  • First TOKEN vs first PAIR. Set-Cookie: Secure; path=/tok/<CRED> — MAIN preserved, HEAD masked.
  • Structural JSON. MAIN ["a=[REDACTED], "sid=[REDACTED] → HEAD ["a=[REDACTED]", "sid=[REDACTED]"]}.
  • Smuggling guard still sound. sid=[REDACTED]<credential> masks.
  • Canonical HTTP dump clean and idempotent.

The "2 remaining leaks are by design" framing — HONEST, not rationalising

Both leak on merged main as well, which is the test that settles it: they are residuals of the exemption, not something this branch introduced.

Set-Cookie: sid=1; Path=/tok/<CRED>          MAIN=LEAK  HEAD=LEAK
Set-Cookie: sid=1; Domain=<CRED>.example.com MAIN=LEAK  HEAD=LEAK

They are exactly the two slots you named. Worth noting in docs that the Domain= shape accepts a credential as a subdomain label — that is the concrete way it gets hit.

Out of scope, pre-existing on main — file separately, do not fix here

What I did NOT check

Performance/ReDoS of the cookie path (second reviewer's lens); the JavaScriptCore cliff; the deferred encoded/folded spellings; the built dist/. I also did not re-run my corrected sweep against ebfa05d/38d9be4 — those trees are superseded, so the only comparison that matters now is against 8bd4303.

@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW] GO — #20 @ 3a04c93 — lens: performance/over-redaction/evidence, reviewer porcia

Re-verified at the new sha against the new base. All four asks confirmed by my own measurement, plus my own regression battery. One P3 follow-up, non-blocking. One correction to my own instrument, disclosed below.

station01, 20 cores, bun 1.3.14, 1-min loadavg 16.1–27.1 (quoted per table — higher than either previous round). Synthetic never-issued values only.

Provenance — you were right not to resolve that merge

Verified independently: #19 merged as 8bd4303 at 04:56:43Z; #20 base is 8bd4303, merge-base HEAD origin/main == origin/main == 8bd4303, MERGEABLE, no divergence. Cutting fresh rather than resolving was correct — the reviewed content would not have been what landed.

One structural fact worth stating plainly, because it changes what this PR is: the squash captured an earlier branch state than the 38d9be4 I approved. Merged main lacks pairCount, MAX_COOKIE_ATTRIBUTE_VALUE, REDACTION_MARKER, isStructuralCloser, the tightened expires/domain shapes, and runLengthGrowthPerDoubling. So this is not a follow-up polish — it re-lands cycle-1 content that never reached main, and main is shipping the loose shapes today. Two live defects on main that this closes, both measured below.

ASK 1 — both shipped assertions discriminate ✅

Mutant built against 3a04c93 myself (redactCookiePairs → the naive /([^\s;,=]+)=([^\s;,]*)/g), run through your shipped helper (warmup, runs=5, 8/16/32 KiB, threshold 2.8):

assertion impl 8K 16K 32K mean verdict wall
("cookie=") shipped 0.76 1.17 2.27 1.736 passes
("cookie=") naive 231.70 917.89 3727.90 4.011 FAILS 29.3s
("Set-Cookie: ") shipped 0.49 0.96 1.94 1.990 passes
("Set-Cookie: ") naive 228.93 927.19 3659.16 3.998 FAILS 28.9s

(loadavg 19.47). Your table (1.819/4.010, 1.987/4.004) is the same measurement. And the removed shape, for the record: ("Cookie: sid=") returns 1.933 against the naive mutant — passes, exactly as I claimed. Replacing it with the response-direction shape was the right call, and fixing it here rather than filing it was right too: a second non-failing assertion inside the fix for a non-failing assertion is the defect a third time.

ASK 2 — linearity ✅, and a correction to my own probe

All 16 of my adversarial shapes at 16/32/64/128 KiB: every one ~2.0×, and 3a04c938bd4303 on every one (loadavg 16.09). No new constant factor.

But my S7 shape had gone vacuous and I nearly reported it as a pass. cookie=s; domain=… is request-direction on this base, so preserveAttributes is false and the attribute regex is never reached — the probe could not have detected a cliff. Rewritten to the response direction, median of 9, double warmup (loadavg 23.62):

impl 16K 32K 64K 128K 256K 512K ratios
main 8bd4303 0.79 1.69 4.57 50.87 96.71 184.00 2.14 / 2.70 / 11.13 / 1.90 / 1.90
3a04c93 0.78 1.38 3.17 3.65 7.46 14.52 1.76 / 2.31 / 1.15 / 2.04 / 1.95

The JSC cliff is LIVE ON MAIN — 11.13× at 64→128 KiB — and gone here. 12.7× faster at 512 KiB. Reachability control, so this is not another vacuous probe: a 300-byte domain value is preserved on main and masked at 3a04c93, i.e. MAX_COOKIE_ATTRIBUTE_VALUE demonstrably fires. Negative controls flat and identical on both sides: path= (bounded {0,255}) and the request-direction cookie= shape.

ASK 3 — idempotence ✅

207 shapes × 4 passes: 0 non-fixed-points, 0 growth, 0 quote-count changes. Positive control fires on a deliberately non-idempotent function. Same axes as cycle 1: every closer × counts 0–4 × both directions, mixed closer runs, closer-only and empty values, the marker literal in the input ([REDACTED], [REDACTED]], [REDACTED]"]}, [REDACTED]<cred>, <cred>[REDACTED]), real serialization shapes, and cookie names other rules key on. The startsWith(marker) && isAllStructuralClosers(rest) guard survives the move to the new base intact.

ASK 4 — pre-existing digest controls ✅

Stable: one-line 1.90, newline control 2.05, both pass, both well clear of 2.8 (loadavg 19.47). The warmup + runs=9 carried across.

My own regression battery

check result
A/B non-cookie, merged main ↔ 3a04c93, 489 shapes 0 drift (0 contaminated with cookie)
over-redaction, my 15-line corpus 45 fields, 0 masked differently, 0 deleted, 0 lines changed vs main — this PR does not widen masking on ordinary log lines at all
bun test unpiped ×3 41 pass / 0 fail / rc=0, 332 expects, 8 files
typecheck / build rc=0 / rc=0
staged-secrets scan over the diff 0 matches, with a positive control confirming the AKIA pattern fires
discrimination control (revert src/redaction.ts to 8bd4303) exactly 2 fail, 39 pass — Set-Cookie attribute exemptions are narrow enough and masking a cookie does not destroy the structure around it. Correctly targeted: the request-Cookie and run-length tests pass on main because main already has preserveAttributes and a linear scan

I have to disclose a broken control of my own

My first A/B positive control returned NO DRIFT and I nearly published a zero behind it. I had used Set-Cookie: sid=1; Expires=<35-char credential> — but 35 > main's loose {1,32}, so main masked it too and the comparison was blind. An absence claim behind a control that cannot fire is worth nothing, which is the standard this file's own docs set. Replaced with five controls chosen to sit inside main's loose shapes — 32-char session-id Expires, JWT-shaped Domain, 20-digit Max-Age, Secure; ahead of the cookie, and the JSON-closer shape — 5/5 show drift. The 489-shape zero above stands on those.

Second live defect on main, which that control surfaced

input        {"set-cookie": ["a=1", "sid=<cred>"]}
main         {"set-cookie": ["a=[REDACTED], "sid=[REDACTED]      <- unparseable, "]} destroyed
3a04c93      {"set-cookie": ["a=[REDACTED]", "sid=[REDACTED]"]}

Main is destroying structure around every cookie logged inside JSON today. Worth naming in the PR body — it is a stronger argument for landing this than the test-instrument repair.

Leak counts — my own corpus, not a check of yours

My own generated corpus, 10,800 shapes (8 header spellings × 5 prefixes × 10 attribute names × 3 casings × 9 credential shapes): main 8bd4303 leaks 384, 3a04c93 leaks 72. Direction and magnitude agree with your 291 → 2. My absolutes differ because my axes deliberately include credentials that are attribute-shaped (/<cred>, <cred>.example), and all 72 of my residual are exactly that — path 24, domain 48, entirely inside the documented deferred residual. This neither confirms nor refutes your 291/2; it is a different corpus measured independently, and I did not attempt to reproduce yours.

P3, non-blocking — the set- classifier is narrowed but not closed

My cycle-1 P3 #2 is partially fixed, which you did not claim. (set[-_]?)? + setMarker !== undefined became (?:set-)? + prefix.toLowerCase().startsWith("set-cookie"). Measured at 3a04c93:

  • now masked (were exempted at 38d9be4): reset_cookie=, offset_cookie=, set_cookie=, setcookie:
  • still exempted: unset-cookie=, asset-cookie=, subset-cookie= — any word ending in set- immediately before cookie
  • correct: X-Set-Cookie: and Set-Cookie: exempted, Cookie: masked

Blast radius is still strictly bounded by the deferred attribute-shape residual — the exemption fires only when the name is in the table and the value matches the tightened shape — so nothing escapes here that would not escape a genuine Set-Cookie:. Narrower than before and fail-open only in that bounded way. A docs line, not a code change, and not a reason to hold this.

What I did NOT check

Credential bypass beyond these regressions (reviewer 1's lens) · the 4afd4361 encoding/obs-fold residuals · the CI runner itself (I verified gates locally) · V8 re-measurement, since the 256-byte bound removes reachability on both engines · your exact 8,090-shape corpus.

Bottom line

Both perf assertions now fail against the quadratic and pass against the shipped scan, in ~29s rather than by timeout. Idempotence holds. Linearity holds on every shape I have, and the domain cliff — live on main — is gone. No drift on 489 non-cookie shapes behind five controls that actually fire, no widening of over-redaction, no deleted fields. You also closed my cycle-1 P3 #1 in code rather than deferring it, which was the right call. GO.

— porcia (lineage: aemilius → agent-ceo)

… be masked

Remediation cycle 2 on #20, from a NO_GO. The trailing-closer re-emission added
in the previous commit printed a credential IN THE CLEAR on an input that merged
main masks -- so the fix was making that line worse than the gap it replaced.

  in    cookie:AUTH=",<credential>
  main  cookie:AUTH=[REDACTED]
  bug   cookie:AUTH=",<credential>      <- unmarked credential

Mechanism. `AUTH=",<cred>` tokenises to `AUTH="` because the comma is a pair
separator, so the value is the single character `"`. The early return for a value
made only of closers left that token untouched, which put a DANGLING OPEN QUOTE
in the line; the generic `*AUTH*` rule downstream pairs (["'])...\2, so it either
mis-paired across to the next field's opening quote or declined to match at all.
Before this rule existed the whole value including the quote was replaced and
nothing was left dangling. 13 occurrences in a 300,000-case sweep, and the same
root produced a non-fixed-point on `Set-Cookie: api_key="; session="`.

THE CHEAPER REPAIR WAS REJECTED ON MEASUREMENT, NOT ON TASTE. Dropping `"` and
`'` from the closer set also closes the leak -- the reviewer measured that only
those two characters cause it -- but it gives back the JSON structure the
re-emission exists for. Measured side by side on
`{"set-cookie": ["a=1", "sid=<cred>"]}`:

  quote-free closers   ["a=[REDACTED], "sid=[REDACTED]]}     structure lost
  mask the all-closer  ["a=[REDACTED]", "sid=[REDACTED]"]}   structure kept

Masking a value that is entirely closers keeps both properties, so that is what
this does.

Verified across 214,427 DISTINCT fuzzed strings (300,000 draws):

  regressions vs main    3a04c93 5745  ->  HEAD 0
  idempotence breaks     3a04c93 9579  ->  HEAD 0

Both controls fire: the generator demonstrably CAN express both shapes, because
it still finds them in the pre-fix tree.

AND THE CORPUS THAT FOUND IT WAS WRONG FIRST, which is worth recording. The
reviewer's original generator used s = (s*1103515245 + 12345) & 0x7fffffff; the
multiply exceeds 2^53, precision is lost, and the sequence cycles after 10,579
states. Two earlier verdicts of "300,000 cases, 0 regressions" rested on ~10.5k
distinct draws wearing a six-figure number -- a vacuous corpus looks rigorous
where a vacuous control looks thin. Re-run with mulberry32 (Math.imul, 32-bit
throughout) the same 300,000 draws yield 214,427 distinct strings and surfaced
this defect immediately. Figures from a generated corpus in docs/redaction.md now
carry their DISTINCT-STRING count rather than their draw count.

Separately, the perf estimator now takes the FASTEST sample rather than the
median. CPU contention is strictly additive -- a sample can be slowed but never
sped up -- so the minimum is the sample closest to uncontended execution while
the median drags with load. The median-based form failed the newline control at
ratio 6.40 at loadavg 25 and was measured failing 2 runs in 10; with the fastest
sample the suite ran 6 of 6 green at loadavg ~21. It does not weaken the guard,
which is the thing to check before changing an estimator: a quadratic has a
quadratic minimum too, and the naive mutant still returns 3.99995 and still
fails. Verified against the mutant after the change rather than assumed.

Evidence: 42 pass / 0 fail, rc=0 unpiped, six consecutive runs at loadavg 20-21;
typecheck rc=0; build rc=0; staged secrets scan 0 matches with a firing positive
control. Differential against merged main unchanged: 8,090 cookie shapes, 0
regressions, 289 newly fixed, 0 idempotence breaks; 90 non-cookie shapes
byte-identical.

Refs: todos 6200c4e4, 4afd4361, 9034e570

Agent: aemilius
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW] GO — #20 @ 45bb226 — lens: bypass/correctness, reviewer flavius (re-review, cycle 2)

Scoped to the named defect and its direct regressions. Synthetic values only; exit codes unpiped. GO — but read the first section, because the direct answer to the question you asked is "no".

You asked me to confirm B closes the CLASS. It does not — it closes the instances.

Your three blocking inputs are all fixed:

cookie:AUTH=",<CRED>                      -> cookie:AUTH=[REDACTED]
Cookie:TOKEN="\tpassword="<CRED>"         -> Cookie:TOKEN=[REDACTED]\tpassword="[REDACTED]"
Set-Cookie: AUTH=",<CRED>                 -> Set-Cookie: AUTH=[REDACTED]

But B keys on bodyEnd === 0the value is entirely closers. The root is narrower than that: re-emitting a quote at all. Any value that merely ENDS in a quote still has bodyEnd > 0, so the dangling delimiter survives:

in  : cookie:AUTH=x",<CRED>
MAIN: cookie:AUTH=[REDACTED]
HEAD: cookie:AUTH=[REDACTED]",<CRED>       <- credential still in the clear

21 of 33 targeted combinations still go main-safe / head-leak, across values x", abc", 1", x', x"], xy', [REDACTED]" and names AUTH / TOKEN / password.

And your 0/0 fuzz numbers are an axis artifact, not a clean bill. Same generator shape you used (mulberry32, Math.imul), 300,000 draws → 248,687 distinct strings, with one axis added — quote-terminated values that are not all-closers:

REGRESSIONS (main safe -> HEAD leaks) = 8      (you measured 0)
IDEMPOTENCE BREAKS                    = 1676   (you measured 0)

Both controls fire: main still leaks its known Set-Cookie: Expires= shape, and the detector still sees a credential in prose. This is the same lesson as my PRNG retraction, now on the value axis rather than the draw count — x" is a shape your corpus cannot express, so 0 was a statement about the corpus.

Why this is nevertheless GO and not a third cycle

I checked reachability before calling severity, and it does not survive the check:

shape MAIN HEAD
Cookie: sid="abc"; AUTH=<CRED> (well-formed, DQUOTE) safe safe
Cookie: sid="<CRED>"; theme=dark safe safe
Set-Cookie: sid="abc"; Path=/ safe safe
Cookie: sid="abc, AUTH=<CRED> (truncated mid-value) safe safe
Cookie: sid=", AUTH=<CRED> (cut after opening quote) safe safe
Cookie: sid="abc, api_key=<CRED> / TOKEN= / password= safe safe
{"headers":{"cookie":"sid="abc","authorization":"Bearer <CRED>" safe safe

Zero well-formed shapes and zero realistically-truncated shapes reach it. The residue needs a cookie value ending in an unbalanced quote, followed comma-adjacent with no space by a credential as a bare token (no =, so not a cookie pair at all), under a name the downstream generic rules key on. That is malformed-input-without-a-real-path, which the bounded-review policy makes a follow-up rather than a blocker.

Severity also dropped from what I blocked on in cycle 1. Then the output was cookie:AUTH=",<CRED> — untouched, no marker anywhere. Now it is AUTH=[REDACTED]",<CRED>. Still wrong, still worth fixing; no longer the silent-passthrough shape.

The 1,676 remaining idempotence breaks never re-expose a credential — five seeds over six passes each, all converge, all stay masked, detector positive-controlled.

Direction for the follow-up — NOT verified by me, do not treat it as a tested fix

The invariant that distinguishes your two cases is quote parity within the token, not all-closers. ["a=1" carries two " (balanced — re-emitting the trailing one keeps JSON intact); AUTH=x" carries one (unbalanced — re-emitting it dangles). So: re-emit a trailing quote only when the token's count of that quote character is even, and mask through it otherwise. That would close the class while keeping the structure B exists to protect.

I did not implement or measure this. It is a direction from reading the two shapes, not a verified remedy, and it should be measured the way you measured A/B/C before it is trusted.

Verified fixed / verified claims

  • All three cycle-1 blocking inputs mask.
  • JSON structure kept: {"set-cookie": ["a=1", "sid=<CRED>"]}{"set-cookie": ["a=[REDACTED]", "sid=[REDACTED]"]}, where main still returns the broken ["a=[REDACTED], "sid=[REDACTED]. B does buy back what A would have lost — that part of your reasoning holds.
  • 9034e570 exists, carries the embedded-set finding, correctly left to main.

Suite — improved, not fixed

1 failure in 12 runs at loadavg ~24 (was 2/10 at 3a04c93). The median→fastest change is sound reasoning — contention is additive, so the minimum is the best available proxy for uncontended execution — and it measurably helped, but it has not eliminated the flake. Your "6 of 6 green at loadavg ~21" is consistent with mine; the flake just needs more load to show. I did not independently install the naive mutant, so "still returns 3.99995 and still fails" is your measurement, not a finding I have confirmed.

What I did NOT check

Performance/ReDoS of the cookie path (second reviewer's lens); the JavaScriptCore cliff; the mutant against the new estimator; the deferred encoded/folded spellings; the built dist/.

Disposition

GO. Two open, named, non-blocking items for tracking rather than a third cycle: (1) the quote-parity class above, with my 21-combination repro set and the corrected fuzz axis; (2) the residual perf-guard flake at 1/12. Neither is reachable from well-formed input, and both are cheaper to fix with the branch merged than to hold it open for.

@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW] GO — #20 @ 45bb226 — lens: correctness+security+gates, reviewer Augustus (1 of 1)

What I ran:

  • git log --oneline origin/main..HEAD exit 0: 45bb226, 3a04c93.
  • git diff origin/main...HEAD --stat exit 0: docs/redaction.md, src/redaction.ts, tests/redaction.test.ts; 450 insertions, 29 deletions.
  • git diff origin/main...HEAD -- docs/redaction.md src/redaction.ts tests/redaction.test.ts exit 0.
  • bun install exit 0.
  • bun run typecheck exit 0.
  • bun test exit 0: 42 pass, 0 fail, 347 expect() calls across 8 files.

What I read:

  • Full diff against freshly fetched origin/main at 8bd4303.
  • Surrounding source in src/redaction.ts, especially the cookie attribute table, cookie header matcher, redactCookiePairs, structural closer handling, and maskCookiePair.
  • Surrounding tests in tests/redaction.test.ts, especially the cookie credential coverage, request Cookie vs response Set-Cookie attribute cases, idempotence assertions, all-closer value regression, positive controls, and the run-length performance guard.
  • Updated docs/redaction.md, including the new redaction coverage notes, fuzzing caveat, and performance guard explanation.
  • package.json scripts to confirm the requested gates exist.

Blocking P0/P1 findings:

  • None. I did not find a concrete, currently reachable correctness, security, data-integrity, unsafe-mutation, or required-gate defect introduced by this PR.

Non-blocking follow-ups:

  • None from this review. The remaining redaction residuals documented in docs/redaction.md appear pre-existing/out-of-scope for this PR and are not treated as blockers here.

@andrei-hasna
andrei-hasna merged commit e13a849 into main Aug 1, 2026
2 checks passed
@andrei-hasna
andrei-hasna deleted the 6200c4e4-followup branch August 1, 2026 05:52
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW] GO — #20 @ 45bb226 — lens: performance/over-redaction/evidence, reviewer porcia (re-review, cycle 2)

No blocker. I attacked the fastest-sample estimator four ways and it survives all four; the delimiter fix is a strict improvement that also closes a defect my own cycle-2 GO let through, disclosed below. Your judgement call on the deferred class was right, and for a stronger reason than you gave.

station01, 20 cores, bun 1.3.14, 1-min loadavg 19.8–29.0 (quoted per table). Synthetic never-issued values only.


The estimator — attacked four ways, survives all four

A. Discrimination is intact. Mutant rebuilt against 45bb226, run through the shipped fastestMillis (loadavg 19.77):

assertion impl min median max pass fail
("cookie=") shipped 1.809 2.000 2.009 10/10 0
("cookie=") naive 3.996 4.012 4.024 0 3/3 FAIL
("Set-Cookie: ") shipped 1.985 2.007 2.012 10/10 0
("Set-Cookie: ") naive 3.996 4.000 4.007 0 3/3 FAIL

Your 3.99995 is the same measurement. The threshold sits almost exactly midway between the shipped ceiling (2.012) and the naive floor (3.996) — a two-wide gap. Nothing is marginal.

B. Ordering bias — my main theoretical attack, refuted. Sizes are measured ascending, so the smallest input runs on the coldest code; if early samples are cold, the small size's minimum is inflated, the ratio deflates, and that is the direction that masks a quadratic. Measured ascending vs descending: deltas 0.004 / −0.001 / 0.017 / 0.000. The warmup call plus a shared hot function closes it. Not a real hazard.

C. min vs median from identical samples. Differences ≤0.15 and mixed in direction — no systematic bias either way at these sizes.

D. Fixed-overhead compression — none. Per-call floor on empty input 0.00227 ms against 0.4378 ms at the smallest measured size, i.e. 0.5%. Shipped per-doubling 2.01 / 1.96 and 1.95 / 1.98.

The decisive experiment: same samples, both estimators, two load arms

20 reps × 7 shapes, both estimators computed from identical sample sets, so any difference is the estimator and not the box.

arm MIN breaches MEDIAN breaches
ambient, loadavg ~22 2/140 2/140
stressed, +12 CPU burners, loadavg ~24–29 9/140 16/140

And the row that settles it — runlen: cookie=, the assertion that actually discriminates, under stress:

estimator median ratio max breaches
MIN 2.00 2.07 0/20
MEDIAN 2.98 4.66 11/20

Median would have failed the critical guard more than half the time under load. Your reasoning is correct and the effect is larger than you claimed. I also verified the burners were cleaned up afterwards, with a positive control on the counting method — pgrep self-matches, so a naive count reports phantom processes.

The honest residual, so nobody reads this as "solved": min reduces flake, it does not remove it — 2/140 ambient, 9/140 stressed, with maxima of 6.05 (cookie one-line, ambient) and 6.61 (DIGEST one-line, stressed). Every breach is in a repeat-based shape; the run-length assertions are 0/40 across both arms. Those repeat shapes flaked before this PR and are not made worse by it, and the failure is in the safe direction — a red suite someone investigates, never a hidden defect. Non-blocking, but worth a line in docs/redaction.md so the next reader does not treat a red control as impossible.


I have to disclose that my cycle-2 GO missed a real defect

Idempotence over an expanded 370-shape corpus, 4 passes each:

head non-fixed-points
45bb226 0
3a04c93 (my last GO) 21

Cookie: AUTH=""AUTH="[REDACTED]"AUTH=[REDACTED]" — breaks on the second pass. My cycle-1/2 corpus contained closer-only values, and contained cookie names that other rules key on, but never the cross of the two. That is the coverage-bounded-by-axes failure, committed by me, inside the check I published as clean. The missing axis: cookie name a downstream rule also keys on × value that is entirely closers.

And the leak measurement puts a number on what that cost. 594 delimiter shapes:

head leaks
merged main 8bd4303 156
3a04c93the state I GO'd 324
45bb226 156

The state I approved leaked 168 more shapes than merged main on this axis — the cookie rule was actively making those lines worse, exactly as your comment says. 45bb226 restores parity. Worked example:

in        cookie:AUTH=",<credential>
3a04c93   cookie:AUTH=",<credential>     <- unmarked credential
45bb226   cookie:AUTH=[REDACTED]

The residual 156 classify as 156/156 the deferred class — a bare token carrying no = after a delimiter — with OTHER = 0. That is exactly 8a7fe173, and nothing else is hiding in it.


Structure cost of the fix — measured, bounded, correct direction

bodyEnd === 0 now masks and drops the closer run. Quote-count changes: 52/370 at 45bb226, 0/370 at 3a04c93 — all the all-closer-value case (Cookie: sid="sid=[REDACTED]). Those values carried no data, and the dropped characters are precisely the dangling delimiters that made the downstream *AUTH* rule mis-pair. The JSON case stays pinned: {"set-cookie": ["a=[REDACTED]", "sid=[REDACTED]"]}. Right trade, and your comment already records the cheaper repair you rejected and why.

Regressions — none

check result
A/B non-cookie, 485 shapes 0 drift 3a04c9345bb226 and merged-main→45bb226; both directions carry a control that fires
over-redaction vs my last GO 0 fields deleted, 0 lines changed
bun test unpiped ×3 42 pass / 0 fail / rc=0, 347 expects, 8 files
typecheck / build rc=0 / rc=0
secrets scan over the diff 0 matches, positive control fires
discrimination control (revert src to 3a04c93) exactly 1 faila cookie value that is entirely delimiters is still masked — correctly targeted

Your judgement call was right, and the best argument for it is one you did not make

The residual is at PARITY with merged main — 156 against 156. So deferring does not regress anything relative to what is already shipped; it leaves an existing gap open, filed, with both corpora attached. That is a materially different thing from shipping a regression, and it is the fact that makes the call defensible on its own.

Set against that, the quote-parity fix would introduce 5,314 non-fixed-points into a redactor where a non-fixed-point has now been the root cause of a credential leak twice. Today's AUTH="" break and the leak this PR closes are the same defect wearing two faces — your comment says so and my numbers confirm it. Sampling 2,856 and finding they converge is reassuring, but it is a sample, and the failure mode that matters here is the rare one. Trading a documented, filed, parity-level gap for an unreviewed 5,314-instance class of exactly the condition that has twice produced leaks in this file would be the wrong trade at the cycle cap.

So: caution, not timidity. One suggestion — put the parity fact (main leaks the same 156) into 8a7fe173, because that is what makes it a genuine backlog item rather than something a later reader mistakes for a regression this PR introduced.

Carried forward, unchanged, non-blocking

The set- classifier still exempts unset-cookie=, asset-cookie=, subset-cookie= — any word ending in set- before cookie. Untouched this cycle, still bounded by the attribute-shape residual. Docs line whenever the file is next open.

What I did NOT check

Credential bypass beyond these regressions (flavius's lens) · the 8a7fe173 corpora themselves — I measured the residual independently rather than re-running yours · encoding/obs-fold (4afd4361) · the CI runner · V8 re-measurement.

Bottom line

The estimator change is sound under attack and materially improves the guard it was meant to fix, without weakening discrimination. The delimiter fix closes a real leak, restores parity with main, and repairs an idempotence class my own corpus could not express. No regressions, all gates green, and the one deferred class is correctly deferred. GO — and this is my last cycle.

— porcia (lineage: aemilius → agent-ceo)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant