Skip to content

Stop a mask's own edge from exposing half of a copy (#188) - #198

Merged
icebergai-review-bot[bot] merged 1 commit into
mainfrom
claude/codebase-review-cleanup-ovlli4
Aug 19, 2026
Merged

Stop a mask's own edge from exposing half of a copy (#188)#198
icebergai-review-bot[bot] merged 1 commit into
mainfrom
claude/codebase-review-cleanup-ovlli4

Conversation

@richardmhope

Copy link
Copy Markdown
Contributor

Fixes #188.

A redacted snippet could carry a fragment of the very secret it was masking — a violation of the invariant that only a masked snippet and a salted hash ever leave the engine (ADR 0004, CLAUDE.md invariant 2).

The defect

_context scrubs whole copies of a matched secret out of the text it emits, so a stray copy sitting between two masks was already covered. A copy that ran into the side of a mask was not: the mask cut it in two, the scrub never saw it whole, and the part sticking out was emitted verbatim — into the stored snippet, the console, notification payloads and hand-over payloads.

Two ways in, both reproduced against the merged code before fixing:

# a neighbouring match whose mask begins mid-copy
snippet: XX[20 chars redacted]--------SECRETAAAA[10 chars redacted] tail
                                      ^^^^^^^^^^ plaintext

# the target's own mask, where a periodic secret overlaps itself
snippet: [20 chars redacted]abcdefghij tail
                            ^^^^^^^^^^ plaintext

Ten contiguous characters of a twenty-character token in each case.

The existing defences only ever looked at the two window edges: _clip_to_secret_boundaries pulls those back past a straddling copy, and _assert_no_bisected_secret refuses the snippet if that failed. Interior seams had neither, though they emit context just the same.

The fix

  • Any copy overlapping a mask is now masked in full. The part sticking out gets a mask of its own rather than widening the mask it touches — the target's mask reports its own length and may reveal a structural prefix, and neither would still be true of a wider span.
  • The bisection check now guards every edge that emits context, not just the window's two.
  • Both the renderer and the check read the same _layout, so the check cannot come to disagree with what was actually emitted.

Validation

  • Three regression tests, one per seam (neighbour, own-mask right, own-mask left). Each was confirmed to fail against the pre-fix code and pass with it.
  • Fuzzed ~40k adversarial inputs — small alphabets so copies and self-overlap are dense, random targets/neighbours across four policies — checking positionally that no character belonging to a copy is emitted across a mask edge. Zero straddle leaks.
  • Zero cost in dropped findings. On the identical corpus the fail-closed rate is unchanged at 1173 of 40000 on both old and new code: the pathological cases that used to leak are now masked, not refused.
  • _occurrences bounds both ends of its search. Without a stop, the final find scans the rest of the unit to prove there is nothing more — a whole pass per secret per snippet on a large document. Benchmarked on an 85k-char unit with 200 matches: ~40µs added per snippet.
  • ruff / ruff format / mypy / make docs-check clean; full suite 1940 passed, 2 skipped (the two permission-bit tests, correctly skipped as root in this container; CI runs them).

Operator note

Snippets already in the database were written by the old engine and are not rewritten in place. Ingest refreshes a finding's snippet on every re-sighting (engines/ingest.py — "display metadata is refreshed"), so a full scan on engines carrying this fix replaces the stored snippet of every finding that still exists. A finding whose secret has since been removed keeps the snippet it was stored with. This is spelled out under Security in the CHANGELOG.

Not included: the related nit in #188 about the mask marker disclosing a short secret's exact length (hunter2[7 chars redacted]). That changes snippet format everywhere and is a visible-output decision worth taking on its own rather than bundling into a security fix — #188 can stay open for it, or it can move to its own issue.

🤖 Generated with Claude Code

https://claude.ai/code/session_012sohE85sRDt6t2w3936rGJ


Generated by Claude Code

A redacted snippet could carry a fragment of the very secret it was masking.

`_context` scrubs *whole* copies of a matched secret out of the text it emits,
so a stray copy sitting between two masks was already covered. A copy that ran
into the *side* of a mask was not: the mask cut it in two, the scrub never saw
it whole, and the part sticking out was emitted verbatim — into the stored
snippet, the console, notification payloads and hand-over payloads. Two ways in:

* a neighbouring match whose mask begins mid-copy, and
* the target's own mask, where a periodic secret overlaps itself and the copy
  starting inside the match runs out the far side.

Both reproduce with ten contiguous characters of a twenty-character token.

The existing defences only ever looked at the two window edges — `_clip_to_
secret_boundaries` pulls those back past a straddling copy and
`_assert_no_bisected_secret` refuses the snippet if it failed. Interior seams
had neither, though they emit context just the same.

So: any copy overlapping a mask is now masked in full, the part sticking out
getting a mask of its own rather than widening the mask it touches — the
target's mask reports its own length and may reveal a structural prefix, and
neither would still be true of a wider span. The bisection check now guards
every edge that emits context, not just the window's two, and both it and the
renderer read the same `_layout`, so the check cannot come to disagree with
what was actually emitted.

Costs nothing in dropped findings: over 40k adversarial inputs (small
alphabets, so copies and self-overlap are dense) the fail-closed rate is
identical to before — 1173 of 40000 — while straddle leaks go from widespread
to none. The pathological cases that used to leak are now masked, not refused.
`_occurrences` bounds both ends of its search; without a stop the final `find`
scans the rest of the unit to prove there is nothing more, which on a large
document is a whole pass per secret per snippet.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sohE85sRDt6t2w3936rGJ

@icebergai-review-bot icebergai-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

APPROVE

Completed bounded review across 1 immutable scope(s). No actionable findings in the supplied immutable delta.

Scope health

Convergence: healthy. Review mode: initial.
Recommended action: CONTINUE_INCREMENTAL.

  • No escalation signals.

Prior findings

Finding Status
No prior finding state

New findings

No new findings.

Fix-induced regressions

  • None evidenced.

Uncertainty

  • No material uncertainty recorded.

Validation

  • Reviewed the supplied redaction-plan, partial-copy coverage, layout, and boundary-check changes.
  • Exact-head CI passed per supplied review context.

Residual risks

  • None identified.

@icebergai-review-bot
icebergai-review-bot Bot merged commit 397e230 into main Aug 19, 2026
6 checks passed
@icebergai-review-bot
icebergai-review-bot Bot deleted the claude/codebase-review-cleanup-ovlli4 branch August 19, 2026 22:06
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.

Redaction: an unmatched copy of a secret straddling an interior mask seam leaks plaintext context

2 participants