Repository navigation
redaction: overlapping findings leave part of a value in cleartext; identical spans render by sort order, not a rule #773
Description
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; donex1.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
internal/redactors/overlap.go:41-102resolves only strict containment:>at:82-83keeps both identical spans; partial overlaps are kept by design (pinned atoverlap_test.go:122).- Writers locate each match in text earlier writes changed, so the second overlapping match is skipped: plaintext via the
sort.Slicetie insortMatchesByPosition(plaintext/redactor.go:479-488, skips:296-371); Office via first-write-winspartReplacements.add(office/redactor.go:1042-1051); legacy OLE (legacyole/redactor.go:162-195) has no overlap pass. - 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.Sliceis insertion sort up tomaxInsertion = 12(Go 1.27sort/zsortfunc.go:62), pdqsort above. - 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:1062says this ordering "keeps partially-overlapping values leak-safe". - legacy OLE: matches are overwritten in input order. The comment at
:160-161defers to "the caller's overlap resolution", but the only non-test callers ofResolveOverlapsareplaintext/redactor.go:228andoffice/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-251and 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), OfficerecordCoverageForRemainingParts(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 forEMAIL/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/SSN13/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_overlapno longer overlaps (PHONEis 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.
--fileoracle: 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_NAME
PERSON_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--stdinundersimpleandformat_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_preservingrendering 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
- Group in
ResolveOverlapssame-LineIDmatches whose spans strictly intersect (not touching, other-line or unresolvable). One rule, one write from original offsets, every writer. - 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).
- Representative (
simplelabel,syntheticgenerator): highest confidence, thenType, then input index. Order- and size-independent; today's ≤12-match winner except SECRETS/EMAIL. - Strategies: a group whose members resolve to different strategies ({{U31}}) takes the most protective (
simplestrictest); a member with noMatch.Check({{U35}}) resolves tosimple({{U31}}) and counts as SECRETS. - 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).
- No silent skip: an unwritable group refuses the file; the audit records one entry per group, with member types.
- 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 itsREDIS_URLacceptance 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]undersimplein.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, replacingTestResolveOverlaps_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,Boisewritten. -
Renée Müller, José García, François Lefèvre(.txt,.docx,simple/format_preserving): neitherRenéenorJosé Garcíawritten. -
Located at 100 Martin Luther King Blvdas.doc(and.xls/.pptviaolefixture.Build): neither100norBlvdwritten. - 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 underformat_preservingat 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;
legacyolegroups; guard, docs, CHANGELOG present.
Related
- cross-validator deconfliction: 5.15% of findings are sub-spans of a wider, more-confident finding, and only redaction knows it #687: report-time containment; not a dependency; the 17 contained date pairs are its call.
- Prior art: fix(redact): collapse overlapping matches so a contained span can't hide the wider one #138 (
ResolveOverlaps), fix(validators): make a file's match order independent of goroutine timing #191 (input order), fix(redaction): remove a reported value everywhere, and verify the written output #574 (crossing overlaps "Also left"). - {{U31}} Resolution 8, {{U18}} Resolution 6, secrets: a password or token in URI user info (
scheme://user:pass@host) is never detected, so it survives redaction #766 Resolution 6 (requirements tested here); {{U45}} (legacy OLE strategy remap); {{U46}} (email renderer types); {{U35}} (Match.Check).
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
fix(secrets): detect a password or token in URI user info (scheme://u…
fix(redaction): write overlapping findings once, over their union (#811)
chore(changelog): place the #770 bullet under Bug Fixes after the rebase
chore(goldencorpus,changelog): rebase #765 onto main after #811
fix(secrets): scope the line pre-check veto to benign manifest lines (#…
fix(redaction): fail a cut-short redaction on every sink, keep partia…
chore(changelog): collect release notes as one fragment per PR under …