Skip to content

redaction: overlapping findings leave part of a value in cleartext; identical spans render by sort order, not a rule #773

Closed

Description

@sectoramen

Summary

When two findings on one line partly overlap, the text, Office and legacy-OLE redactors write one and silently skip the other, so part of a reported value stays in cleartext under every strategy: Residence: 77 August Lane, Boise ID 83702 (US_STREET_ADDRESS 95) becomes Residence: 77 August *********** ID 83702, with exit 0, no warning and an audit saying failed_redactions: 0. In 1,501 tracked repo files, 21 of 22 partially overlapping pairs leak this way. Legacy .doc/.xls/.ppt never resolve overlaps, so containment leaks there too (100 ****************** Blvd). On identical spans a sort.Slice tie picks the type, so unrelated lines decide whether format_preserving keeps the last 4: Passport number: 512010001 (PASSPORT 100 + SSN 50) is ********* at 12 findings and *****0001 at 13; in a 20-line roster, 12 lines keep the last 4. The fix is redaction-only: findings and suppression hashes do not change.

Reproduction

Measured on main @ 3df48ac (v2.5.3); line references are at that commit. main has since gained dependency patch bumps and #751, #752 and #759 (changelog CI, perfguard, build tooling), none of which changes detection or redaction.

Isolated config, no suppressions. 512010001 is synthetic (area 512, group 01, serial 0001); names and addresses are repo test fixtures.

printf 'Residence: 77 August Lane, Boise ID 83702\n' | ferret-scan --stdin --enable-redaction; echo "rc=$?"
# Residence: 77 August *********** ID 83702   rc=0 (also with --fail-on-incomplete)

for k in 10 11; do
  { for i in $(seq 1 $k); do echo "Contact $i: user$i@example.com"; done; echo "Passport number: 512010001"; } |
    ferret-scan --stdin --enable-redaction | tail -n1
done
# Passport number: *********  (12 findings)
# Passport number: *****0001  (13 findings)
default strategy .txt / --stdin .docx legacy .doc
Residence line 77 August *********** **************, Boise 77 ***********, Boise
Located at 100 Martin Luther King Blvd full mask full mask 100 ****************** Blvd
Passport line, 13 findings *****0001 ********* *********
Cross-format script

mkdocx.py OUT PARA... writes a minimal WordprocessingML package, one <w:p> per argument. mkdoc OUT TEXT is a short Go program calling internal/olefixture.LegacyDoc(TEXT+"\r", nil), built in a module whose path is under github.com/awslabs/ferret-scan/v2/, with a replace to the checkout.

F=${F:-ferret-scan}
show() { case $1 in
  *.txt)  cat "$1" ;;
  *.docx) unzip -p "$1" word/document.xml | perl -pe 's#</w:p>#\n#g; s/<[^>]*>//g' ;;
  *.doc)  LC_ALL=C strings "$1" | grep -a -E 'Residence|Located|Passport' ;; esac | grep -v '^$' | tail -n1; }
i=0
for line in 'Residence: 77 August Lane, Boise ID 83702' 'Located at 100 Martin Luther King Blvd'; do
  i=$((i+1)); printf '%s\n' "$line" > x$i.txt; python3 -I mkdocx.py x$i.docx "$line"; ./mkdoc x$i.doc "$line"
done
set --; : > x3.txt     # the identical span after 11 unrelated email lines (13 findings)
for n in $(seq 1 11); do echo "Contact $n: user$n@example.com" >> x3.txt; set -- "$@" "Contact $n: user$n@example.com"; done
echo 'Passport number: 512010001' >> x3.txt; set -- "$@" 'Passport number: 512010001'
python3 -I mkdocx.py x3.docx "$@"; ./mkdoc x3.doc "$(printf '%s\r' "$@")"
for c in x1 x2 x3; do for ext in txt docx doc; do o=""
  for s in simple format_preserving; do rm -rf out
    $F --file $c.$ext --enable-redaction --redaction-strategy $s --redaction-output-dir out --quiet --format json >/dev/null 2>&1
    o="$o rc=$? $(show "$(find out -type f -name $c.$ext)") |"; done
  printf '%-7s%s\n' $c.$ext "$o"; done; done
x1.txt  rc=0 Residence: 77 August [PERSON-NAME-REDACTED] ID 83702 | rc=0 Residence: 77 August *********** ID 83702 |
x1.docx rc=0 Residence: [US_STREET_ADDRESS-REDACTED], Boise ID 83702 | rc=0 Residence: **************, Boise ID 83702 |
x1.doc  rc=0 Residence: 77 ***********, Boise ID 83702 | rc=0 Residence: 77 ***********, Boise ID 83702 |
x2.txt  rc=0 Located at [US_STREET_ADDRESS-REDACTED] | rc=0 Located at *************************** |
x2.docx rc=0 Located at [US_STREET_ADDRESS-REDACTED] | rc=0 Located at *************************** |
x2.doc  rc=0 Located at 100 ****************** Blvd | rc=0 Located at 100 ****************** Blvd |
x3.txt  rc=0 Passport number: [SSN-REDACTED] | rc=0 Passport number: *****0001 |
x3.docx rc=0 Passport number: [PASSPORT-REDACTED] | rc=0 Passport number: ********* |
x3.doc  rc=0 Passport number: ********* | rc=0 Passport number: ********* |

files_processed: 1 on every run. Under synthetic the Residence line keeps 77 August in .txt and , Boise in .docx; .doc writes the same 77 ***********, Boise as under the other two strategies ({{U45}} owns that remap). Findings (--format sarif --show-match, 1-based byte columns, end exclusive): x1 = US_STREET_ADDRESS 95 77 August Lane [12,26), PERSON_NAME 92 August Lane [15,26) (contained), PERSON_NAME 67 Lane, Boise [22,33). x2 = US_STREET_ADDRESS 65 over the whole address [12,39), PERSON_NAME 93 Martin Luther King [16,34) inside it. The accented-names fixture line Renée Müller, José García, François Lefèvre (internal/preprocessors/plaintext_sniff_test.go; PERSON_NAME 100 Renée Müller, PERSON_NAME 100 Müller, José García) leaves Renée in .txt and , José García in .docx, under both strategies. With --redaction-audit-log, x1.txt records total_redactions: 1, successful_redactions: 1, failed_redactions: 0 for 3 findings, and the names line the same for 2. .xls/.ppt go through the same legacyole loop as .doc (measured on .doc).

Identical-span cases

Roster: 20 lines Passport number: 5120100NN (NN = 01..20), 40 findings, through --stdin. Three repeated runs hash identically and --file gives the same 12, so the split is fixed per file, not random per run.

txt  format_preserving: last 4 kept 12, full mask 8
txt  simple:            [SSN-REDACTED] 12, [PASSPORT-REDACTED] 8
docx format_preserving: last 4 kept 0 of 20

Other identical pairs (synthetic: NPI 1000000004 with a computed check digit, 2125550100 in 555-01xx), each line alone, then after 11 unrelated email lines (default strategy; under simple the label flips the same way, e.g. [NPI-REDACTED] → [PHONE-REDACTED]):

Passport number: 512010001             [PASSPORT 100 + SSN 50]          *********   ->  *****0001
driver license 512010001               [DRIVERS_LICENSE 85 + SSN 50]    *********   ->  *****0001
Provider NPI: 1000000004 at the clinic [NPI 100 + PHONE 10]             **********  ->  ******0004
MRN: 2125550100                        [MRN 80 + PHONE 5]               **********  ->  ******0100
Account number: 2125550100             [US_BANK_ACCOUNT 85 + PHONE 15]  **********  ->  ******0100

SECRETS/EMAIL. A value from python3 -I -c 'import secrets,string; a=string.ascii_letters+string.digits; print("".join(secrets.choice(a) for _ in range(24)))', written as password = "<value>@example.org", gives API_KEY_OR_SECRET 75 and BUSINESS 40 on the same columns [13,49). Output shapes (X = the value's first character, verbatim), identical with 0, 5, 11, 12, 20, 40 or 60 unrelated lines before and after:

simple            password = "[EMAIL-REDACTED]"
format_preserving password = "X***********************@example.org"

secret = "4532-0000-0000-0009" (VISA 85 + API_KEY_OR_SECRET 75; computed Luhn digit) writes ****-****-****-0009. Scanning each of these two lines alone with pkg/scan.ScanText and passing its findings to pkg/scan.RedactText in reverse order writes a full mask for both lines, so the CLI result is input order, not a rule.

Threshold, through plaintext.RedactString (format_preserving; k unrelated matches, then the identical pair; P = *********, S = *****0001):

[..,PASSPORT,SSN]  n=2..62: PPPPPPPPPPPSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS
[..,SSN,PASSPORT]  n=2..62: SSSSSSSSSSSPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPP
200 shuffled input orders (PASSPORT rendering / SSN rendering): n=2 99/101, n=12 100/100, n=13 102/98, n=50 101/99
ResolveOverlaps kept (identical pair): 2 of 2

Root cause

  1. internal/redactors/overlap.go:41-102 resolves only strict containment: > at :82-83 keeps both identical spans; partial overlaps are kept by design (pinned at overlap_test.go:122).
  2. Writers locate each match in text earlier writes changed, so the second overlapping match is skipped: plaintext via the sort.Slice tie in sortMatchesByPosition (plaintext/redactor.go:479-488, skips :296-371); Office via first-write-wins partReplacements.add (office/redactor.go:1042-1051); legacy OLE (legacyole/redactor.go:162-195) has no overlap pass.
  3. Check-name input order (internal/validators/detector.go:79-86, fix(validators): make a file's match order independent of goroutine timing #191) survives only to 12 matches: sort.Slice is insertion sort up to maxInsertion = 12 (Go 1.27 sort/zsortfunc.go:62), pdqsort above.
  4. Whole-value safety nets miss partly rewritten values.
Per writer
  • plaintext (--stdin, text --file, pkg/scan, pkg/redact, svg, the rtf inner pass): the sort key is (line desc, strings.LastIndex(FullLine, Text) desc). Identical spans tie. For a partial overlap the later span is written first, and the earlier span's text can no longer be found, so its head survives.
  • Office: replacements are keyed by value, first write wins, and applied longest-first by a left-to-right strings.Replacer, so the earlier-starting value is written and the later value's tail survives. The comment at :1062 says this ordering "keeps partially-overlapping values leak-safe".
  • legacy OLE: matches are overwritten in input order. The comment at :160-161 defers to "the caller's overlap resolution", but the only non-test callers of ResolveOverlaps are plaintext/redactor.go:228 and office/redactor.go:926.
  • tagmeta (tagmeta/overwrite.go:105-237, Plan + mergeOccurrences) is already correct: it locates every occurrence in the original bytes, merges overlapping spans and masks any span built from more than one match. Its comment (:94-102) describes this exact failure.
  • internal/parallel/validator_runner.go:243-251 and PR fix(redaction): remove a reported value everywhere, and verify the written output #574 ("Also left: … crossing overlaps … a real second defect") record the partial-overlap leak. No issue was filed.
  • redactverify.SweepRemaining (internal/redactverify/redactverify.go:212), Office recordCoverageForRemainingParts (office/partcoverage.go:118) and the written-output check (docs/user-guides/README-Redaction.md:242-246) all look for whole values.
  • replacement.FormatPreserving (internal/redactors/replacement/replacement.go:138-154) keeps the last 4 digits for SSN, PHONE and card types, the first character and domain for EMAIL/GMAIL/BUSINESS, and two octets for IP, and masks everything else. So the arbitrary winner decides which bytes survive.

Width of the class

  • Defect set: same-line findings whose spans intersect without strict containment (plaintext, Office); in legacy OLE, containment too.
  • Population (1,501 files, 10,137 findings): 106 identical pairs, 85 disagreeing on the last 4; via --file (default strategy) 31 of 85 kept them, 54 masked (PASSPORT/SSN 13/39). 22 partial, 21 leaking. 147 contained, collapsed to the widest outside legacy OLE.
  • Guards: none; no golden case has a same-line identical or partial pair on text/Office paths, and creditcard_phone_overlap no longer overlaps (PHONE is on line 2).
Population method
git ls-files | grep -v -x 'config.yaml' | grep -v -E '\.(png|gz|pyc)$' | sed "s#^#$PWD/#" > /tmp/files.txt   # 1,501
cd "$(mktemp -d)"   # absolute paths from an empty dir: no project config is auto-loaded
ferret-scan --format sarif --confidence all --limit 0 --quiet --output repo.sarif $(cat /tmp/files.txt)

Results are grouped by (uri, startLine, line text) and each pair is classified by SARIF byte columns: identical, strictly contained, or partial. Touching spans count as disjoint, and 13 findings have no columns. Unaffected paths: tagmeta formats (audio, video), images (metadata stripped), PDF (refused). The same classifier over the 52 golden *.sarif.json files finds 1 identical and 2 contained pairs, all in clip.wav (tagmeta), and no partial pair; file_docx_numeric_character_reference pairs a metadata finding (AUTHOR_INFO) with a body SSN that both report "line 1" on different FullLines, so it is not a group.

  • Identical: PASSPORT/SSN 52, API_KEY_OR_SECRET/OTP_SECRET 13, NPI/PHONE 10, API_KEY_OR_SECRET/VISA 8, PHONE/US_BANK_ACCOUNT 6, ABA_ROUTING/SSN 3, DRIVERS_LICENSE/SSN 3, PHONE/SSN 3, DRIVERS_LICENSE/PHONE 2, and 6 singletons. In 85, exactly one type keeps the last 4. --file oracle: each of the 51 files redacted alone; the reported columns of the written line hold the last-4 rendering or a full mask (no pair was left in cleartext; every file produced output).
  • Partial: PERSON_NAMEPERSON_NAME 17, US_STREET_ADDRESSPERSON_NAME 2, AZURE_RESOURCE_IDGCP_RESOURCE_NAME 1, GCP_RESOURCE_NAMEGCP_RESOURCE_NAME 1, BUSINESS~INTELLECTUAL_PROPERTY 1. Oracle: each line through --stdin under simple and format_preserving; it leaks if the earlier span's head is immediately followed by the later span's replacement.
  • Contained: IBAN>PHONE 29, OTPAUTH_URI>API_KEY_OR_SECRET 25, OTPAUTH_URI>BUSINESS 16, API_KEY_OR_SECRET>AWS_SECRET_ACCESS_KEY 15, IP_ADDRESS>DATE_OF_BIRTH 14, and 19 smaller rows. In 21 the survivor's format_preserving rendering keeps bytes (IP_ADDRESS>DATE_OF_BIRTH 14, BUSINESS>DATE_OF_BIRTH 3, EMAIL>EMAIL 2, MASTERCARD>PHONE 2). In 17 of those (the two DATE_OF_BIRTH rows) it keeps a byte inside the contained span that the contained type would mask. None of the 21 has a SECRETS-check match inside.

Resolution

  1. Group in ResolveOverlaps same-LineID matches whose spans strictly intersect (not touching, other-line or unresolvable). One rule, one write from original offsets, every writer.
  2. Render: containment stays widest-wins but copies no original letter/digit inside a contained SECRETS-check match; identical and partial groups are written once over the union, copying no original letter/digit unless every covering member would. 0 of 147 contained pairs change; applying the byte rule to all containment would change the 17 date pairs (cross-validator deconfliction: 5.15% of findings are sub-spans of a wider, more-confident finding, and only redaction knows it #687's call).
  3. Representative (simple label, synthetic generator): highest confidence, then Type, then input index. Order- and size-independent; today's ≤12-match winner except SECRETS/EMAIL.
  4. Strategies: a group whose members resolve to different strategies ({{U31}}) takes the most protective (simple strictest); a member with no Match.Check ({{U35}}) resolves to simple ({{U31}}) and counts as SECRETS.
  5. Writers: plaintext in a total order by resolved offset; Office one value per group, stricter rendering wins; legacy OLE groups and masks multi-type groups like tagmeta ({{U45}} owns its strategy remap).
  6. No silent skip: an unwritable group refuses the file; the audit records one entry per group, with member types.
  7. Correct overlap.go:12-29, office/redactor.go:1053-1065, legacyole/redactor.go:160-161, detector.go:79-86; THREAT_MODEL.md:170 ("Tracked separately"): rendering is this issue; report-time arbitration stays open.

Compatibility: findings, recall, FPs and suppression hashes are unchanged. Bytes change only in groups (106 identical, 22 partial): the 31 last-4 renderings become full masks; golden redact snapshots hold no group. Safeguards: disjoint, touching, other-line, unresolvable and contained shapes stay byte-identical.

Rule table
group today proposed
strict containment widest wins widest wins; no original letter/digit inside a contained SECRETS-check match copied
identical first written wins representative's rendering, copied bytes masked unless every member copies them; synthetic falls back to the representative's simple placeholder if anything needed masking
partial / chain earlier head (plaintext) or later tail (Office) stranded union written once; one shared Type → that type's rendering, same byte rule; mixed types → * mask (format_preserving) or the representative's placeholder

"Copied" means the renderer writes the original byte back at the same offset by construction: last 4 digits, an email's first character and domain, two IP octets, a synthetic card's issuer prefix. replacement should return these ranges with the string rather than leave them to be inferred. The representative rule picks labels only; the Phase 3 same-span arbitration policy (docs/proposals/ARCHITECTURE_AND_PLAN.md:428) may later change which label wins, never which bytes are copied. SECRETS membership comes from Match.Check ({{U35}}). At runtime {{U35}} counts an unattributed match under unattributed and {{U31}} redacts it with simple plus a disclosed warning; inside a group it is treated as SECRETS (strictest).

Requirements from other drafts, asserted here with synthetic match pairs:

  • {{U31}} Resolution 8: members of one group resolving to different strategies take the most protective; two adjacent, non-overlapping values of different checks keep their own strategies (the group must not over-merge).
  • {{U18}} Resolution 6: a national-ID match on the same bytes as SSN or PHONE is fully masked under format_preserving, and the SSN/PHONE finding is still reported.
  • secrets: a password or token in URI user info (scheme://user:pass@host) is never detected, so it survives redaction #766 Resolution 6 (and its REDIS_URL acceptance criterion): a SECRETS match nested inside an EMAIL match leaves no secret byte under any strategy.

Expected under the rule:

  • Passport line: ********* at any file size in every format; [PASSPORT-REDACTED] under simple in .txt/.docx.
  • Residence line: Residence: ********************* ID 83702 / Residence: [US_STREET_ADDRESS-REDACTED] ID 83702.
  • Password line: a full mask / [API_KEY_OR_SECRET-REDACTED].
  • Card-in-secret line: ****-****-****-****.
  • MLK .doc: a full mask.

Tests

  • Unit: grouping (contained, identical both orders, partial, chain, touching, other LineID, unresolvable) × rule × strategy, replacing TestResolveOverlaps_PartialOverlapKeepsBoth; plus the {{U31}}/{{U18}}/secrets: a password or token in URI user info (scheme://user:pass@host) is never detected, so it survives redaction #766 pairs (Rule table) and a group with one unattributed member.
  • Order independence: 200 permutations at n = 2, 12, 13, 50 give identical bytes through plaintext, pkg/scan, pkg/redact.
  • Golden sink cases (all strategies, formats) per criterion; decoys: disjoint same-line, contained IP/date, identical PHONE/SSN.
  • Repair creditcard_phone_overlap; guard ≥1 same-line identical, partial and contained corpus group; inject a write failure.

Docs & CHANGELOG

  • README-Redaction.md:219 (what "redacted" guarantees): the overlap rule, and that the output check (:242) compares whole values; plus Resolution 7.
  • [Unreleased] Fixed: partial-overlap and legacy-OLE leaks; identical spans rendered by sort order. Changed: overlap-group rendering. Findings and suppression hashes unchanged.

Acceptance criteria

  • Residence line, all strategies and formats: none of 77, August, Lane, Boise written.
  • Renée Müller, José García, François Lefèvre (.txt, .docx, simple/format_preserving): neither Renée nor José García written.
  • Located at 100 Martin Luther King Blvd as .doc (and .xls/.ppt via olefixture.Build): neither 100 nor Blvd written.
  • Passport line after 1, 10, 11, 30, 60 unrelated lines (before/after/both): format_preserving ********* in every format, simple [PASSPORT-REDACTED] (.txt, .docx); roster: 20 identical lines.
  • NPI, MRN, licence, account and password = "<generated>@example.org" lines (details): a full mask under format_preserving at any padding.
  • Population: 0 of 22 partial pairs strand bytes; 0 of 106 identical pairs keep a byte another member masks; contained and non-overlapping lines byte-identical to main.
  • Findings JSON byte-identical to main (golden corpus, population).
  • Order-independence test passes; write failure refuses the file; audit has one entry per group; legacyole groups; guard, docs, CHANGELOG present.

Related

Open secrets PRs #761 and #762 move the population counts; re-state the population line if either merges before this is filed

Re-measured with the same command over the same 1,501 files and the same SARIF classifier. PR #761's head (a0ba05b) gives 10,138 findings and 146 contained, because the API_KEY_OR_SECRET>API_KEY_OR_SECRET row drops from 8 to 7; the identical and partial pairs are unchanged. PR #762's head (64e6589) gives 10,134 findings with every pair unchanged; the four golden *.sarif.json files it adds hold no overlapping pair.


Audit item U47 · tier P0 · effort M · rank 10/51. Measurements pinned to 3df48acd (v2.5.3); a {{U##}} token is a sibling item not yet filed.

Activity

  1. added this to the v2.6.0 milestone on Oct 14, 2026
  2. added
    bugSomething isn't working
    goPull requests that update go code
    P0Audit tier P0: fail-open or cleartext leak; fix first
    on Oct 14, 2026
  3. added a commit that references this issue on Oct 26, 2026
    10ef847
  4. added 2 commits that reference this issue on Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P0Audit tier P0: fail-open or cleartext leak; fix firstbugSomething isn't workinggoPull requests that update go code

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions