Version: 0.1 (PCSR draft) Last reviewed: 2026-07-12 Owner: Andrea Di Fabio (adifabio@) — pending handover to OSS maintainer team on publication
This is a starting-point threat model drafted during PCSR prep so a reviewer can edit and sign off before publication. Update whenever a new trust boundary, route, or ingestion path is added. The current model covers two execution modes — CLI scanner and embedded web UI.
Ferret Scan is a Go-based CLI/web tool that detects sensitive content (PII, secrets, IP markers) in local files and produces redacted output or structured findings. Two execution surfaces:
- CLI —
ferret-scan --file <path>reads files from the local filesystem; no listening sockets, no inbound trust boundaries. - Web UI —
ferret-scan --web --port 8080starts an HTTP server bound to:<port>(all interfaces). Accepts multipart file uploads and JSON suppression-rule mutations.
A former optional cloud-preprocessor path (Amazon Textract / Transcribe / Comprehend) was removed entirely from the codebase; the associated trust boundaries (TB-5, TB-6) are retained below for the record, marked eliminated.
| # | Boundary | Direction | Notes |
|---|---|---|---|
| TB-1 | User filesystem → CLI process | inbound | Operator-controlled paths; filepath.Clean / filepath.Abs applied (cmd/main.go:1259, 2085, 2145). |
| TB-2 | Browser → web server | inbound | No authentication. Server binds to :<port> (all interfaces). |
| TB-3 | Web server → uploaded file content | inbound | Multipart upload, 100 MB cap (server.go:323, 425). Written to OS temp dir, removed on scan completion. |
| TB-4 | Web server → suppression YAML on disk | bidirectional | suppressions/ package mutates the YAML file. Path comes from --suppression-file flag at startup; not request-controlled. |
| TB-5 | Process → AWS APIs (formerly Textract/Transcribe/Comprehend) | outbound | Eliminated — the GenAI integration was removed entirely; the tool makes no outbound AWS API calls. Row retained for the record. |
| TB-6 | Process → external HTTP (formerly the Transcribe transcript URI fetch) | outbound | Eliminated — the Transcribe integration (including the transcript-URI http.Get) was removed entirely; the tool makes no outbound HTTP calls. Row retained for the record. |
| TB-7 | Pre-commit hook → ferret-scan binary | inbound (developer machine) | Pre-commit invokes the binary against staged files; same trust as CLI. |
| TB-8 | GitHub Actions runners → external registries (ECR Public / GHCR / PyPI) | outbound | Build, package, and publish flow. Workflows run with contents: write, packages: write, and id-token: write (PyPI trusted publishing + AWS OIDC for ECR). Third-party actions are SHA-pinned with version comments (@<sha> # vX.Y.Z) — see PR #64 and .github/workflows/README.md. Auto-version-tag job pushes tags to main on every push. |
| Threat | STRIDE | Mitigation |
|---|---|---|
Path traversal via --file ../../etc/passwd |
T (Tampering) | Mitigated in code — filepath.Clean + filepath.Abs in cmd/main.go:1259, 2085. The scanner reads files; it does not write to operator-controlled paths from CLI. |
Decompression bomb via .zip / .tar.gz preprocessor input |
D (DoS) | Compensating control — 100 MB upload cap in web mode (server.go:425). CLI mode reads files directly; per-file size cap is the operator's responsibility, but peak memory across a concurrent scan is now bounded by the opt-in --max-live-bytes admission budget (internal/execguard BytesLimiter; default: no cap). Document --max-live-bytes in the README hardening section for constrained hosts (e.g. Lambda). |
| Runaway / pathological-input validator hang (e.g. O(n²) on a single very long line) exhausts CPU on CLI or web | D (DoS) | Mitigated in code (v2) — validators poll context.Context in their hot loops, so the per-job context deadline (and cancellation) can now interrupt an in-flight validator instead of hanging unbounded. The opt-in --validator-budget NAME=DURATION sets a per-validator time cap; over-budget validators are stopped and the file is flagged incomplete (ScanResult.Incomplete / IncompleteReason). --fail-on-incomplete makes truncated coverage a non-zero (3) exit instead of a stderr warning. |
Report injection via a filename from the scanned tree — a name carrying \x1b[2K\r erases its own finding row from the operator's terminal, and one carrying \n\nNo sensitive information found. fabricates a report line |
T (Tampering) | Mitigated in code — shared.SanitizeDisplayText escapes C0/DEL/C1 and invalid UTF-8 to a visible \xNN form at every display sink: the text formatter's filename column and its verbose "Match found in" line, escapeCSVField (RFC 4180 quoting passes a control byte straight through), and the gitlab-sast Location line. Applied unconditionally, NOT gated on --no-color, because these bytes are the attacker's rather than ferret's styling. Measured before the fix: text and csv each carried ESC=4/CR=2 from three hostile filenames while json/yaml/junit/sarif carried none; replayed through a CSI interpreter the report showed 2 finding rows while its own summary said "Findings: 3". Exit codes were never affected, so machine gates were not fooled — the damage was to the report a human reads. Tracked as TM-12. |
Reading sensitive system files (/etc/shadow, ~/.aws/credentials) the operator did not intend to scan |
I (Info disclosure) | Compensating control — the tool only reads files the operator explicitly passes. --exclude and respect_gitignore reduce scope. No additional sandbox. |
| A 500 MB video admitted by the per-type size ceiling exhausts memory | D (DoS) | Mitigated by construction — router.MaxSizeForPath raises the ceiling only for .mp4 .m4v .mov .3gp .3g2, the one family read without being held: the extractor parses moov and seeks past mdat, so memory is `O(min( |
| Threat | STRIDE | Mitigation |
|---|---|---|
Unauthenticated remote access — server binds to 0.0.0.0:8080 and any LAN host can scan/suppress |
S (Spoofing), I (Info disclosure), E (Elevation) | Mitigated in code — server binds to 127.0.0.1 by default (server.go createSecureServer). Container runtimes (Docker/Podman, detected via /.dockerenv or FERRET_CONTAINER_MODE=true) auto-bind to 0.0.0.0 since the container's network namespace is the trust boundary; port publishing decides host exposure. Operators wanting LAN binding from bare metal pass --bind 0.0.0.0 and receive a stderr warning at startup. Resolution helper: security.go ResolveBindAddress. |
CSRF on /scan, /suppressions/* POST endpoints |
T, E | Mitigated in code — originCheckMiddleware (security.go) rejects POST/PUT/DELETE/PATCH whose Origin (or Referer when Origin is absent) does not match the bound host:port. Non-browser callers (curl, scripts) that send neither header are allowed — they're not subject to CSRF. Localhost ↔ 127.0.0.1 alias handled in sameOriginHostSet. |
| Reflected XSS via uploaded filename rendered back in HTML | T | Mitigated in code — sanitizeFilenameForDisplay (server.go:494) rewrites the filename before it leaves the API. Verify the front-end uses textContent not innerHTML when rendering match excerpts. |
| Missing security headers (CSP / X-Frame-Options / X-Content-Type-Options / HSTS) | T, I | Mitigated in code (defense-in-depth) — securityHeadersMiddleware (security.go) sets Content-Security-Policy, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer on every response. CSP script-src is strict ('self', no 'unsafe-inline'): all front-end code lives in the embedded /app.js asset, and interactivity binds via data-action/data-change event delegation — structural tests fail the build if inline script returns. style-src still carries 'unsafe-inline' for the template's ~301 inline style attributes; hoisting those is tracked as a follow-up. CSP also blocks: external script/style sources, cross-origin form posts (form-action 'self'), object/embed (object-src 'none'), framing the page (frame-ancestors 'none'). HSTS intentionally omitted — server is HTTP-only by design; TLS-terminating proxies are the right layer. |
| Slowloris / slow-header attacks | D | Mitigated in code — ReadHeaderTimeout: 15s, ReadTimeout: 30s, WriteTimeout: 30s, IdleTimeout: 60s (server.go:217-225). |
| Decompression bomb / oversized upload | D | Mitigated in code — ParseMultipartForm(100<<20) and io.LimitReader cap to 100 MB (server.go:323, 425). |
| Repudiation — no audit log of suppression rule changes | R | Compensating control — suppressions YAML is the source of truth and is git-trackable in dev workflows. Production deployments should rely on file-level audit (auditd, fs-events). |
| Threat | STRIDE | Mitigation |
|---|---|---|
| Malicious file content (e.g. crafted PDF/DOCX) exploiting a preprocessor parser | E | Compensating control — risk surface is local Go libraries (PDF, DOCX); no cloud preprocessors exist. Recommend dependency-pin review in PCSR follow-up. |
Temp file leakage to /tmp after process crash |
I | Compensating control — os.CreateTemp uses random filenames; defer os.Remove(...) (server.go:436). Crash mid-scan leaves the file behind, but content is bounded by what the operator already had locally. |
| Temp-disk exhaustion from a large upload named to look like a video | D (DoS) | Mitigated by construction — this path deliberately keeps the flat 100 MB io.LimitReader cap and does not consult router.MaxSizeForPath, so the CLI's 500 MB video ceiling cannot be reached by naming an upload x.mp4. The asymmetry is intentional: the CLI reads a file already on disk, whereas this path materializes the bytes into a temp file first, so a per-type allowance here would bound attacker-supplied bytes by an attacker-supplied name. Raising it needs a compensating control (an upload quota, or a streaming scan that never lands the file), not a larger number. Refusals are disclosed as coverage gaps, so a rejected video is reported and never silently truncated (#415, #410). |
| Threat | STRIDE | Mitigation |
|---|---|---|
| Malicious suppression rule hides real findings | T, R | Mitigated — gated by TB-2's localhost-only default. Any caller able to reach the suppression endpoints is on the local machine; LAN binding requires explicit --bind 0.0.0.0 plus the cross-origin check (which still applies). The pragma # pragma: allowlist secret is appended automatically (suppressions package), preventing the suppressions file from triggering ferret-scan itself. Re-evaluate if a future deployment exposes the UI remotely (add explicit auth on suppressions/* routes at that point). |
| Threat | STRIDE | Mitigation |
|---|---|---|
| Hardcoded AWS credentials in source | I | Eliminated — the GenAI integration was removed entirely; the codebase contains no AWS SDK usage and no credential handling. No AWS_ACCESS_KEY_ID literals in source. |
| Excessive IAM permissions when role is assumed | E | Eliminated — the GenAI integration was removed entirely; no IAM role is assumed and no IAM template ships with the project. |
| Threat | STRIDE | Mitigation |
|---|---|---|
SSRF via http.Get(uri) with attacker-controlled URI |
T, I | Eliminated — the Transcribe integration (and its transcript-URI fetch) was removed entirely; the tool makes no outbound HTTP requests. |
| Threat | STRIDE | Mitigation |
|---|---|---|
Pre-commit hook bypassed (--no-verify) |
E | Compensating control — server-side ferret-scan in CI (gitlab-ci.yml has ferret-sast and gosec-sast jobs). Pre-commit is best-effort developer hygiene. |
| Threat | STRIDE | Mitigation |
|---|---|---|
Compromised third-party action (e.g. attacker repoints softprops/action-gh-release@v3 upstream) reads ${{ secrets.* }}, exfiltrates GITHUB_TOKEN / OIDC issuance, pushes commits to main as github-actions[bot], publishes a tampered ferret-scan to PyPI / ECR Public / GHCR. |
T (Tampering), E (Elevation of Privilege) | Mitigated — every external uses: reference is SHA-pinned (PR #64). Dependabot tracks upstream releases against the version-comment field (PR #66). The trust path is now git's content-addressing + dependabot review surface, not GitHub's tag mutability. Tracked as TM-08. |
auto-version-tag job in .gitlab-ci.yml pushes tags to main on every push using GITLAB_RELEASE_TOKEN. No human approval gate beyond the originating commit. |
E | Compensating control — if: condition limits the job to default-branch pushes by humans (not MRs, not tag pushes). Token can still issue a release without explicit /release-style gate. Tracked as TM-09. |
Dockerfile FROM lines reference upstream images by tag (golang:<version>-alpine), not by @sha256:<digest>. Final image is FROM scratch, so the runtime impact is bounded to the builder stage's compiled binary, but the upstream image swap surface is real. |
T | Compensating control — final stage is FROM scratch with no upstream layer; only the builder-stage compilation is at risk. Defense-in-depth fix is digest pinning. Tracked as TM-10 (now mitigated — the builder is digest-pinned). |
| ID | Threat | Severity | Status |
|---|---|---|---|
| TM-01 | Web UI binds to all interfaces with no auth (TB-2 spoofing/info disclosure) | Major | Mitigated — loopback default + container auto-detect + --bind opt-in flag with stderr warning when bound broadly. See internal/web/security.go ResolveBindAddress. |
| TM-02 | Web UI POST endpoints have no CSRF protection (TB-2 tampering) | Major | Mitigated — Origin/Referer check on POST/PUT/DELETE/PATCH (originCheckMiddleware). |
| TM-03 | Web UI emits no security headers (TB-2 tampering/info disclosure) | Major | Mitigated (defense-in-depth) — securityHeadersMiddleware sets CSP/X-Frame-Options/X-Content-Type-Options/Referrer-Policy. CSP script-src is strict ('self', no 'unsafe-inline') — see TM-05 for the remaining style-src half. |
| TM-04 | Suppression mutation has no auth (TB-4) — gated by TM-01 fix | Major | Mitigated — closed by TM-01's loopback default. Re-evaluate if --bind 0.0.0.0 becomes the documented deployment posture. |
| TM-05 | Embedded HTML template has ~90 inline event handlers and ~301 inline style attributes; CSP must include 'unsafe-inline' until refactored |
Minor (defense-in-depth) | Partially closed (script half done) — inline <script> block extracted to the embedded /app.js asset and all 93 inline on* handlers replaced with data-action/data-change event delegation; CSP script-src is now 'self' (issue #147 item 1). Remaining: hoist inline style attributes into the stylesheet, then drop 'unsafe-inline' from style-src. |
| TM-06 | Cloudscape design-system stylesheet loaded from https://d0.awsstatic.com (CSP allow-listed) |
Minor (defense-in-depth) | Open — tracked. Self-host the CSS in the embed to drop the third-party style-src allowance. |
| TM-07 | SSRF defense-in-depth on Transcribe transcript URI fetch (TB-6) | Minor | Eliminated — the GenAI integration (including the Transcribe transcript-URI fetch) was removed entirely; the code no longer exists. |
| TM-08 | Floating-tag GitHub Actions chained with elevated runner permissions and auto-commit (TB-8) | Major | In progress — added 2026-05-22 by REPO scan. PR #64 SHA-pins every uses: reference (@<sha> # vX.Y.Z pattern); PR #66 adds dependabot config so the pins stay maintainable. Worst offender pre-fix was pypa/gh-action-pypi-publish@release/v1 (a branch reference); now pinned to cef22109... # release/v1 (2026-04-07). |
| TM-09 | auto-version-tag GitLab job pushes tags without manual approval (TB-8) |
Minor | In progress — added 2026-05-22 by REPO scan. PR #69 adds when: manual + allow_failure: true on the rule (a maintainer must click "play" in the GitLab UI for the tag to push) and inline-documents the expectation that GITLAB_RELEASE_TOKEN is scoped write_repository only. Final closure also requires verifying the token scope in the GitLab project-settings UI. |
| TM-10 | Dockerfile builder-stage FROM not pinned by digest (TB-8) |
Minor | Mitigated — added 2026-05-22 by REPO scan; PR #70 introduced digest pinning. The builder is pinned by digest; the current tag and digest live in the Dockerfile and .go-version rather than being restated here, because golang:<version>-alpine is a MUTABLE tag and any digest written into this document goes stale the next time upstream republishes it. That is not hypothetical: it happened within a day of the 1.26.6 bump — upstream republished the manifest list with riscv64 added, so the index digest changed while GOLANG_VERSION and the build timestamp were identical. scripts/go-version.sh check compares the pin against the registry on every make, and reports a digest-only drift as its own condition with instructions to verify the new image's Go version and platform coverage BEFORE adopting it — because auto-syncing a moved tag would accept an unreviewed upstream image and defeat the pin. Go 1.26.6 itself picks up fixes for CVE-2026-46600 and CVE-2026-39821, both rated HIGH against the 1.26.5 stdlib, plus the earlier 1.26.5 fixes for CVE-2026-39822 and CVE-2026-42505. The tag+digest are kept in sync via scripts/go-version.sh from the .go-version source of truth; the digest references a multi-arch manifest list covering both linux/amd64 and linux/arm64, matching the platforms docker-multiarch.yml builds. Final stage is FROM scratch and is unaffected. |
| TM-11 | Context-suppression evasion (TB-3): an attacker who authors the scanned content can drive a REAL, mathematically-valid secret below the detection threshold — to zero — by padding the surrounding line with negative-context keywords, so the match is never emitted and (in redaction mode) passes through in cleartext. | Major | Partially closed (checksum-bearing half) — added 2026-07-23; part (a) of the mitigation shipped 2026-07-27. Every value that passes a checksum now keeps a floor of 15 (LOW) instead of being erased: Luhn cards, mod-97 IBAN (compact and spaced), CMS-Luhn NPI, DEA check digit. Re-measured under the §4.7 padding string, post-fix: IBAN 15, NPI 20, DEA 25, VISA 15, VIN 50 — all emitted, so all redacted. VIN needed no change (it degraded to 50, never to zero) and the bankaccount ABA path is unreachable in practice — phone claims 021000021 first (PHONE 100 clean / 25 padded), so there is nothing there to floor. Still open: part (b), --assume-hostile, for the no-checksum validators, plus the evaluateNPI phone-keyword veto (see §4.7 residual). Root cause: validator confidence scoring treats attacker-controllable context (keywords like test/invoice/serial/example) as authoritative and lets it override value-intrinsic validity (Luhn/mod-97/NPI-Luhn checksums, the published-test denylists). The creditcard validator returns -100 on any single negative keyword regardless of Luhn; SSN/phone/DL/secrets have the same confidence <= 0 → not emitted shape. Reproduced end-to-end 2026-07-23: a Luhn-valid Visa (4532015112830366), a mod-97-valid IBAN, and an NPI-Luhn-valid NPI each go from confidence 100 to not detected at all when the line is padded with test example fake mock sample serial tracking invoice order …; VIN degrades 100→50 and SSN 100→20 (still emitted). Because pkg/redact faithfully redacts only what validators emit (it applies no confidence floor of its own — verified), a non-emitted match is a redaction bypass: the value leaks. This is the inverse risk of the false-positive-reduction work (PR #166) — every decoy-suppressing keyword is also a suppression oracle. See §4.7 for the analysis and chosen mitigation. |
| TM-12 | A filename from the scanned tree reaches the text and csv reports verbatim, so it can erase or fabricate report lines (TB-1 tampering) | Minor | Mitigated — shared.SanitizeDisplayText at every display sink; see the TB-1 row. Also fixes a related markdown defect in the same change: the gitlab-sast code fences were hardcoded to three backticks, so a matched value containing a three-backtick line closed the block early (CommonMark §4.5) — an odd number of injected fences left the closing fence acting as an opener and swallowed the Remediation section. Fences now outrun their content. Reported and fixed under #381. |
| TM-13 | A config file inside the scanned tree can disable detection, and in pre-commit/CI the disclosure that would name it was suppressed (TB-7 tampering). FindConfigFile checks the WORKING DIRECTORY first (config.yaml, ferret.yaml, ferret.yml, .ferret-scan.yaml, .ferret-scan.yml) before the user config dir, so a config shipped in a PR governs the scan reviewing that PR. |
Major | Mitigated (disclosure half) — added 2026-09-04. Project-local config is deliberate and stays; what was wrong is that the note naming a discovered config was gated on pre-commit mode, and IsPrecommitEnvironment() is true from ENVIRONMENT VARIABLES ALONE — PRE_COMMIT, _PRE_COMMIT_RUNNING, PRE_COMMIT_HOME, PRE_COMMIT_HOOK, GIT_HOOK_TYPE — so it took no flag to silence. Measured on main: a copyright notice beside a .ferret-scan.yaml disabling copyright and internal_url went 1 finding → 0 either way, but with no env var set the run emitted a 126-byte note naming the config, and with any one of the five set it emitted 0 bytes at exit 0 — the same outcome as TM-11 by a shorter path, since shipping a config is easier than crafting padding, and no TM entry covered it. The gate is removed for reportConfigProvenance only: it is a disclosure about what governed the scan, not progress output, so it is gated on neither --quiet, non-interactive, nor pre-commit. Correction, 2026-09-08: that sentence was true of the --file path but FALSE of the --stdin path when first written — #603 changed one of the two call sites and TM-13 overstated its reach. Measured: --stdin in a hostile working directory emitted 0 bytes and no note under every one of the five pre-commit environment variables, plus --pre-commit-mode and --quiet, while --file emitted 109 bytes under all of them. Fixed on the stdin path too; the note is now withheld there only when --enable-redaction is set with no --output, because stderr then carries the findings DOCUMENT and prose on it breaks 2> findings.json — a mechanical constraint rather than a preference. Guarded by cmd/stdin_config_provenance_test.go, including the case that must stay silent. warnUnknownConfigKeys deliberately stays gated — a typo'd key means a setting the operator wrote did not apply, which is a correctness annoyance rather than an attacker's lever, and that decision has its own test (TestUnknownConfigKeysSilentInPrecommit). Noise stays bounded because the note fires only for a config DISCOVERED next to the working directory and is silent when --config named one explicitly, so a repo that deliberately keeps a project config acknowledges it with a flag. Both write to stderr and neither changes the exit code. Still open: the disclosure makes the substitution visible but does not prevent it — requiring opt-in for a config found inside the scan target, and reporting config-disabled types through the Incomplete / IncompleteReason coverage channel, are tracked in #293. |
Three numbered chains the model should be evaluated against. Each names the starting trust zone, the chain of steps to impact, and the defense that breaks the chain.
-
Compromised third-party GitHub Action → tampered PyPI / GHCR / ECR release. Starting position: attacker controls one upstream action (or its mutable tag/branch). Chain: floating tag in
.github/workflows/*.yml→ action runs withid-token: writeandcontents: write→ exfiltrates OIDC token /GITHUB_TOKEN→ pushes a tampered package to PyPI via thepypienvironment, or pushes a tampered image to GHCR / ECR Public. Impact: every downstreampip install ferret-scanordocker pull public.ecr.aws/awslabs/ferret-scan:latestreceives the tampered artefact. Defense that breaks the chain: SHA-pin everyuses:(TM-08). -
Operator binds web UI to LAN → cross-origin POST forges suppression rule. Starting position: A1 unauthenticated remote attacker on the same LAN as an operator who has passed
--bind 0.0.0.0. Chain: attacker sendsPOST /suppressions/createwith a spoofedOriginheader matching the operator'shost:port. Defense that breaks the chain:originCheckMiddlewarerequires the Origin / Referer to be insameOriginHostSet, which excludes LAN-resolvable hostnames the server can't enumerate. Today's mitigation is "best-effort" by design; re-evaluate if a future deployment moves to LAN-default. Stays at "compensating control" until then. -
Operator scans an attacker-supplied PDF / DOCX → preprocessor parser exploit. Starting position: operator runs
ferret-scan --file <attacker-supplied.pdf>. Chain: bug inpdfcpu,ledongthuc/pdf, or office-extractor library is triggered. Defense: dependency provenance (gemnasium dep-scan in.gitlab-ci.yml,go mod tidypre-commit hook), 100 MB size cap, sandboxedFROM scratchruntime. Still relies on upstream library quality; thegemnasium-python-dependency_scanningjob was previously blocked by the Phase 3 #3 YAML parse error and is restored in PR #69. -
Attacker authors scanned content → context padding hides a real secret from detection/redaction. Starting position: attacker controls the bytes ferret-scan will scan — an outside contributor's PR run through a maintainer's pre-commit/CI (TB-7), or any content flowing through a redaction gateway (TB-3). Chain: attacker embeds a real, checksum-valid secret (card / IBAN / NPI) and pads the same line with negative-context keywords (
test invoice serial example …) → validator scores it to zero → match is never emitted → in redaction mode the value passes through in cleartext; in scan mode it never appears in the report a reviewer/CI acts on. Verified reproducible 2026-07-23 (creditcard/bankaccount/medicalid: 100 → not detected). Defense that breaks the chain: the value-intrinsic confidence floor (checksum-valid ⇒ cannot be zeroed by context) +--assume-hostilemode for the no-checksum validators (TM-11 / §4.7). Chain broken for checksum-bearing values as of 2026-07-27 — the floor ships, so a padded card / IBAN / NPI / DEA is emitted at LOW and therefore redacted; re-verified end-to-end (base produced zero redacted files for a padded PAN, the fix redacts it). The chain still runs for values with no checksum to floor on (MRN, bare US account, entropy secrets, person names) until--assume-hostilelands.
In priority order. Numbered for cross-reference from the REPO scan punch list.
Pin every(TM-08 / Phase 3 #1.) Shipped in PR #64. Companion PR #66 adds dependabot tracking and a workflow-conventions README so the pins stay maintainable. PR #65 takes available major-version upgrades on top of the pins.uses:in.github/workflows/*to commit SHA with version comment.Fix(.gitlab-ci.ymlline 175[redacted-internal-ci-component]/kaniko/executor@~latest). Shipped in PR #69. With the parse error fixed,gosec-sast,ferret-sast, andgemnasium-python-dependency_scanningare restored.Pin Dockerfile(TM-10.) Shipped in PR #70.FROMlines to@sha256:<digest>.Add a manual approval gate or token-scope reduction on(TM-09.) Shipped in PR #69. Final closure of TM-09 also requires verifyingauto-version-tag.GITLAB_RELEASE_TOKENscope in GitLab project settings — the YAML inline comment points to the right place.- Refactor
internal/web/assets/template.htmlto drop'unsafe-inline'from CSPscript-srcandstyle-src. (TM-05.)script-srchalf shipped (issue #147 item 1): script extracted to embedded/app.js, handlers converted to event delegation,script-src 'self'enforced.style-srchalf (inlinestyleattributes) still tracked, no time pressure. - Self-host the Cloudscape stylesheet to drop the
https://d0.awsstatic.comallowance. (TM-06.) Add the value-intrinsic confidence floor+--assume-hostilemode to close context-suppression evasion. (TM-11 / §4.7.) Two parts: (a)checksum-valid, non-denylisted matches cannot be suppressed below the emit/redact threshold by context alone — closes the creditcard/bankaccount/medicalid/VIN cleartext-leak paths by defaultshipped 2026-07-27: floor of 15 on Luhn cards, mod-97 IBAN, CMS-Luhn NPI, DEA check digit. VIN was already safe (degrades to 50, never zero) and the ABA path is unreachable —phoneclaims021000021first — so neither was touched; (b)--assume-hostile/--no-context-suppressionsurfaces every structural candidate for the no-intrinsic-check validators, default-on for the redaction gateway. Sequence with the negative-keyword consolidation refactor (tag keywords intrinsic-vs-contextual once). Ship as its own security-reviewed PR with regression cases proving a padded valid card/IBAN/NPI still surfaces.
The core design flaw. Validator confidence scoring conflates two signals that have opposite trust properties under an adversarial author:
- Value-intrinsic — the value itself proves real-or-fake independent of context: Luhn (creditcard), mod-97 (IBAN), ABA checksum, NPI-Luhn / DEA checksum (medicalid), VIN check digit; and the fakeness side: published-test denylists (
4111 1111 1111 1111, SSN123-45-6789), all-same-digit, sequential. An attacker cannot forge these away — a real card they want to exfiltrate must pass Luhn, so it cannot masquerade as a checksum failure. - Contextual — surrounding words that claim (non)sensitivity:
test,invoice,serial,example. Fully attacker-controlled in authored content.
Today context can zero out a value-intrinsically-valid match. That is the bug: a signal the attacker controls overrides a signal they cannot.
Threat-model framing. The current default encodes "the document author is trusted" — correct for the accidental-leak model (a developer's own repo, where test/example genuinely mark fixtures and suppressing them cuts false positives). It is wrong for the adversarial-author model: PR content from an outside contributor scanned by a maintainer's pre-commit/CI (TB-7), or any deployment scanning third-party-supplied content. The tool cannot know which model applies — only the operator can declare it.
Chosen mitigation — both, layered, because they address different halves:
-
Confidence floor for value-intrinsically-valid matches (always on). ✅ Shipped 2026-07-27. When a value passes its checksum AND is not on a published-test denylist, context penalties may lower its confidence for triage ordering but must not pull it below the emit/redact threshold. Value-intrinsic fakeness (denylist, sequential, all-same) still hard-drops — those are not attacker-forgeable in the dangerous direction. It does nothing for validators with no intrinsic check — see the residual.
As built: the floor is 15.0, deliberately the same number the creditcard validator already used as the hard cap for known-suspect patterns. A value whose line claims
hash/timestamp/testis therefore treated as no more credible than the most-suspect known value — it sorts to the bottom of LOW — but it is emitted, and emission is what makes redaction cover it. Applied to creditcard (Luhn), bankaccount compact and spaced IBAN (mod-97), medicalid NPI (CMS Luhn) and DEA (check digit). Not applied to: SWIFT/BIC and US account numbers (no checksum), and ABA — its checksum passes roughly 1 in 10 random 9-digit values, so the banking-context requirement is carrying the false-positive load rather than the checksum, and in practicephoneclaims the value first anyway. VIN needed nothing (already degrades to 50, not 0).Measured cost, 600 real plain-text documents, all validators, before vs after: 0 findings lost, 0 confidence changes, no new IBAN/NPI/DEA findings at all. The only new rows were 6 creditcard decoys in one deliberately adversarial fixture (
UUID,MD5 hash,Database record ID,Network node IDlabels on Luhn-valid numbers), all at 15 LOW. That is the intended trade and it is not fixable by better keywords: the "MD5 hash" decoy is a Luhn-valid Mastercard BIN, so the value alone cannot distinguish it from a real PAN — which is precisely why context must not be allowed the last word in the other direction. -
Opt-in hostile mode —
--assume-hostile(alias--no-context-suppression). Disables all context-driven suppression, so every structural candidate surfaces regardless of surrounding keywords. This is the only mitigation that helps the no-intrinsic-check validators, and it puts the threat-model choice in the operator's hands rather than the tool guessing. Default stays trust-context (low FP for the common accidental-leak case); pre-commit-on-untrusted-PRs and gateway/DLP deployments opt in. The redaction gateway (pkg/redact) should default this to on — its entire input is adversarial by definition — but that is one consumer of the flag, not the whole fix.
Why not either alone: the floor can't protect MRN / bare US bank account / generic-entropy secrets / person names (no math to floor on); hostile-mode alone would force every self-scan to eat the full false-positive load that the negative-keyword work (PR #166) deliberately removed. Together: the floor makes the checksum validators safe by default, and hostile-mode lets the operator extend "surface everything" to the rest when the threat model demands it.
Rejected: a padding-density anomaly heuristic ("too many negative keywords near a match = suspicious"). Gameable, adds its own FPs, and is an arms race — not a trust-boundary fix.
Residual (documented, not closable). Validators with no value-intrinsic signal — MRN, bare US bank account numbers, generic high-entropy secrets, person names, phone/SSN structural-only — cannot be made evasion-proof: there is no attacker-unforgeable property to floor on. For these, --assume-hostile degrades to "surface every structural candidate," which is correct but carries the full FP cost. This is a fundamental limit of pattern-plus-context detection, not a ferret-scan defect.
Residual (closable, but not by the floor) — cross-validator vetoes. A second, narrower suppression oracle survives the floor: validators veto each other using keyword context as a proxy, because no cross-validator arbitration layer exists (internal/parallel/validator_runner.go plain-concatenates every validator's matches; two validators claiming the same bytes simply both emit). The live case is medicalid.evaluateNPI, which returns no-match whenever the line contains phone context — so appending the word phone erases a CMS-Luhn-valid NPI, exactly the TM-11 shape, and it also costs recall on legitimate content (Provider NPI 1234567893, phone 555-0100 reports no NPI). Gating the veto on "unless provider context is present" was tried and rejected: physician / hospital are provider context, so phone-shaped NPI-Luhn-valid numbers came back as false positives. The correct fix is same-span arbitration — when two validators claim overlapping bytes, prefer the more specific one (checksum-bearing NPI over generic 10-digit PHONE) instead of having each guess from keywords. That needs match spans carried on detector.Match (match_len is 0 in JSON output today), a specificity ordering, and a decision on whether the loser is dropped or demoted. Tracked separately; the veto is left in place with an in-code KNOWN RESIDUAL comment rather than replaced by more keyword logic.
Implementation note. The floor and hostile-mode both want each negative keyword tagged value-intrinsic vs contextual, which is exactly the classification the planned negative-keyword consolidation refactor introduces — build them together so the security logic has a structural basis rather than per-validator special-casing. The shipped floor deliberately does not wait for that refactor: it is applied at the four checksum sites directly, because the leak was live and the refactor is a larger change. Once keywords are tagged, those four call sites become the natural place to read the tag instead of hard-coding the floor per validator.
Regression coverage. Each floored validator has a dedicated test that asserts the precondition (the keyword really does drive the score to zero), the exact floor value, LOW-band membership, that an unpadded value is not re-ranked, and that a checksum-invalid value is still rejected outright — so the floor can never be mistaken for "emit everything". Two golden-corpus cases (context_soft_negative_labels, context_keywords_snake_case) previously recorded PANs surviving --enable-redaction in cleartext; their expectations now record redaction, and their descriptions state demotion-not-erasure as the contract so a future change cannot silently restore the leak.
- Supply-chain attacks on the
go.moddependency tree — covered bygo mod tidy, license check, and gemnasium dependency scanning in CI. - Compromise of the developer machine itself.
- Side-channel attacks on the running process.