Skip to content

fix(redaction): stop the quoted-value scan terminating on an escaped quote - #24

Merged
andrei-hasna merged 1 commit into
mainfrom
fix/d841b3e1-redaction-terminator
Aug 1, 2026
Merged

fix(redaction): stop the quoted-value scan terminating on an escaped quote#24
andrei-hasna merged 1 commit into
mainfrom
fix/d841b3e1-redaction-terminator

Conversation

@andrei-hasna

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

Copy link
Copy Markdown
Contributor

Fixes the live credential leak in published @hasna/tai 0.1.5, installed on every station. Tracked as todos d841b3e1.

Do not merge this on my say-so — I am the author and this is security work. A reviewer is being arranged separately.

The defect

tai.redact — the tool whose only job is redaction — returns the credential with a [REDACTED] marker beside it. The rule engages and terminates in the wrong place, which is worse than no rule at all: the next reader greps for the marker, finds it, and stops looking.

It is reachable from an ordinary JSON.stringify(req.headers). No contrivance, no attacker. RFC 7616 Digest and Hawk carry quoted parameters natively, so one plain serialization already produces the shape:

{"headers":{"authorization":"Digest username=\"u\", response=\"<cred>\""}}

The quoted-value body (?:(?!\2)[^\r\n])* stops at the first quote character, regardless of the backslash escaping it. So it masked the four characters before that inner quote and emitted "authorization":"[REDACTED]"u\", response=\"<cred>\"".

Pre-existing and byte-identical across 0.1.3, 0.1.4 and main — nobody regressed anything and there is no revert to reach for.

The fix is one mechanism, not a list of shapes

The escape-aware value body is a single shared constant used by all eight rules that model a quoted value. Eight copies of a subtly wrong quoting model is exactly what this defect was, and this file has already grown four list-shaped guards.

The two branches are deliberately disjoint on their first character, and that is load-bearing. The obvious form (?:\\.|(?!\2)[^\r\n])* lets a backslash be consumed by either branch — a ReDoS wherever the closing quote is required and the branch can fail. Measured against a mutant carrying it:

N backslashes ambiguous form shipped (disjoint) form
18 0.25 ms ~0.001 ms
22 1.54 ms ~0.001 ms
26 10.35 ms ~0.001 ms
28 27.33 ms ~0.001 ms

Roughly 1.6^N. This file has shipped two quadratic rules already; a third arriving through the fix for a leak would be a poor trade.

Evidence — both directions

Synthetic canary throughout. No real credential was used or rendered at any point.

4 schemes x JSON nesting depth 0/1/2, both controls firing in every run:

before (main 06cc7de) after
depth 0, all four ok ok
depth 1 Digest / Hawk MISLEADING ok
depth 1 Basic / Bearer ok ok
depth 2, all four honest-gap honest-gap (left open, see below)
leaking cells 6 of 12 4 of 12

