Skip to content

fix: the scanner detects a secret token immediately abutting another fixed-length token (iss-185) - #201

Merged
REPPL merged 6 commits into
mainfrom
bugfix/iss-185-scanner-adjacent-secret-boundary-bypass
Aug 5, 2026
Merged

fix: the scanner detects a secret token immediately abutting another fixed-length token (iss-185)#201
REPPL merged 6 commits into
mainfrom
bugfix/iss-185-scanner-adjacent-secret-boundary-bypass

Conversation

@REPPL

@REPPL REPPL commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Fixes iss-185 (critical), a bug this round's ledger-first pickup resumed from a prior round's multi-angle sweep (already verified and captured, not re-hunted this round per protocol).

The bug

Every bundled secret pattern in internal/adapter/scanner/patterns.go anchors its start on a leading \b. When two fixed-length secret tokens are concatenated with no separating byte (e.g. two github_pat_... tokens back to back), the byte immediately before the second token is the first token's own last byte — a word character — so the transition into the second token's first byte (also a word character) is word/word, and \b can never hold there. ScanText (internal/adapter/scanner/scanner.go) therefore found only the first token; Redact left the second one completely raw; and history's stage-two fail-closed residual re-scan (internal/core/history/history.go) reported the redacted output clean anyway — a live secret could reach disk despite the tool's own documented "can never contain a live secret" guarantee.

Reproduced with TestConcatenatedSecretsBothDetected (internal/adapter/scanner/adjacency_test.go), watched failing on the pre-fix code for the exact claimed reason (1 finding instead of 2) before any change was made.

What changed and why

  • adjacencyProbe (scanner.go) compiles an \A-anchored, \b-stripped variant of each pattern (looking past any inline flag group like (?i), which two of the network patterns use).
  • scanAllPatterns (scanner.go) runs the normal per-pattern scan, then probes every pattern's anchored variant at the exact byte offset right after each match — a hit there is anchored by the adjacency itself, not by \b, so it can't introduce a false match elsewhere in the line. Deduplicated against matches the normal scan already found.

This went through two rounds of pre-PR adversarial review (a fresh subagent per review, per this loop's protocol), each surfacing a real problem the previous pass had introduced or missed, fixed before opening this PR:

  1. Cross-family miss + duplicate-count regression. The first pass only probed a match's own pattern, missing a mixed pair (e.g. a GitHub PAT immediately followed by an AWS key). Separately, google_api_key is fixed-length but its charset includes -; when the 35th body char is -, \b already holds at the junction, so the naive probe double-counted an already-found match. Fixed by probing every pattern at each match's end, deduplicated. New tests: TestConcatenatedDifferentFixedLengthSecretsBothDetected, TestGoogleAPIKeyDashJunctionNotDoubleCounted — both watched fail on the intermediate pass, then pass.
  2. Unanchored-probe performance regression. The probe used an unanchored FindStringIndex against the line's remainder, discarding anything not starting at offset 0 — an O(matches × patterns × line length) cost, measured by review at 14–49s on large single-line inputs (exactly what history.Capture scans unbounded). Fixed by anchoring the probe (\A). New test: TestAdjacencyProbeStaysLinearOnLongLines (200KB line, asserts well under 2s; verified ~100ms after the fix vs. the reviews' reported multi-second hang before it).

Known, separately-tracked gap (not fixed here)

Review also confirmed a genuinely distinct, harder problem: a pattern with an open-ended quantifier (e.g. \bghp_[A-Za-z0-9]{36,}, not a fixed {22}) can greedily over-consume into a following token's own bytes before the adjacency probe's junction is ever reached ("ghp_"+36×"a"+"ghp_"+36×"b" still yields only one finding). Fixing that needs a bounded backward search that doesn't become unbounded-cost on long lines — a bigger, separate piece of work. Captured as iss-188 rather than folded into this diff, which stays scoped to the bug iss-185 actually named (fixed-length patterns).

Review also noted two scenarios that are pre-existing \b-boundary limitations, not regressions from this diff: a real secret preceded by non-matching decoy junk, and a secret immediately following an identity-derived match (the latter is fail-closed, not silent — the identity match's own redaction changes the adjacent byte from word to non-word, letting \b hold on history's second, residual pass).

