Skip to content

fix: mask sibling secrets by byte span in serialized scan snippets (iss-65) - #30

Merged
REPPL merged 1 commit into
mainfrom
auto/iss-65-scanner-secret-crossleak
Jul 12, 2026
Merged

fix: mask sibling secrets by byte span in serialized scan snippets (iss-65)#30
REPPL merged 1 commit into
mainfrom
auto/iss-65-scanner-secret-crossleak

Conversation

@REPPL

@REPPL REPPL commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Resolves iss-65 (native ledger) — the highest-severity item from the clean-slate adversarial sweep (a BLOCK).

The leak

The secret scanner's findings are serialized into the abcd launch --json report (which CI may archive). Each Finding stores its whole source line, but Finding.MarshalJSON masked only the finding's own matched token. So two secrets on one line each leaked the other verbatim into the report — e.g. a minified .env line gh=ghp_AAAA… other=ghp_BBBB…: finding A's snippet masked A but shipped B raw.

Fixes (all in internal/adapter/scanner)

  • Cross-leak (C14/C17, the BLOCK). New sealSnippets masks every finding's span out of the shared source line before serialization, by byte span (Column + len(Matched)), not substring. A byte covered by one span shows that token's head/tail fingerprint; a byte in an overlap of two+ spans is forced to *; uncovered bytes stay raw. O(n+k) per line.
  • Mid-rune sniff (C15). isText trims a dangling partial trailing rune before utf8.Valid, so a valid UTF-8 file whose multibyte rune straddles the 8 KB sniff cap is no longer misread as binary and skipped from scanning. The NUL check still runs on the full chunk first.
  • Unreadable file (C18). ScanBundle surfaces an unreadable file in Unscanned instead of dropping it silently; the zero-coverage sentinel still fails closed.

Why byte-span, not substring

The first iteration masked with strings.ReplaceAll (longest-first). The security-reviewer caught a second BLOCK: when two matches partially overlap (a greedy sk-ant-… key running into a following eyJ… JWT), masking the longer first destroys the shorter's substring, leaving its non-overlapping tail raw. Byte-span masking closes every overlap topology (partial, nested, 3-way, adjacent, duplicate, multibyte — all verified). It also makes the pass O(n+k), resolving a quadratic the ruthless-reviewer had flagged.

Tests (TDD, each watched fail→pass)

  • TestSerializedFindingRedactsSiblingSecret — two disjoint tokens, both directions.
  • TestSerializedFindingRedactsOverlappingSecret — the overlap repro (sk-ant-…-eyJ…).
  • TestIsTextMultibyteRuneAtSniffBoundary — a straddling byte 8192.
  • TestUnscannedUnreadableRecorded — an unreadable bundle file surfaced.

make preflight + make record-lint green. Reviewed: ruthless-reviewer SHIP, security-reviewer PASS (after finding + re-verifying the overlap BLOCK).

Deferred

P10 (per-repo pii.json skip-list has no per-fragment coverage floor) is document-accepted: the result-level zero-coverage sentinel already fails closed when nothing is scanned, blank/slash fragments are already rejected, and "over-broad" has no crisp threshold.

Assisted-by: Claude:claude-opus-4-8

…ss-65)

A serialized scan finding stores its whole source line and Finding.MarshalJSON
masked only the finding's OWN token, so two secrets on one line each leaked the
other into the abcd launch --json report (which CI may archive).

- sealSnippets (new, in ScanText) masks EVERY finding's span out of the shared
  line before serialization. Masking is by BYTE SPAN (Column + len(Matched)),
  not substring: a byte covered by one span shows that token's head/tail
  fingerprint, a byte in an overlap of two+ spans is forced to '*', an uncovered
  byte stays raw. Substring masking leaked the non-overlapping tail of a shorter
  match when a greedy neighbour (e.g. an sk-ant- key running into a JWT) partially
  overlapped it; byte-span masking closes every overlap topology. O(n+k) per line.
- isText trims a dangling partial trailing rune before utf8.Valid, so a valid
  UTF-8 file whose multibyte rune straddles the 8KB sniff cap is not misread as
  binary and skipped (the NUL check still runs on the full chunk first).
- ScanBundle surfaces an unreadable file in Unscanned instead of dropping it
  silently, matching binary-file visibility; the zero-coverage sentinel still
  fails closed.

TDD: disjoint, overlapping, straddling, mid-rune, and unreadable cases each
watched fail then pass. Reviewed: ruthless SHIP; security BLOCK on the first
(substring) fix's overlap leak, re-reviewed PASS on the byte-span fix.

Resolves iss-65 in the native ledger (abcd capture resolve). P10 (skip-list
coverage floor) deferred: the result-level zero-coverage sentinel already fails
closed, and per-fragment breadth capping has no crisp threshold.

Assisted-by: Claude:claude-opus-4-8
@REPPL
REPPL merged commit 5a49696 into main Jul 12, 2026
12 checks passed
@REPPL
REPPL deleted the auto/iss-65-scanner-secret-crossleak branch July 12, 2026 08:56
REPPL added a commit that referenced this pull request Jul 12, 2026
…31)

Ledger bookkeeping trailing the merged fixes:
- iss-70 (PR #29): receipt-gate detector binding + fail-closed arming + parser.
- iss-65 (PR #30): byte-span snippet masking (the BLOCK) + isText + Unscanned.

Assisted-by: Claude:claude-opus-4-8
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