End-to-end through the MCP tool tai.redact (JSON-RPC stdio against the built dist, request serialized by json.dumps so the escaping is a real serializer's):

installed 0.1.5   depth1 Digest MISLEADING   depth1 Hawk MISLEADING
this build        depth1 Digest ok           depth1 Hawk ok
                  CONTROL must-redact ok     CONTROL must-leak leaks (probe can see a leak)

Negative control — base-vs-patched output drift over 191 inputs: 10 outputs changed, all 10 strictly safer (canary survived on base, masked here), 0 needing review, 0 non-idempotent. A change that redacts everything scores zero leaks and is a different bug; this one changes nothing else. Ordinary prose carrying the same literal stays visible, escaped quotes in non-credential JSON come back byte-identical, and neighbouring audit fields beside authorization=denied survive.

The canary matches no provider-prefix rule in this file, and that is the whole point. An sk--prefixed canary is masked by the prefix rule while the structural rule is still broken — the probe passes for the wrong reason. Varying only the canary on one fixed shape:

canary iapp-sms tai (before)
matches no prefix rule leaks + marker leaks + marker
sk- prefixed redacted redacted
sms_ prefixed redacted leaks + marker

Consequence, and it reverses the brief I was given: iapp-sms is not clean on these shapes and was not transplanted. On a plain Authorization: Digest ... response="<tok>" header it returns Authorization: [REDACTED], realm="r", ... response="<tok>" where tai returns Authorization: [REDACTED]tai has a dedicated Digest rule that iapp-sms lacks. Across the same 12-cell matrix iapp-sms leaked 8 cells to tai's 6. Copying it wholesale would have regressed this repo. Recorded in docs/redaction.md.

Tests introduce the axis

The corpus carried 21 digest fixtures, zero escaped quotes, zero Hawk (verified with a positive control). The suite's fixtures could not express this shape, so adding cases along existing axes would never have found it.

The two defect tests fail on unfixed main and pass here:

(fail) a JSON-serialized headers object does not leak the credential past an escaped quote
       -> Digest leaked through a JSON-serialized headers object
(fail) the marker never appears beside a surviving credential
       -> Digest printed a marker beside a surviving credential

The other four added tests are controls and guards, not regression tests, and pass on both sides by design — stated so nobody reads six passing tests as six proofs.

Gates

bun test        48 pass, 0 fail, rc=0   (baseline was 42 pass)
bun run typecheck                rc=0
bun run build                    rc=0
staged secrets scan          clean, with a positive control proving it can fire

Deliberately NOT fixed, and recorded rather than left silent

Headers serialized into a JSON string field (two JSON.stringify levels — pino/winston/axios-error) still leak for all four schemes. That is the rung above this one: the backslash sits between authorization and its :, so no rule engages and no marker is printed. Closing it means teaching the key prefix to cross escaping, which is adjacent to the normalisation-layer decision already ruled a documented won't-fix on todos 4afd4361. Kept out so this change stays one mechanism wide. Added as a row in docs/redaction.md.

Pre-existing flaky tests, unrelated to this change

The perf-growth family flakes on a loaded box. Measured on the unmodified base at loadavg ~18: 2 of 5 suite runs failed (the cookie rule stays linear on a cookie-dense single line, the Digest rule stays linear on a single long line); this branch passed 5 of 5. An interleaved A/B of the specific assertion gave base 1.99 and patched 2.00 against a 2.8 threshold — identical. Not caused by this PR; filed separately rather than fixed here, so this PR's evidence stays about one thing.


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

…quote

`tai.redact` — the tool whose only job is redaction — returned the credential
with a `[REDACTED]` marker beside it. The rule engaged and ended in the wrong
place, which is worse than no rule: the next reader greps for the marker, finds
it, and stops looking.

Reachable from an ordinary `JSON.stringify(req.headers)`. No contrivance and no
attacker: RFC 7616 Digest and Hawk carry quoted parameters natively, so one
plain serialization already produces

  {"headers":{"authorization":"Digest username=\"u\", response=\"<cred>\""}}

The quoted-value body `(?:(?!\2)[^\r\n])*` stops at the first quote CHARACTER
regardless of the backslash escaping it, so the rule masked the four characters
before that inner quote and emitted
`"authorization":"[REDACTED]"u\", response=\"<cred>\""`.

Pre-existing and byte-identical in 0.1.3, 0.1.4 and main, so there is nothing to
revert.

THE FIX IS ONE MECHANISM, NOT A LIST OF SHAPES. The escape-aware body is now a
single shared constant used by all eight rules that model a quoted value, rather
than eight copies of a quoting model — eight copies of a subtly wrong model is
what this defect was, and this file has already grown four list-shaped guards.

The branches are deliberately DISJOINT on their first character. The obvious
form `(?:\\.|(?!\2)[^\r\n])*` lets a backslash be consumed by either branch,
which is a ReDoS wherever the closing quote is required and the branch can fail:
measured against a mutant carrying it, 0.25/0.57/1.54/3.96/10.35/27.33 ms at
N=18..28 backslashes (~1.6^N) versus a flat ~0.001 ms for the shipped form. This
file has shipped two quadratic rules already; a third arriving through the fix
for a leak would be a poor trade.

Evidence, synthetic canary throughout — no real credential used or rendered:

  4 schemes x JSON nesting depth 0/1/2, both controls firing in every run
    before  6 of 12 cells leak (depth-1 Digest and Hawk misleading)
    after   4 of 12, all of them the depth-2 rung left open below
  end-to-end through the MCP tool `tai.redact` on the built dist
    installed 0.1.5  depth-1 Digest MISLEADING, Hawk MISLEADING
    this build       both ok, must-redact control ok, must-leak control leaks
  base-vs-patched drift over 191 inputs
    10 outputs changed, all 10 strictly safer, 0 needing review, 0 non-idempotent

The canary matches no provider-prefix rule in this file, and that is
load-bearing: an `sk-`-prefixed canary is masked by the prefix rule while the
structural rule is still broken, so the probe passes for the wrong reason. That
is how a sibling implementation was read as clean on shapes it in fact leaks on.

STILL OPEN, recorded in docs rather than silently left: headers serialized into
a JSON string field (two `JSON.stringify` levels) leak for all four schemes.
That is the rung above this one — the backslash sits between `authorization` and
its `:`, so no rule engages and no marker is printed. Closing it means teaching
the key prefix to cross escaping, which is adjacent to the normalisation-layer
decision ruled a documented won't-fix, and is kept out so this change stays one
mechanism wide.

Tests introduce the axis rather than extending the list: the corpus carried 21
`digest` fixtures but zero escaped quotes and zero Hawk, so no amount of adding
cases along the existing axes could have expressed the shape. The two defect
tests fail on unfixed main and pass here.

Refs: todos d841b3e1

Agent: atticus
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW] GO — #24 @ eac8401 — lens: credential-leak, reviewer Lucretius (1 of 1)

Independent review. Everything below was re-measured from the two commits, not read from the PR body. Synthetic canary throughout (CANARYd841b3e1…, chosen to match no provider-prefix rule in this file) — no real credential was used or rendered at any point.

Premise: CONFIRMED, not assumed

The leak reproduces on the base commit 06cc7de through the shipped tai.redact MCP tool — JSON-RPC over stdio against the built dist/mcp/index.js, request serialized by a real json.dumps — not a unit stub.

{"headers": {"authorization": "[REDACTED]"u\", realm=\"r\", … response=\"CANARY…\""}}

The marker sits beside the surviving credential. The pathology is exactly as filed.

Matrix — 4 schemes x JSON nesting depth 0/1/2, both builds

depth scheme BASE 06cc7de HEAD eac8401
0 all four ok ok
1 Basic, Bearer ok ok
1 Digest, Hawk MISLEADING ok (FIXED)
2 all four honest-gap honest-gap (unchanged)

Leaking cells: BASE 6/12 -> HEAD 4/12. Zero regressions. This reproduces the PR's own table exactly.

Both controls fired in both builds — must-redact fires, must-leak stays visible — so the probe is not vacuous in either direction.

1. Scope discipline — HELD

The two cells that changed are exactly the two that emit a marker (rule engaged, terminated wrong). All four marker-less depth-2 cells are byte-unchanged base->head. The change did not wander into 4afd4361 territory — which is the discriminator that ruling itself names: "that fixer is told to stay off marker-less cases."

2. Over-redaction — the trap this class of fix falls into: PASSED

I ran a 15-case corpus using neighbour sentinels — legitimate non-credential fields placed after a credential field, which vanish if the scan over-consumes. Two independent properties per case: canary absent (leak), sentinel present (over-redaction).

Shipped fix: zero over-redactions introduced. Ordinary prose carrying the literal stays visible; escaped quotes in non-credential JSON return byte-identical; Path=/, requestId and route all survive beside a redacted Digest/Hawk/Bearer value.

Proving the control can actually fail — I built a deliberate over-masking mutant (QUOTED_VALUE_BODY = [^\r\n]*):

SHIPPED : {"headers": {"authorization": "[REDACTED]"}, "requestId": "NEIGHBOUR…", "route": "SECOND…"}
OVERMASK: {"headers": {"authorization": "[REDACTED]"

The over-masker scores the same 4/12 on the leak matrix as the shipped fix — a leak count alone cannot tell a correct fix from this bug — but the sentinel detector fires on 5 cases the shipped fix passes. The fix is genuinely not an over-masker.

3. Test axis — genuinely INTRODUCED

Base tests/ across all 8 files: 0 escaped-quote literals, 0 Hawk, 0 JSON.stringify in the redaction tests. Positive control: Digest appears 16x capitalised / 3x lowercase, so the greps work and the zeros are real. The head adds all three axes. Adding cases along an existing axis would not have caught this.

4. Sabotage — fails, and for the RIGHT reason

Reverting only the terminator (old body restored, new tests intact) fails exactly the two defect tests, each with its own named assertion:

(fail) a JSON-serialized headers object does not leak the credential past an escaped quote
       -> Digest leaked through a JSON-serialized headers object
(fail) the marker never appears beside a surviving credential
       -> Digest printed a marker beside a surviving credential
29 pass, 2 fail, rc=1

Not incidental failures — the second asserts the marker-beside-credential pathology directly.

5. ReDoS claim — independently verified

The disjoint-branch design is load-bearing, not stylistic. Failing suffix, N backslashes:

N ambiguous form shipped disjoint form
16 0.758 ms 0.032 ms
22 1.916 ms 0.002 ms
28 34.348 ms 0.003 ms

Exponential versus flat, matching the PR's measurements in shape and order of magnitude.

Gates

bun run typecheck rc=0 · bun run build rc=0 · staged secrets scan clean, with a positive control proving the grep fires · full suite 48 pass / 0 fail on 2 of 3 runs (see F3). All exit codes measured unpiped.


Findings — none blocking

F1 (P1, non-blocking follow-up) — the negative control cannot fail on over-masking.
bun test tests/redaction.test.ts passes 31/31 on 3 of 3 runs against the over-masking mutant above, which demonstrably destroys neighbouring fields. The reason: the fixtures in "escaped quotes in ordinary non-credential JSON are preserved byte for byte" contain no credential key (user, note, path, msg), so no rule engages and QUOTED_VALUE_BODY is never exercised — the control passes without ever testing the constant it exists to guard. The shipped code is correct; the guard against the next edit is not. The remedy is one case: a credential key plus a following field, asserting the following field survives. (The full 48-test run did catch the mutant once — but only via a load-sensitive perf assertion, in 1 of 4 runs. That is not a guard.)

F2 (P2, pre-existing, NOT caused by this PR) — allowlisted non-secret values are masked.
authorization=denied -> authorization=[REDACTED], likewise =none, although both sit in NON_SECRET_AUTHORIZATION_VALUES. Byte-identical on base and head; this PR does not touch the allowlist (the only diff line naming it is a hunk header). The set is consulted at src/redaction.ts:548, inside the parameterized path only, while the generic authorization= rule matches first. This does not contradict the PR's claim, which says neighbouring audit fields survive — they do (user=alice and reason=… both survived).

F3 (P2, pre-existing, disclosed) — perf flakiness is slightly worse than stated.
The body reports the branch passing 5 of 5. I measured HEAD at 1 failure in 3 full-suite runs (the cookie rule stays linear on a cookie-dense single line) at loadavg ~17. Pre-existing, correctly filed separately by the author, unrelated to the terminator — but "5 of 5" reads as more settled than it is.

F4 (P3, accuracy nit) — the body says "21 digest fixtures"; I count 19 in tests/ (16 Digest + 3 digest), or 27 case-insensitive matches. The substantive claim — axis absent, digest present as a control — is true.

What I did NOT check

  • Windows/CRLF line endings, and non-JSON serializers (YAML, logfmt) that escape differently.
  • The 191-input drift corpus the body cites — an ad-hoc harness not in the repo. I ran my own 15-case corpus instead, so that number is unverified by me, though its conclusion is corroborated by mine.
  • Real pino/winston/axios output shapes beyond my synthetic two-level construction.
  • Performance under genuine parallel CI load — my ReDoS timings were taken in isolation.
  • Anything about the published npm artifact; publishing is not part of this PR.
  • Whether the depth-2 gap is reachable from this repo's own logging paths (documented as an honest gap, correctly out of scope).

Verdict: GO. The fix is one mechanism wide, correct, scope-respecting, and closes a live fleet-wide leak in published 0.1.5 where the marker was actively telling readers the line was clean. F1 should land as a follow-up before the next edit to QUOTED_VALUE_BODY, but it guards a future change rather than describing a current defect, and holding a correct security fix for it would leave the live leak in place.

@andrei-hasna

Copy link
Copy Markdown
Contributor Author

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

What I ran:

  • git rev-parse origin/main exit 0 -> 06cc7de6a4789e341a372fbd77f1b85cc55c938b.
  • git rev-parse HEAD exit 0 -> eac84011025b4cef276dd0e33731965b092621cc.
  • git log --oneline origin/main..HEAD exit 0 -> one commit, eac8401 fix(redaction): stop the quoted-value scan terminating on an escaped quote.
  • git diff origin/main...HEAD --stat exit 0 -> docs/redaction.md, src/redaction.ts, tests/redaction.test.ts; 246 insertions, 11 deletions.
  • bun install exit 0.
  • bun run typecheck exit 0.
  • bun test exit 0 -> 48 pass, 0 fail, 382 expect calls.
  • Targeted redaction probes with bun --print exit 0: one-level JSON Digest and Hawk escaped-quote credentials did not leak; safe escaped-quote JSON stayed byte-identical; two-level serialized-string Basic/Bearer/Digest/Hawk still leaked without a [REDACTED] marker, matching the documented unchanged honest gap.

What I read:

  • Full diff against 06cc7de6a4789e341a372fbd77f1b85cc55c938b...HEAD for src/redaction.ts, tests/redaction.test.ts, and docs/redaction.md.
  • Surrounding implementation in src/redaction.ts, including the shared QUOTED_VALUE_BODY, SECRET_PATTERNS, redactDigestResponse, redactCookieHeader, redactCookiePairs, maskCookiePair, and redactParameterizedAuthorization.
  • Surrounding tests in tests/redaction.test.ts, including the existing Digest/cookie performance and idempotence coverage plus the new escaped-quote axis.
  • Redaction call sites in src/agentic.ts, src/mcp/index.ts, src/proposal.ts, and src/agentic-plan.ts via rg and focused source reads.
  • package.json scripts to confirm typecheck exists.

Blocking P0/P1 findings: none.

Non-blocking follow-ups:

  • The two-level serialized JSON string gap remains reachable and is explicitly documented as an honest gap; I did not treat it as blocking because it is unchanged from the base and outside this one-mechanism fix.
  • docs/redaction.md still contains unchanged rows describing an earlier Digest response= quadratic as live P1, while the current source and redaction tests exercise the restructured linear Digest path. That documentation cleanup is useful, but it is not a blocker for this PR because the rows are pre-existing and this change does not reintroduce that behavior.

@andrei-hasna
andrei-hasna merged commit f56fc81 into main Aug 1, 2026
2 checks passed
@andrei-hasna
andrei-hasna deleted the fix/d841b3e1-redaction-terminator branch August 1, 2026 08:28
andrei-hasna added a commit that referenced this pull request Aug 1, 2026
0.1.5 returns the credential beside a [REDACTED] marker whenever the value
arrives with an escaped quote, which is what an ordinary
JSON.stringify(req.headers) produces for RFC 7616 Digest and Hawk. Merged as
#24 (head eac8401); reviewed GO by Lucretius, who reproduced the leak on base
through the shipped MCP path.

Ships the merged fix. No source change in this commit beyond the version bump
and the changelog entry.

Agent: Papinian
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