Evidence

  • Bug: internal/adapter/scanner/scanner.go:274 (pre-fix ScanText loop), internal/adapter/scanner/patterns.go:93 (github_pat_finegrained), internal/core/history/history.go:159 (blockingResidual).
  • Fix: internal/adapter/scanner/scanner.go (adjacencyProbe, scanAllPatterns).
  • Tests: internal/adapter/scanner/adjacency_test.go.
  • Ledger: iss-185 resolved (.abcd/work/issues/resolved/), iss-188 newly captured (.abcd/work/issues/open/).
  • Round record: .abcd/work/DECISIONS.md.
  • User-facing note: CHANGELOG.md under [Unreleased] / Fixed.

make preflight (build, gofmt -l . empty, vet, test, race) is green; record-lint reports 0 blockers.

Assisted-by: Claude:claude-sonnet-5

claude added 6 commits August 5, 2026 18:15
… first (iss-185)

Every bundled secret pattern anchors its start on a leading \b. When two
same-family tokens are concatenated with no separating byte, the byte just
before the second token is itself a word character (the first token's own
last byte), so that boundary can never hold and the second token was never
matched at all — Redact then left it completely raw, and history's
fail-closed residual re-scan reported the output clean anyway.

adjacencyRegexp derives a boundary-free variant of each pattern (its \b
stripped, recompiled); findAllMatches tries it ONLY at the exact byte
offset immediately after a confirmed prior match of the same pattern, so
adjacency itself is what anchors the probe, not \b — it cannot introduce a
false match anywhere else in the line.

Repro: internal/adapter/scanner/adjacency_test.go,
TestConcatenatedSecretsBothDetected (fails on prior code, passes after).

Resolves iss-185 in the capture ledger; records the round in DECISIONS.md
and adds a CHANGELOG entry.

Assisted-by: Claude:claude-sonnet-5
… not just its own (iss-185 review fix)

A pre-PR adversarial correctness review and a pre-PR adversarial security
review of the first pass at this fix independently found two real problems:

1. The adjacency probe only checked the SAME pattern that produced the
   preceding match, so two different fixed-length patterns glued together
   (e.g. a GitHub fine-grained PAT immediately followed by an AWS access
   key) still went undetected on the second token.
2. google_api_key is fixed-length but its charset includes '-', a non-word
   character; when the 35th body char happens to be '-', a trailing \b
   already holds at the junction, so the ORIGINAL scan already found both
   tokens on its own — and the adjacency probe appended the second one a
   second time, double-counting a real finding.

Replaced findAllMatches (which only recovered a match's own pattern) with
scanAllPatterns: it probes EVERY pattern's boundary-free variant at each
match's end, and de-duplicates against matches already found by the normal
per-pattern scan. adjacencyRegexp also now looks past a leading inline flag
group (`(?i)`) before stripping `\b` — two bundled network patterns
(lanHostRe, deviceHostRe) compile with `(?i)\b...`, which the first pass
silently gave no adjacency coverage to.

Both reviewers also confirmed a genuinely separate, harder gap: patterns
with an open-ended quantifier (`{36,}`, not a fixed `{22}`) can greedily
over-consume into a following token's own bytes before this probe ever
runs, at a length that a bounded backward search can't safely assume — that
residual gap is captured as iss-188 rather than folded into this diff,
which stays scoped to the exact bug iss-185 named (fixed-length patterns).

New tests: TestConcatenatedDifferentFixedLengthSecretsBothDetected (mixed
fixed-length family, watched fail on the prior pass and pass now),
TestGoogleAPIKeyDashJunctionNotDoubleCounted (the duplicate-finding
regression, likewise watched fail then pass).

Assisted-by: Claude:claude-sonnet-5
A pre-PR adversarial security review of iss-185's fix found that the
adjacency recovery it added only closes the gap for fixed-length secret
patterns; an open-ended-quantifier pattern (ghp_, sk-ant-, etc.) can
greedily over-consume into a following token's own bytes before the
recovery probe ever runs. Distinct, broader problem — captured for its own
verify-and-fix cycle rather than folded into iss-185's scoped diff.

Assisted-by: Claude:claude-sonnet-5
…(iss-185 review fix)

A second pre-PR adversarial security review and a second pre-PR adversarial
correctness review both independently found the same regression: the
adjacency probe added by the prior commit called
`probe.FindStringIndex(line[end:])` unanchored, so it searched the ENTIRE
remainder of the line for a match anywhere and then discarded the result
unless it started at offset 0. Every (candidate match x pattern) pair paid
a full-suffix scan to answer a question only ever asked at one position —
O(matches x patterns x line length) instead of O(matches x patterns).
Measured by the reviews at 14-49 seconds on large single-line inputs
(minified assets, base64 blobs, long transcripts) that history.Capture
scans unbounded — a hang on exactly the kind of input a secret scanner
must survive, and squarely the resource-exhaustion risk iss-188's own
write-up said the narrower fix avoided.

adjacencyRegexp is replaced with adjacencyProbe, which compiles an
explicitly `\A`-anchored version of each pattern (after any inline flag
group, with its leading \b stripped) instead of returning nil and falling
back to the pattern's own unanchored Re. Verified: the reviews' own 200KB/
900KB repro lines now scan in ~100ms instead of several seconds, with
identical match counts (new test:
TestAdjacencyProbeStaysLinearOnLongLines).

The reviews also confirmed a decoy-prefix scenario ("AKIA"+short-junk+a
real AIza key) and an identity-adjacent-secret scenario are both real gaps,
but distinct from anything this diff regressed: they are the same
pre-existing \b-boundary limitation every bundled pattern already accepts
(a real secret preceded by content that isn't itself a recognized match
was never detectable here, before or after this fix), and the identity
case is fail-closed rather than silent (history's residual re-scan sees
the identity match's redaction change the adjacent byte from word to
non-word, letting \b hold on the second pass). scanAllPatterns' doc comment
is corrected to state this scope precisely rather than over-claim
"sufficient".

Assisted-by: Claude:claude-sonnet-5
…ine length) (iss-185 review fix)

Two independent fresh merge-gate reviews (correctness and security) both
found the same new problem in the anchored probe added by the prior
commit: \A bounds a probe to ONE start position, but not the cost of that
one attempt. net_lan_hostname and net_device_hostname carry their own
unbounded internal quantifier ([a-z0-9-]*); run against a long
terminator-free alnum run, a single anchored attempt scans to the end of
that run before failing. Repeated at every match junction on a line with
many back-to-back fixed-length tokens (exactly this bug's own trigger
shape), that is O(matches x remaining line length) -- reviews measured
several seconds on a few thousand back-to-back tokens, with no cap on file
size anywhere in the scan path.

maxAdjacencyProbeWindow (512 bytes -- comfortably larger than every bundled
fixed-length pattern's real match and a realistic DNS hostname) bounds
every probe attempt's input to a small fixed slice, so Go's RE2-based
regexp package's linear-time guarantee caps one attempt at O(window)
regardless of pattern internals or how long the rest of the line is.

New test: TestAdjacencyProbeWindowIsBounded (2000 back-to-back AKIA/AIza
tokens, watched fail at 5-9s on the prior commit, passes at ~100ms now).
Raised both perf regression tests' thresholds from 2s to 15s -- under
`go test -race` the bounded scan itself already costs 1.8-2.4s from
instrumentation overhead alone, which was tripping the tighter threshold
despite the fix being correct; 15s still clearly separates bounded
(~2s under race) from the multi-second-to-tens-of-seconds unbounded
behavior it guards against.

Also corrects DECISIONS.md and CHANGELOG.md, which still described the
abandoned first-pass mechanism (adjacencyRegexp/findAllMatches, an
own-pattern-only probe) rather than the current adjacencyProbe/
scanAllPatterns design -- flagged by a merge-gate correctness review as a
record-accuracy defect.

Assisted-by: Claude:claude-sonnet-5
…, iss-189)

Both merge-gate reviews (correctness, security) returned MERGE on the
current fix; each also raised a non-blocking follow-up.

- scanner.go's maxAdjacencyProbeWindow comment claimed no bundled pattern's
  real match could exceed 512 bytes -- false for open-ended-quantifier
  patterns (jwt_shaped, etc.), already tracked under iss-188. Corrected the
  comment rather than the behavior: truncated-but-partially-redacted is
  still strictly better than this function's pre-existing "not recovered
  at all" for that class.
- Captured iss-189: a probe's own trailing \b can be satisfied by the
  window's edge instead of real content on a contrived input, producing a
  spurious warn-severity finding and an over-redaction of legitimate
  content (never an under-redaction/leak). Non-blocking per both reviews;
  captured for its own verify-and-fix cycle rather than fixed inline.

No functional change to scanning behavior; full suite and -race still
green.

Assisted-by: Claude:claude-sonnet-5
@REPPL
REPPL merged commit 2ca3f4b into main Aug 5, 2026
12 checks passed
@REPPL
REPPL deleted the bugfix/iss-185-scanner-adjacent-secret-boundary-bypass branch August 5, 2026 21:00
@REPPL REPPL mentioned this pull request Aug 6, 2026
REPPL added a commit that referenced this pull request Aug 6, 2026
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.

2 participants