Skip to content

Fix the scanner's false positives upstream, and gate on high-severity findings - #19

Merged
ralyodio merged 1 commit into
mainfrom
threatcrush-triage
Aug 8, 2026
Merged

ralyodio merged 1 commit into
mainfrom
threatcrush-triage

Conversation

@ralyodio

@ralyodio ralyodio commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

You were right that these are false positives — all 56 of them. I verified each one individually rather than assuming, and separately made a real vulnerability stop the merge.

Triage

Rule # Why it cannot be exploited
js-unescaped-html-sink 44 Extracted all 59 interpolations reaching an HTML sink across public/*.js and classified them; the 17 not provably safe mechanically were read by hand. Every one is escaped via esc()/aEsc(), is a number through toFixed/toLocaleString, or comes from a hardcoded constant. The HIGH one (auth.js:189) is a static string — no interpolation at all.
js-ssrf-outbound-request 3 URL is a constant base + code-literal path. Caller input reaches only URLSearchParams values, which are percent-encoded and cannot alter scheme, host or port.
secret-* 2 Test fixtures. The auth one is a deliberately real-looking fake whose test exists to prove a real-looking key still cannot send under NODE_ENV=test.
sql-template-interpolation 4 Hardcoded table names, the static ADDED_COLUMNS migration map, and ? placeholders with bound args. One points at a console.log.
redos-nested-quantifier 1 Measured, not argued. The quantifiers are disjoint on their first char (digit vs .), so there is nothing to backtrack through — 60,000-char adversarial input matches in 2.6ms, growing linearly.
js-dynamic-code-execution 1 The jsdom harness eval-ing this repo's own files.
insecure-temp-file 1 A "/tmp/x" string literal in a fixture; nothing opens it.

One honest note: a few credit values in auth.js are interpolated unescaped. They are safe because the server coerces them with Number() and the packages are a literal array — so not a vulnerability, but it is safety held by an invariant elsewhere rather than at the sink. Happy to harden it if you want; it would not change the scanner output either way.

The gate

ThreatCrush 0.3.0 has no ignore file and no inline suppression — the only control is --fail-on <severity>. I checked the CLI and the package. With every current finding a false positive, --fail-on high would block every PR and get switched off within a day.

So the gate is "no new findings". The triaged 56 live in .github/threatcrush-baseline.json, each with a written justification; anything not listed fails CI. A real vulnerability stops the merge, the known-clean ones do not.

Verified by injecting one. I added a genuine XSS (unescaped query text into innerHTML), and the gate failed:

FAILED — 1 unreviewed finding(s).
  [medium] js-unescaped-html-sink
      public/app.js:1551
      document.getElementById("cx-summary").innerHTML = "<b>Results for " + q + "</b>";
  count for js-unescaped-html-sink in public/app.js rose 35 -> 36

Removed it, green again. It also fails closed: no findings file means "NOT scanned", not "clean".

Design details worth reviewing:

  • Keyed by rule + file + hash of the line, not line number, so edits above a finding do not spuriously fail. Per-file counts are checked too, so a second identical-looking sink is still caught (two pairs of current findings share a fingerprint, and the count covers them).
  • Findings inside the baseline file are ignored. It quotes source lines so a reviewer can see what they are signing off, and the scanner otherwise reads those quotes as code — that self-reference showed up in testing and produced 8 phantom findings.
  • Lives in a new workflow, not in threatcrush-scan.yml, which is managed by the sh1pt Actions Fleet and carries a content hash that a pack update would restore over any local edit.

No product code changed: 490 tests pass, tsc clean.

🤖 Generated with Claude Code

Two things: establish whether the 56 findings are real, and make a genuine
vulnerability stop the merge.

TRIAGE. All 56 are false positives. Verified individually rather than assumed:

  - js-unescaped-html-sink (44). Extracted all 59 interpolations reaching an
    HTML sink across public/*.js and classified them; the 17 that could not be
    proven safe mechanically were read by hand. Every one is escaped through
    esc()/aEsc(), is a number formatted by toFixed/toLocaleString, or comes
    from a hardcoded constant. The HIGH one is a static string with no
    interpolation at all. Credit values are unescaped but the server coerces
    them with Number() and the packages are a literal array.
  - js-ssrf-outbound-request (3). URLs are a constant base plus a code-literal
    path; caller input reaches only URLSearchParams values, which are encoded
    and cannot alter scheme, host or port.
  - secret-* (2). Test fixtures. The auth one is a deliberately real-looking
    fake whose test exists to prove a real-looking key cannot send under
    NODE_ENV=test.
  - sql-template-interpolation (4). Hardcoded table names, the static
    ADDED_COLUMNS map, and '?' placeholders with bound arguments.
  - redos-nested-quantifier (1). Measured, not argued: the quantifiers are
    disjoint on their first character, and 60k-char adversarial input matches
    in 2.6ms with linear growth.
  - js-dynamic-code-execution (1). The jsdom harness eval'ing our own files.
  - insecure-temp-file (1). A string literal in a fixture.

GATE. ThreatCrush 0.3.0 has no ignore file and no inline suppression; the only
control is --fail-on <severity>. With every current finding a false positive,
enabling it would block every PR and be switched off within a day. So the gate
is "no NEW findings": the triaged 56 are recorded in threatcrush-baseline.json
with justifications, and anything not listed fails.

Verified by injecting a real XSS (unescaped query text into innerHTML) and
confirming the gate fails with exit 1 naming that line, then removing it and
confirming green.

Keyed by rule + file + hash of the offending line rather than line number, so
edits above a finding do not spuriously fail; per-file counts are checked too,
so a second identical-looking sink is still caught. Findings located in the
baseline file itself are ignored — it quotes source lines so reviewers can see
what they are signing off, and those quotes are otherwise scanned as code.

Lives in a new workflow rather than in threatcrush-scan.yml, which is managed
by the sh1pt Actions Fleet and carries a content hash that a pack update would
restore over any local edit.

No product code changed: 490 tests pass, tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

64 finding(s)

HIGH/CRITICAL: 6 | MEDIUM: 58

Severity Rule Location
HIGH js-unescaped-html-sink public/auth.js:189
HIGH js-ssrf-outbound-request src/crypto/client.ts:109
HIGH js-ssrf-outbound-request src/crypto/fundamentals.ts:137
HIGH js-ssrf-outbound-request src/providers/alpaca.ts:76
HIGH secret-generic-api-key test/auth.test.ts:190
HIGH secret-generic-credential test/credits.test.ts:24
MEDIUM redos-nested-quantifier .github/threatcrush-baseline.json:76
MEDIUM redos-nested-quantifier .github/threatcrush-baseline.json:78
MEDIUM sql-template-interpolation .github/threatcrush-baseline.json:132
MEDIUM sql-template-interpolation .github/threatcrush-baseline.json:195
MEDIUM sql-template-interpolation .github/threatcrush-baseline.json:237
MEDIUM sql-template-interpolation .github/threatcrush-baseline.json:307
MEDIUM insecure-temp-file .github/threatcrush-baseline.json:342
MEDIUM insecure-temp-file .github/threatcrush-baseline.json:344
MEDIUM js-unescaped-html-sink public/app.js:41
MEDIUM js-unescaped-html-sink public/app.js:97
MEDIUM js-unescaped-html-sink public/app.js:232
MEDIUM js-unescaped-html-sink public/app.js:244
MEDIUM js-unescaped-html-sink public/app.js:246
MEDIUM js-unescaped-html-sink public/app.js:338
MEDIUM js-unescaped-html-sink public/app.js:354
MEDIUM js-unescaped-html-sink public/app.js:358
MEDIUM js-unescaped-html-sink public/app.js:362
MEDIUM js-unescaped-html-sink public/app.js:381
MEDIUM js-unescaped-html-sink public/app.js:385
MEDIUM js-unescaped-html-sink public/app.js:389
MEDIUM js-unescaped-html-sink public/app.js:572
MEDIUM js-unescaped-html-sink public/app.js:657
MEDIUM js-unescaped-html-sink public/app.js:678
MEDIUM js-unescaped-html-sink public/app.js:800
MEDIUM js-unescaped-html-sink public/app.js:801
MEDIUM js-unescaped-html-sink public/app.js:810
MEDIUM js-unescaped-html-sink public/app.js:848
MEDIUM js-unescaped-html-sink public/app.js:908
MEDIUM js-unescaped-html-sink public/app.js:913
MEDIUM js-unescaped-html-sink public/app.js:926
MEDIUM js-unescaped-html-sink public/app.js:977
MEDIUM js-unescaped-html-sink public/app.js:1056
MEDIUM js-unescaped-html-sink public/app.js:1062
MEDIUM js-unescaped-html-sink public/app.js:1129
MEDIUM js-unescaped-html-sink public/app.js:1169
MEDIUM js-unescaped-html-sink public/app.js:1218
MEDIUM js-unescaped-html-sink public/app.js:1234
MEDIUM js-unescaped-html-sink public/app.js:1377
MEDIUM js-unescaped-html-sink public/app.js:1379
MEDIUM js-unescaped-html-sink public/app.js:1396
MEDIUM js-unescaped-html-sink public/app.js:1591
MEDIUM js-unescaped-html-sink public/app.js:1603
MEDIUM js-unescaped-html-sink public/app.js:1618
MEDIUM js-unescaped-html-sink public/auth.js:41

…and 14 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 9e689cf into main Aug 8, 2026
5 checks passed
@ralyodio

ralyodio commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Simplified, now that the scanner itself is fixed.

The baseline machinery existed for one reason: 56 findings here, all 56 false positives, six of them high — so --fail-on would have blocked every PR. That turned out to be a rule problem rather than anything in this repo, and it is fixed upstream in ThreatCrush 0.4.0 (threatcrush#76, published to npm).

This repo now has zero high-severity findings, so the gate is just:

threatcrush scan . --fail-on high

.github/threatcrush-baseline.json and .github/threatcrush-gate.py are gone — about 200 lines removed. It still fails closed: a scan that produces no findings file is reported as not scanned, not as clean.

What the simpler gate gives up

Worth being explicit, because it is a real reduction in coverage. Severity depends on whether the scanner can see the taint source near the sink, so the same XSS is graded differently depending on how the code is arranged. Both measured against this tree:

shape severity blocks?
innerHTML = '<b>' + new URL(location).searchParams.get('q') + '</b>' high yes
innerHTML = '<b>' + q + '</b>', where q is a parameter medium no

So it stops a vulnerability written in one place and misses one whose source sits in another function. The old baseline gate would have caught both, because it failed on any new finding at any severity.

--fail-on medium would close the gap and today costs 31 false positives — all innerHTML sinks in multi-line templates whose interpolations are escaped, just on a different line than the assignment, which a line-oriented scanner cannot see. Closing those properly needs multi-line template awareness in the scanner, which is a reasonable next upstream change.

Both the README and the workflow now say this is a floor, not a proof.

Verification

  • 490 tests pass, tsc --noEmit clean
  • gate verified locally against the published 0.4.0: passes on this tree, exits 1 on an injected XSS with a visible taint source

@ralyodio ralyodio changed the title Triage every ThreatCrush finding, and gate on new ones Fix the scanner's false positives upstream, and gate on high-severity findings Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant