Skip to content

fix(redaction): write overlapping findings once, over their union - #811

Merged
rustic merged 3 commits into
mainfrom
fix/773-overlap-groups-render-once
Oct 26, 2026
Merged

rustic merged 3 commits into
mainfrom
fix/773-overlap-groups-render-once

Conversation

@sectoramen

Copy link
Copy Markdown
Contributor

Closes #773. Redaction-only: findings, recall, false positives and suppression hashes are unchanged; only the bytes written for findings that share bytes on a line change. No file in common with the open wave-2 streams except CHANGELOG.md (the ### 🐛 Bug Fixes heading hunk). pkg/redact, pkg/scan, internal/core/redact.go and cmd/stdin.go are untouched: all four reach the fixed writer through plaintext.RedactString.

What

When two findings on one line share bytes, ferret-scan wrote one of them and skipped the other, so part of a reported value stayed in cleartext, at exit 0, with failed_redactions: 0 in the audit log:

Residence: 77 August Lane, Boise ID 83702         US_STREET_ADDRESS "77 August Lane", PERSON_NAME "August Lane", PERSON_NAME "Lane, Boise"
.txt / --stdin / pkg     Residence: 77 August *********** ID 83702
.docx                    Residence: **************, Boise ID 83702
.doc / .xls / .ppt       Residence: 77 ***********, Boise ID 83702

Legacy .doc/.xls/.ppt had no overlap pass at all, so containment leaked there too (Located at 100 ****************** Blvd). And for the same bytes reported twice (PASSPORT and SSN on 512010001), which type was written depended on sort.Slice's tie order, which changes at 13 elements: the passport's last four digits survived (*****0001) or not depending on how many unrelated findings the file held.

Now every writer makes one write per overlap group, over the union of its members' bytes:

group written as
one finding contains the others the widest finding's replacement, as before, except no original letter/digit inside a contained SECRETS finding is copied
the same bytes reported twice the highest-confidence finding's label; a character format_preserving would copy (last four digits, an email's first character) is kept only if every finding on it would keep it
findings that cross the union once: a full * mask (format_preserving) or that type's rendering when all members share one type; the highest-confidence placeholder (simple)

synthetic uses the representative's generator and falls back to its placeholder when the fake would carry a byte a member hides. The label is chosen by confidence, then type name, then input index: never by input order or file size. A group that cannot be located refuses the file instead of writing part of it, and the audit log records one entry per group with member_types. Touching values are not a group.

Verification

All values synthetic (512010001 area 512; the repo's fixture names and addresses; computed check digits). Main = the shared c403e27 binary, branch = this head, isolated empty config home, no suppressions.

Cross-format table (the issue's script, plus .xls/.ppt built with internal/olefixture.MustBuild)

simple | format_preserving | synthetic, last line of the redacted copy:

input main branch
x1 Residence .txt 77 August [PERSON-NAME-REDACTED] · 77 August *********** · 77 August Gussie Shih [US_STREET_ADDRESS-REDACTED] · ********************* · [US_STREET_ADDRESS-REDACTED]
x1 .docx [US_STREET_ADDRESS-REDACTED], Boise · **************, Boise · FZauqAR1XZ1MMT, Boise [US_STREET_ADDRESS-REDACTED] · ********************* · [US_STREET_ADDRESS-REDACTED]
x1 .doc / .xls / .ppt 77 ***********, Boise (all three strategies) ********************* (all three)
x2 MLK .txt / .docx [US_STREET_ADDRESS-REDACTED] · 27 * · random identical to main
x2 .doc / .xls / .ppt 100 ****************** Blvd (all) 27 * (all)
x3 Passport, 13 findings .txt [SSN-REDACTED] · *****0001 · 999577937 (SSN generator) [PASSPORT-REDACTED] · ********* · passport generator
x3 .docx [PASSPORT-REDACTED] · ********* identical to main
x3 .doc ********* identical to main

rc 0 and one output file on every run, both sides.

Identical-span cases (--stdin)

main branch
Passport line, k email lines before/after/both, k ∈ {0,1,10,11,30,60} (FP / simple) *****0001 / [SSN-REDACTED] at 11-before, 30-before, 30-both, 60-*; ********* / [PASSPORT-REDACTED] elsewhere ********* / [PASSPORT-REDACTED] in all 18
Roster, 20 lines Passport number: 5120100NN (40 findings) FP: last 4 kept 12, full mask 8; simple: [SSN-REDACTED] 12 / [PASSPORT-REDACTED] 8; --file 12; .docx 0 of 20 FP: 0 kept, 20 masked; simple 0 / 20; --file 0; .docx 0 of 20
driver license 512010001, Provider NPI: 1000000004 at the clinic, MRN: 2125550100, Account number: 2125550100, alone → after 11 email lines (FP) full mask → *****0001, ******0004, ******0100, ******0100; simple flips to [SSN-REDACTED]/[PHONE-REDACTED] full mask → full mask, label unchanged, all four
password = "<24 random alnum>@example.org" (generated at run time), k = 0..20 lines each side X***…@example.org / [EMAIL-REDACTED] (and at k = 40, 60: full mask / [API_KEY_OR_SECRET-REDACTED]) full mask / [API_KEY_OR_SECRET-REDACTED] at every k
secret = "4532-0000-0000-0009" alone (VISA + API_KEY_OR_SECRET) ****-****-****-0009 ****-****-****-****
Accented names .txt (simple / FP) Renée [PERSON-NAME-REDACTED], François Lefèvre / Renée *****… [PERSON-NAME-REDACTED], François Lefèvre / 29 *
Accented names .docx [PERSON-NAME-REDACTED], José García, … / **************, José García, … as .txt
--redaction-audit-log, x1 / names 1 entry, data_types [PERSON_NAME], failed 0 1 entry, data_type US_STREET_ADDRESS, member_types [PERSON_NAME PERSON_NAME US_STREET_ADDRESS], data_types lists both types

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

main    [..,PASSPORT,SSN] n=2..62: PPPPPPPPPPPSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS
main    [..,SSN,PASSPORT] n=2..62: SSSSSSSSSSSPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPP
main    200 shuffles (P/S): n=2 101/99, n=12 96/104, n=13 104/96, n=50 103/97
branch  both orders n=2..62: all P; 200 shuffles: 200/0 at n=2, 12, 13, 50

Population (the issue's method, re-run at c403e27)

--format sarif --confidence all --limit 0 over the 1,717 tracked files (git ls-tree, minus config.yaml, *.png/gz/pyc), grouped by (uri, line, line text), pairs classified by SARIF byte columns. 11,451 findings (13 without columns). 109 identical, 22 partial, 200 contained pairs (type breakdown in the issue's format is reproduced exactly for partial; identical/contained grew with #761/#762/#766, see below).

main branch
identical pairs whose types disagree on the last 4 (87): --file FP keeps the last 4 32 0 (87 full mask)
partial pairs leaving a member's bytes in cleartext, FP / simple 21 / 20 of 22 0 / 0
SARIF output (no --show-match) — byte-identical
JSON results with redaction, both strategies — equal (only duration_seconds and the wall-clock reconstruction_timestamp differ, as between two main runs)
refusal warnings (files_not_redacted 5 simple / 3 FP, values_not_redacted 24) — identical

Every line the branch writes differently from main, accounted for by a script that diffs the two redacted trees line by line (simple: 78 lines, FP: 84):

reason simple FP
an identical or partial group on that line 55 49
contained group with a SECRETS member: only bytes inside the secret change, from the original to * (all 22 are EMAIL/BUSINESS ⊃ API_KEY_OR_SECRET from #766: the email mask's first character) 0 22
no group on the line: main's losing member of a pair elsewhere in the file was relocated (see below) 23 13
unexplained 0 0

The last row is a leak mechanism the issue did not measure. On main the skipped member of an identical pair fell through findMatchPosition's whole-document fallback and wrote its value over the first remaining copy anywhere in the document: internal/scorecorpus/cases_multicheck.go:391 came out UPC 0[US_BANK_ACCOUNT-REDACTED]5 (a UPC with nothing reported on it), and in internal/validators/otp/validator_test.go line 398's API_KEY_OR_SECRET/OTP_SECRET loser wrote [OTP_SECRET-REDACTED] inside line 32's otpauth:// URI, so line 32's own OTPAUTH_URI finding could not be located and was never written. Each such write also landed at an earlier offset than the descending-order invariant allows, so every later finding in the file was re-located by search (--debug: text_mismatch_warning + position_corrected, mapping confidence 0.95 → 0.665 for the rest of the file). On the branch the pair is one write at its own line, so those lines get their own findings' renderings and lines with no finding are left alone — including internal/tabular/tabular_test.go:30, whose unreported \t987654321 copies main had masked only as that side effect (the completeness sweep declines embedded occurrences by design).

Byte identity of non-group shapes

TestNonGroupShapesAreByteIdentical pins disjoint same-line, touching, other-line, unresolvable (no FullLine) and contained (card ⊃ phone, IP ⊃ date) shapes to the bytes main writes, under simple and FP: passes on main and on the branch (a safeguard, by design). The population table above is the same check at scale.

Tests, and how each fails on main

Run against main's production files (git show c403e27:<path> for the 8 production files, this branch's tests; a two-constant shim for the metadata keys so tests asserting on them compile):

test pins on main
plaintext TestOverlapGroupsLeaveNoMemberByte residence, names, passport, password, card-in-secret × 3 strategies 8 subtests fail: got "Residence: 77 August [PERSON-NAME-REDACTED] ID 83702", "Renée" survived, "4532" survived: "secret = \"4532-3687-6365-1651\""
plaintext TestOverlapGroupRenderingIsOrderIndependent 200 shuffles at n = 2, 12, 13, 50, both strategies n=2 simple: passport line not rendered by the rule
plaintext TestUnwritableOverlapGroupRefuses a group whose union is not in the text errors, naming types not values an unwritable group must refuse, wrote "Residence: [US_STREET_ADDRESS-REDACTED],\nBoise ID 83702"
plaintext TestOverlapGroupRecordsOneMappingWithMemberTypes one mapping, group label, member types DataType:PERSON_NAME, no members
plaintext_test TestScanRedactTextRendersIdenticalSpansOrderIndependently pkg/scan.ScanText → 200 shuffled RedactText k=0 (2 findings): got "Passport number: *****0001"
plaintext_test TestRedactEngineRendersIdenticalSpansAtAnySize pkg/redact.Engine, k = 0..30 format_preserving k=30: got "*****0001", simple k=30: got "[SSN-REDACTED]"
office TestOfficeOverlapGroupIsOneValue / …IdenticalSpanRendersByTheRule / …StricterRenderingWinsForAGroupedValue / …UnwritableGroupRefuses union as one value; rule in both orders; a lone same-digit SSN cannot hand *****0001 to the group; refusal "Boise" survived; order [SSN PASSPORT]: got "*****0001"; got "SSN *****0001\nPassport number: *****0001"; must refuse the document
legacyole TestLegacyContainedGroupIsWrittenWhole / …PartialGroupIsMaskedWhole / …LongestValueIsWrittenFirst / …IdenticalGroupKeepsNoDigitAMemberHides / …UnwritableGroupRefuses .doc/.xls/.ppt got "Located at 100 ****************** Blvd\r"; "Residence: 77 ***********, Boise ID 83702\r"; *****0001; no refusal
goldencorpus TestOverlapGroupSinkEveryFormat real core.RedactFile, .txt/.docx/.doc/.xls/.ppt × 3 strategies, 51 subtests, asserting no member fragment survives 31 of 51 fail ("77 August" survived in the redacted .txt, ", Boise" … .docx, "at 100" … .doc/.xls/.ppt, "0001" … .txt (format_preserving))
goldencorpus TestGoldenRedact snapshots 3 mismatches (below)
goldencorpus TestCorpusHoldsEveryOverlapShape (derived guard: groups computed from the real scan of every Cases entry) ≥ 1 same-line identical, partial, contained group, all three in the dedicated case with main's corpus: no corpus case holds a same-line identical overlap group any more, same for partial (only secrets_uri_userinfo_email_overlap holds a contained one)
redactors TestResolveOverlaps_* (grouping: contained both orders, three nested, identical both orders, representative tie-breaks, partial over the union — replaces …PartialOverlapKeepsBoth —, chain, touching, disjoint, other line, same line number different source, unresolvable, same value two checks twice on a line, input order), TestIsSecretsMember, TestAuditRecordsOneEntryPerGroupWithMemberTypes; replacement TestRender*, TestRenderGroup* (rule table × 2 orders × 3 strategies incl. {{U18}}, #766, unattributed member, synthetic fallback, touching values not merged) new API / new field: do not compile against main

Golden corpus

UPDATE_GOLDEN=1 changes:

file class
secrets_uri_userinfo_email_overlap.redact_format_preserving.txt byte change in a redaction: redis://default:B***************@cache.orders-svc.io → ****************@… on both lines (contained group, SECRETS member inside: the case was marked "PENDING #773, expected to change"). Findings section unchanged; simple snapshot unchanged
overlap_groups_render_once.{txt,csv,json.json,yaml,junit.xml,sarif.json,gitlab-sast.json,redact_simple.txt,redact_format_preserving.txt} new case: residence (partial + contained), accented names (partial, one type), passport (identical), MLK (contained), SECRETS/EMAIL (identical); decoys IP ⊃ date (203.12.*.*, unchanged rule), PHONE ⊃ SSN (+* ***-**-4100), disjoint card + phone
metadata-key-census.txt corpus composition: the histogram gains exactly the new case's 17 findings and IP_ADDRESS enters the census through it (17 keys, band 2). No metadata key is added by code

make score: PASS, identical to main modulo timings (recall_all=1.0000 recall_hm=0.9906 prec_hm=0.9589 whole_leak=0, 158 cases, 212 labels).

Suppression round-trip: main --generate-suppressions over the 1,717 files → 11,180 rules (enabled for the test). Main with them: 0 findings, 11,451 suppressed; branch: 0 findings, 11,451 suppressed.

Gates

  • gofmt -l . empty; go vet ./... clean (empty output).
  • go test -race -count=1 on every touched package (internal/redactors/..., internal/validators, internal/goldencorpus): ok. Whole module -race (./cmd/... ./internal/... ./pkg/...): 89 ok, 0 FAIL, no data race.
  • make pr-checklist: changelog, gofmt, vet, build, golden regen (0 files), cross-platform vet RAN; full suite and the 3× flake repeat FAIL only on TestGrowthRatioMissesASparseQuadratic; the run was then terminated by the host during step 13 (the whole-module race, re-run by hand above: green). go test -short -count=1 ./cmd/... ./internal/... ./pkg/... re-run with output kept: 88 ok, 1 FAIL = TestGrowthRatioMissesASparseQuadratic.
  • Known host flake, attributed: go test -count=10 -run TestGrowthRatioMissesASparseQuadratic ./internal/goldencorpus/, interleaved: branch 2/10 then 1/10 failed, main (c403e27) 1/10 then 2/10 — 3/20 each.

Deviations and open questions

  1. Resolution 4 ({{U31}}/{{U35}}) is partial, as scoped. Groups whose members come from different checks render by the per-byte rule under the run's single strategy; there is no per-check strategy to arbitrate, so RenderGroup takes one strategy and "an unattributed member resolves to simple" is not implemented. SECRETS membership reads Match.Validator == "secrets", the attribution a Match carries today (Match.Check does not exist); an empty Validator counts as SECRETS.
  2. Legacy OLE "masks multi-type groups like tagmeta" is implemented as the same group rule under legacy's existing remap (simple and format_preserving both render format_preserving, length-preserving; {{U45}} owns that remap): crossing groups of different types are masked whole, identical groups follow the byte rule, and a contained group keeps the widest member's same-length rendering as in .txt/.docx rather than a tagmeta-style full mask, so contained shapes that did not leak in .doc keep their bytes. Value writes are applied longest first (they were in input order): legacy replaces a value in every stream, so a short value reported on another line used to mask the same name inside a longer value before the longer one was found.
  3. Positions are resolved per type for grouping. detector.ResolveLineSpans' cursor is per text, so when two checks report the same value twice on a line the second check's matches both resolve to the first occurrence and the second occurrence of the first check stands alone. Grouping resolves each type separately; the reported columns (AssignLineColumns) are untouched.
  4. Office "stricter rendering wins" applies where a group touches a value (fewest kept letters/digits, then group over single, then confidence, type, text). A value no group touches keeps first-write-wins, byte-identical; so a value reported on two lines as two different types, with no group, still renders by input order in Office. Not in this issue's defect set; left as is.
  5. No silent skip is scoped to groups. A single match that cannot be located is skipped as before.
  6. Office's coverage pass (recordCoverageForRemainingParts) now receives the write units, not the old survivor list: an unreported copy of a crossing member's value in another part is no longer rewritten by that pass, and the residue guard refuses the document instead. Recording members there would let the cross-run pass rewrite a member before its group's union.
  7. Files outside the listed ownership: internal/redactors/index.go (ContentRedaction.MemberTypes, member_types,omitempty; data_types gains member types) and internal/redactors/manager.go (copies the metadata into the audit entry), additive, for Resolution 6's "audit records … member types". partcoverage.go, internal/redactverify/* and tagmeta/* are unchanged.
  8. Golden repairs. creditcard_phone_overlap cannot be made to overlap without changing the PHONE validator (it no longer fires inside a card); its description now says so and points to the new case and the guard. The "identical PHONE/SSN" decoy could not be produced by today's validators in a probe; the case uses PHONE ⊃ SSN containment (both keep the same last four) instead, and identical same-type unanimity is pinned in TestRenderGroupRuleTable. The cross-format sink cases are a test (TestOverlapGroupSinkEveryFormat) rather than FileCases snapshots, because the FileCases sink checks only whole values. The order test through pkg/redact varies the file size (the engine scans its own input); plaintext and pkg/scan.RedactText permute.
  9. The issue's numbers have moved since 3df48acd, as it anticipated: 1,717 files / 11,451 findings; 109 identical (87 disagreeing on the last 4, of which main kept 32: the extra one is CHANGELOG.md:978, MRN/PHONE, past SARIF's snippet cap); 200 contained. "0 of 147 contained pairs change" and "golden redact snapshots hold no group" no longer hold: secrets: a password or token in URI user info (scheme://user:pass@host) is never detected, so it survives redaction #766 added 22 EMAIL/BUSINESS ⊃ API_KEY_OR_SECRET pairs, which the SECRETS byte rule now changes as Resolution 2 prescribes, and the golden case it left PENDING. Two of the issue's details do not reproduce on c403e27: the SECRETS/EMAIL line is not identical at every padding on main (at 40 and 60 lines each side main already writes the full mask), and the card-in-secret pair only forms when the line is alone (with ≥ 5 unrelated lines around it SECRETS does not report it, so ****-****-****-0009 is the correct single-finding rendering on both sides).
  10. Open: report-time arbitration of same-span findings (which one is reported) stays open — THREAT_MODEL.md's cross-validator residual now says rendering is settled and reporting is not.

Checklist

  • make test and make build pass — build, vet and the whole-module race run pass; the non-race suite and -short fail only on the known host flake TestGrowthRatioMissesASparseQuadratic, attributed above (3/20 on this branch, 3/20 on main).
  • CHANGELOG: an entry under ## [Unreleased] → ### 🐛 Bug Fixes
  • Golden corpus regenerated and the diff explained
  • Closes #773

Closes #773

Every writer applied findings one at a time, locating each by its text in
content an earlier write had already changed. When two findings on a line
shared bytes, the second could not be found and was skipped, leaving part
of it in cleartext at exit 0 with failed_redactions 0 in the audit:

  Residence: 77 August Lane, Boise ID 83702
  .txt/--stdin/pkg   Residence: 77 August *********** ID 83702
  .docx              Residence: **************, Boise ID 83702
  .doc/.xls/.ppt     Residence: 77 ***********, Boise ID 83702

Legacy OLE had no overlap pass at all, so containment leaked there too
("Located at 100 ****************** Blvd"). For the same bytes reported
twice (PASSPORT and SSN on one 9-digit number) the plaintext writer's sort
tied, and sort.Slice breaks ties by algorithm, which changes at 13
elements: the last four digits survived or not depending on how many
unrelated findings the file held.

redactors.ResolveOverlaps now returns overlap groups: same-line matches
whose spans strictly intersect, transitively, with positions resolved per
type so one value reported by two checks groups once per occurrence.
replacement.RenderGroup renders a group by one rule, using the copied-byte
ranges replacement.Render now returns with each rendering: contained ->
the widest finding, copying no letter or digit inside a contained SECRETS
finding; identical -> the highest-confidence label, keeping a byte only if
every member would; partial -> the union masked (format_preserving) or the
representative's placeholder. The plaintext writer orders writes by
resolved offset, Office records one value per group with the stricter
rendering winning where a group touches a value, and legacy OLE groups,
writes longest first and masks mixed-type crossing groups. A group that
cannot be located refuses the file, and the audit records one entry per
group with member_types.

Measured over the repo's 1,717 tracked files (11,451 findings): 32 of 87
same-span pairs whose types disagree on the last four kept them, now 0;
21 of 22 crossing pairs stranded bytes, now 0. SARIF findings are byte-
identical, make score is unchanged, main-generated suppression rules
still suppress all 11,451 findings.

Closes #773
@sectoramen sectoramen added this to the v2.6.0 milestone Oct 24, 2026
@sectoramen sectoramen added bug Something isn't working go Pull requests that update go code P0 Audit tier P0: fail-open or cleartext leak; fix first labels Oct 24, 2026
@sectoramen
sectoramen requested a review from rustic October 24, 2026 15:51
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Ferret Scan Results

Gate mode: report — findings are reported here and do not fail this check; a scan that fails or is incomplete still does.

⚠️ Sensitive data detected in 13 scanned file(s):

  • High confidence: 210 findings
  • Medium confidence: 157 findings

Findings Summary:

File Type Confidence Line
corpus.go BUSINESS HIGH 510
corpus.go BUSINESS HIGH 823
corpus.go BUSINESS HIGH 824
corpus.go BUSINESS HIGH 825
corpus.go BUSINESS HIGH 826
corpus.go BUSINESS HIGH 862
corpus.go DEA_NUMBER HIGH 716
corpus.go IP_ADDRESS HIGH 533
corpus.go PASSPORT HIGH 530
corpus.go PASSPORT HIGH 815

... and 357 more findings

⚠️ Findings list truncated: ferret-scan caps results at --limit (200 by default), so the ferret-scan-results artifact lists fewer findings than the counts above, which come from its uncapped stats.

Next Steps:

  1. Review the findings above
  2. Remove or redact any actual sensitive data
  3. Add suppressions for false positives
  4. Re-run the scan after making changes

🤖 This comment was automatically generated by Ferret Scan

…s random digits

TestRenderGroupRuleTable asserted that the synthetic rendering of every
group contains neither "0009" (the card row's last four) nor "B7vg" (the
secret row's prefix). A synthetic SSN or passport is nine freshly drawn
digits, so "0009" appears in one of them about one run in seven: 14 of
100 runs on Linux, and the Windows leg of #811's second CI run, where the
{{U18}} row drew "000910363".

The assertion now reads what the rule actually governs: the rendering's
Copies, the value bytes it wrote back by construction. No row of the
table copies a value byte under synthetic (the card's issuer prefix, the
one candidate, is hidden by its SECRETS co-member), so the check is that
the copied set is empty. 200 consecutive runs pass.
@sectoramen

Copy link
Copy Markdown
Contributor Author

Second commit, 65fc0fb: a flaky assertion in the new test, not a product change.

The first CI run's Windows red was the perfguard clock-tick flake (TestTheCalibratedFixtureActuallyClearsTheGateHere, 6.7 ticks measured against 8 required; nothing this PR touches). Its re-run exposed a real problem in this PR's own test: TestRenderGroupRuleTable/{{U18}}: a national ID on the same bytes as an SSN is fully masked failed with synthetic kept a byte a member would hide: "000910363".

The assertion checked that the synthetic rendering's text contains neither "0009" nor "B7vg". A synthetic SSN or passport is nine freshly drawn digits, so "0009" appears in one of them about one run in seven; measured locally, 14 of 100 runs fail, across three rows ({{U18}} 8, "SSN representative" 5, "PASSPORT+SSN" 1). Linux and macOS passed by chance.

The check now reads what the rule governs, the rendering's Copies (the value bytes written back by construction), and requires that set to be empty for every row under synthetic, which is what the rule table promises: the one candidate copy, the card's issuer prefix, is hidden by its SECRETS co-member. 200 consecutive runs pass; go test -race ./internal/redactors/..., gofmt and go vet clean. No production code changed; golden corpus untouched.

@github-actions

Copy link
Copy Markdown
Contributor

🔍 Ferret Scan Results

Gate mode: report — findings are reported here and do not fail this check; a scan that fails or is incomplete still does.

⚠️ Sensitive data detected in 13 scanned file(s):

  • High confidence: 210 findings
  • Medium confidence: 157 findings

Findings Summary:

File Type Confidence Line
corpus.go BUSINESS HIGH 510
corpus.go BUSINESS HIGH 823
corpus.go BUSINESS HIGH 824
corpus.go BUSINESS HIGH 825
corpus.go BUSINESS HIGH 826
corpus.go BUSINESS HIGH 862
corpus.go DEA_NUMBER HIGH 716
corpus.go IP_ADDRESS HIGH 533
corpus.go PASSPORT HIGH 530
corpus.go PASSPORT HIGH 815

... and 357 more findings

⚠️ Findings list truncated: ferret-scan caps results at --limit (200 by default), so the ferret-scan-results artifact lists fewer findings than the counts above, which come from its uncapped stats.

Next Steps:

  1. Review the findings above
  2. Remove or redact any actual sensitive data
  3. Add suppressions for false positives
  4. Re-run the scan after making changes

🤖 This comment was automatically generated by Ferret Scan

Catches up with #799, #818, #819 and #820. CHANGELOG.md merged without
edits through the merge=union attribute (#781). Each side added its entry
under a different [Unreleased] heading (this branch Bug Fixes, main
Security), so every entry stays under the heading it was written for.
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Ferret Scan Results

Gate mode: report — findings are reported here and do not fail this check; a scan that fails or is incomplete still does.

⚠️ Sensitive data detected in 13 scanned file(s):

  • High confidence: 210 findings
  • Medium confidence: 157 findings

Findings Summary:

File Type Confidence Line
corpus.go BUSINESS HIGH 510
corpus.go BUSINESS HIGH 823
corpus.go BUSINESS HIGH 824
corpus.go BUSINESS HIGH 825
corpus.go BUSINESS HIGH 826
corpus.go BUSINESS HIGH 862
corpus.go DEA_NUMBER HIGH 716
corpus.go IP_ADDRESS HIGH 533
corpus.go PASSPORT HIGH 530
corpus.go PASSPORT HIGH 815

... and 357 more findings

⚠️ Findings list truncated: ferret-scan caps results at --limit (200 by default), so the ferret-scan-results artifact lists fewer findings than the counts above, which come from its uncapped stats.

Next Steps:

  1. Review the findings above
  2. Remove or redact any actual sensitive data
  3. Add suppressions for false positives
  4. Re-run the scan after making changes

🤖 This comment was automatically generated by Ferret Scan

@rustic
rustic merged commit 10ef847 into main Oct 26, 2026
19 checks passed
@rustic
rustic deleted the fix/773-overlap-groups-render-once branch October 26, 2026 16:28
sectoramen pushed a commit that referenced this pull request Oct 26, 2026
Rebasing onto main after #811 and #799 auto-merged the cut-short bullet
into the Security section, below the Go 1.27.3 entry. It is a bug fix;
move it under Bug Fixes after the #773 entry. Also restores the blank
line before the Security header.
sectoramen pushed a commit that referenced this pull request Oct 26, 2026
The rebase onto main (now carrying #811 and #799) auto-merged the #765
bullet into the Security section below the Go 1.27.3 entry; it is a bug
fix, so it moves under Bug Fixes, as its first entry: #813 appends its own
bullet after the #773 entry, and two PRs inserting at one seam would
conflict again at the second merge; distinct anchors merge in either order.

corpus.go: both #811 (overlap_groups_render_once) and #765 (the two
line-veto cases) appended to the case table; keep all three in that
order. The metadata key census is regenerated on top of main: the only
movement is +14 findings at 13 keys, which is the secrets_line_veto_triggers
case; no other bucket and no existing snapshot changed, and the two new
snapshots are byte-identical to the ones committed before the rebase.
rustic pushed a commit that referenced this pull request Oct 27, 2026
…812)

* fix(secrets): scope the line pre-check veto to benign manifest lines

containsSecretIndicators, the pre-check in front of the SECRETS keyword
pass, ran three whole-line substring checks before its provider-literal
admits: a version or boolean after the line's first colon, an
"integrity"/"checksum" key beside the bytes sha or md5, and a
"url"/"resolved"/"homepage" key beside a scheme and a registry or forge
host. A hit dropped the whole line, so one "url" member hid an AKIA key
ID, a ghp_/glpat-/xoxb- token, a JWT or a keyword-assigned password
anywhere else on it: no finding, no redacted copy, --pre-commit-mode
exit 0, and the --stdin redaction gateway passed the token through.

The provider-literal admits (JWT, every provider prefix, PEM markers) now
run first, and the three checks are replaced by one veto,
benignManifestLine, that rejects a line only when (a) it is exactly one
JSON member whose value is a version under 20 bytes, a boolean, an SRI
digest or a URL with no user info, query or '=', (b) no keyword stem
from the lists the keyword patterns are compiled from appears anywhere
on it, and (c) one of the old checks fires as well, so the veto is a
subset of what was skipped before. Checking only the key, as the issue's
prototype does, is unsound: in "https://api.session:8443/..." the
unquoted pattern reads `session:` as an assignment. findKeywordSecrets
is split into the gate and keywordCandidates so that the gate's contract
(a rejected line has no candidate) is tested.

Measured against v2.5.5 on identical inputs: the issue's nine triggers
report what their controls report (30 of 30 draws); matrix 1 goes from
99 to 0 of 143 trigger cells reporting nothing (controls 78/78 on both);
every matrix 2 cell, as low as 0/40 before, is 40/40; the redacted
copies, pre-commit exits and stdin gateway are fixed. No existing
finding changes type, text, line or confidence on the matrices, the
reproduction, 39 public npm lockfiles or 3,691 public files that hit an
old check, and main's suppression rules still suppress all of them. The
veto still skips 99.1% of the lockfile lines the old checks skipped;
SECRETS CPU on those lockfiles is 3-6% above v2.5.5 (with no veto at
all, 2.7x).

Closes #765

* test(goldencorpus): state the #765 trigger case's password length right

The fifth line of secrets_line_veto_triggers carries a 41-byte password,
not the 40-byte one its description named. The value stays 41 bytes: a
40-byte value beside an AKIA key ID is the shape secret scanners read
as an AWS key pair. Description only; no snapshot changes.

Refs #765

* docs(changelog): count all 31 seeded draws in the #765 entry

The reproduction ran on seeds 1-31; the entry quoted the 30 draws of
seeds 2-31. On all 31 every trigger reports what its control reports,
and main lost the 40-character password on 10 of them.

Refs #765

* chore(goldencorpus,changelog): rebase #765 onto main after #811

The rebase onto main (now carrying #811 and #799) auto-merged the #765
bullet into the Security section below the Go 1.27.3 entry; it is a bug
fix, so it moves under Bug Fixes, as its first entry: #813 appends its own
bullet after the #773 entry, and two PRs inserting at one seam would
conflict again at the second merge; distinct anchors merge in either order.

corpus.go: both #811 (overlap_groups_render_once) and #765 (the two
line-veto cases) appended to the case table; keep all three in that
order. The metadata key census is regenerated on top of main: the only
movement is +14 findings at 13 keys, which is the secrets_line_veto_triggers
case; no other bucket and no existing snapshot changed, and the two new
snapshots are byte-identical to the ones committed before the rebase.

---------

Co-authored-by: union <union@example.invalid>
rustic added a commit that referenced this pull request Oct 27, 2026
…ls (#813)

* fix(redaction): fail a cut-short redaction on every sink, keep partials

When coverage was cut short -- a caller's ctx done or firing, a validator
budget, the CLOUD_RESOURCES size cap, an unparseable file -- every redaction
path but --file returned partial or unredacted output as success. Measured on
v2.5.5 (c403e27): --stdin --enable-redaction exited 0 under
--fail-on-incomplete (1ns budget, a 5.8 MB log over the cap, SSN=300ms on
20,000 records) and printed 0 B in pre-commit mode; Engine.Redact returned
err == nil and the input unredacted for a pre-cancelled ctx and for 1, 100 and
200 ms deadlines (0 of 6,000 findings); RedactFile copied an unparseable .pdf
byte-identical to the output path, and the over-cap log with 5,518/5,518 ARN
lines in clear; ScanText/ScanFile ignored their ctx (30,000/30,000 findings
under a pre-cancelled one).

Sinks (the --file rule: output never beyond --file's, disclosure always,
exit 3 under the flag / an error wrapping ErrIncomplete):
- pkg/redact: a done ctx at entry or any validator error returns the partial
  Result (Incomplete, IncompleteReason, also on AuditRecord) and an error
  wrapping the new redact.ErrIncomplete and the cause.
- pkg/scan.RedactFile and the new RedactFileContext: a file in any of the three
  --fail-on-incomplete channels gets no passthrough copy, and an error wrapping
  the new scan.ErrIncomplete with RedactFileResult.Coverage (a pointer: the
  struct stays comparable). core.RedactFile does the same with
  core.ErrRedactionIncomplete; ScanFile and RedactFile share coverageOf.
- --stdin: both exits go through resolveIncompleteExitCode; pre-commit mode
  prints a one-line notice.
- examples/lambda-redact maps ErrIncomplete to "redaction incomplete"; the
  README snippets check err.

Caller ctx: core.ScanContentCtx/ScanFileCtx/RedactFileCtx and
ProcessFilesWithProgressCtx root the job contexts at the caller's (the pool's
own context stays the pool's, or a cancellation would drop jobs the collector
waits for); ScanText/ScanFile return (nil, ctx.Err()) at entry and the partial
result with an error wrapping ctx.Err() mid-scan; /scan passes the request's.

Partials: keepMatches uses execguard.IsCoverageCutShort (it tested
ErrMatchBudgetExceeded alone); a kept partial is scored and annotated before
it is sent; both fan-outs keep collecting for a bounded grace period after
Done (100 ms RunValidators, 50 ms the nested document fan-out); the worker
pool never retries a guard outcome; ScanContent uses the predicate (its chain
omitted ErrValidatorPanicked and named the size cap twice) and fills
NotExamined. A go/ast guard derives the family from IsCoverageCutShort and
fails on any errors.Is hand copy outside internal/execguard; on main it finds
5 calls in 2 sites.

records.txt, SSN,EMAIL, SSN=1s: SSN findings 0 -> 8,704 (each equal to its
unbudgeted twin, metadata included), CPU 7.36-7.44 s -> 3.22-3.29 s,
email_validator passes 3 -> 1. Complete runs are byte-identical: golden corpus
0 files changed, make score identical, rules generated by main still suppress
every finding, kept partials included.

Closes #770

* chore(changelog): place the #770 bullet under Bug Fixes after the rebase

Rebasing onto main after #811 and #799 auto-merged the cut-short bullet
into the Security section, below the Go 1.27.3 entry. It is a bug fix;
move it under Bug Fixes after the #773 entry. Also restores the blank
line before the Security header.

---------

Co-authored-by: union <union@example.invalid>
Co-authored-by: Lee Myers <ichilegend@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working go Pull requests that update go code P0 Audit tier P0: fail-open or cleartext leak; fix first

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants