Skip to content

scan: cut false positives without losing coverage (0.4.0) - #76

Merged
ralyodio merged 1 commit into
masterfrom
scan-accuracy-0.4.0
Aug 8, 2026
Merged

scan: cut false positives without losing coverage (0.4.0)#76
ralyodio merged 1 commit into
masterfrom
scan-accuracy-0.4.0

Conversation

@ralyodio

@ralyodio ralyodio commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Scanning a real repository produced 56 findings, all 56 false positives — including every one of its 6 high-severity ones. That makes --fail-on unusable anywhere: a gate that blocks every pull request gets switched off within a day.

Result: 56 findings → 33, and 6 high → 0. No true positives were traded away (verified below).

Four changes

1. Static HTML sinks. innerHTML = '<div class="spinner"></div>' was reported as XSS. An assignment of a string with no interpolation and no concatenation carries no data, so it cannot carry attacker data. A UI built with innerHTML reported every static heading and spinner it had.

This needed a new lineGuard, checked against the matched line only. The existing guard also searches the context window — right for "sanitised three lines up", wrong here, because a static assignment says nothing about a dynamic one two lines below and a context-scoped guard would veto the dynamic one too. There is a test for exactly that.

2. Escaper aliases. The guard matched escapeHtml( and sanitize( but not esc( — which is what the escaper is actually called in code that escapes every interpolation. So the codebases escaping most rigorously were the ones reported most often.

Now matches short aliases, with the identifier required to end at the escaper (plus a known output-context suffix). A looser form I tried first also matched describe( — which would have silenced every finding inside every test file in every repo. Also tested.

3. searchParams is a read and a write API. It counted as untrusted input, so url.searchParams.set(...)building an outbound URL — marked every client of every third-party API as taking attacker input. That is what fired the SSRF rule on requests whose host is a compile-time constant. Only the reading half (get/getAll/entries/…) is evidence now.

4. Credentials in tests → low, not dropped. A fixture is not a leak, and one flagged case existed precisely to prove a real-looking key cannot send. But "nearly always a fixture" is not "always" — a genuine key does get pasted into a test, and hiding it would be worse than the noise. Still reported, no longer blocking.

Coverage is intact

Verified in the other direction against a file of deliberate vulnerabilities. All still reported at high or critical:

case result
reflected XSS (+ req.query.q into innerHTML) high
interpolated XSS (${req.query.name}) high
SSRF from req.query.url high
SSRF from searchParams.get(...) high
SQLi by concatenation critical
SQLi by interpolation critical
eval(req.body.code) critical
live Stripe key critical

62 scan tests pass.

Note

GitHub push protection rejected my first commit because a test fixture used a well-formed sk_live_ string. Correctly — which is a reasonable endorsement of the rule this file tests. The fixture is vendor-less now.

Releasing

npm-publish.yml fires on a v* tag and publishes apps/cli; the version is bumped to 0.4.0 here. Merging does not publish — tagging does. I have not tagged.

🤖 Generated with Claude Code

Measured against a real repository the scanner reported 56 findings, all 56 of
which were false positives, including every one of its 6 high-severity ones.
That is not a tuning problem, it is a fatal one: a gate that cannot be enabled
without blocking every pull request gets switched off, and `--fail-on` was
unusable anywhere.

Four changes, each aimed at a class the triage showed was systematically wrong.

STATIC HTML SINKS. `innerHTML = '<div class="spinner"></div>'` was reported as
XSS. An assignment of a string with no interpolation and no concatenation
carries no data, so it cannot carry attacker data. A UI built with innerHTML
reported every static heading and spinner it had.

This needed a new `lineGuard`, checked against the matched line only. The
existing `guard` also searches the context window, which is right for "the
value was sanitised three lines up" but wrong here: a static assignment says
nothing about a dynamic one two lines below, and a context-scoped guard would
have vetoed the dynamic one too. There is a test for exactly that.

ESCAPER ALIASES. The guard matched `escapeHtml(` and `sanitize(` but not
`esc(`, which is what the escaper is actually called in code that escapes every
interpolation. The codebases escaping most rigorously were therefore the ones
reported most often. Now matches short aliases, with the identifier required to
END at the escaper (plus a known output-context suffix). An earlier, looser
form also matched `describe(` — which would have silenced every finding inside
every test file in every repository. Also tested.

SEARCHPARAMS IS A READ AND A WRITE API. `searchParams` counted as untrusted
input, so `url.searchParams.set('limit', 50)` — building an outbound URL —
marked every client of every third-party API as taking attacker input. That is
what fired the SSRF rule on requests whose host is a compile-time constant.
Only the reading half (`get`/`getAll`/`entries`/…) is evidence now.

CREDENTIALS IN TESTS. A fixture is not a leak, and one of the flagged ones
existed precisely to prove a real-looking key still cannot send. These are
downgraded to `low` in test paths rather than dropped: "nearly always a
fixture" is not "always", a genuine key does get pasted into a test, and
hiding it would be worse than the noise. Still reported, no longer blocking.

Result on that repository: 56 findings -> 33, and 6 high-severity -> 0, so
`--fail-on high` is now a usable gate. Verified in the other direction too,
against a file of deliberate vulnerabilities: reflected and interpolated XSS,
SSRF from `req.query` and from `searchParams.get`, SQL injection by
concatenation and by interpolation, `eval` of a request body, and a live
Stripe key are all still reported at high or critical. Nothing was traded away.

62 scan tests pass.
it('stays silent on an assignment with no interpolation', () => {
// A UI built with innerHTML reports every static heading and spinner. That
// was the largest single source of noise in the corpus.
expect(ruleIds('a.js', `el.innerHTML = '<div class="spinner"></div>';`)).toHaveLength(0);
// A UI built with innerHTML reports every static heading and spinner. That
// was the largest single source of noise in the corpus.
expect(ruleIds('a.js', `el.innerHTML = '<div class="spinner"></div>';`)).toHaveLength(0);
expect(ruleIds('a.js', 'el.innerHTML = `<h2>Verifying your email…</h2>`;')).toHaveLength(0);
// was the largest single source of noise in the corpus.
expect(ruleIds('a.js', `el.innerHTML = '<div class="spinner"></div>';`)).toHaveLength(0);
expect(ruleIds('a.js', 'el.innerHTML = `<h2>Verifying your email…</h2>`;')).toHaveLength(0);
expect(ruleIds('a.js', 'body.innerHTML = "<p>done</p>"')).toHaveLength(0);
});

it('still flags interpolation and concatenation', () => {
expect(ruleIds('a.js', 'el.innerHTML = `<b>Results for ${q}</b>`;')).toContain(
expect(ruleIds('a.js', 'el.innerHTML = `<b>Results for ${q}</b>`;')).toContain(
'js-unescaped-html-sink',
);
expect(ruleIds('a.js', 'el.innerHTML = "<b>Results for " + q + "</b>";')).toContain(
const source = [
"const url = new URL('https://api.example.com/v1/bars');",
"url.searchParams.set('symbols', symbols.join(','));",
'const res = await fetch(url, { headers });',
it('still flags a request whose URL comes from the caller', () => {
const source = [
'const target = req.query.url;',
'const res = await fetch(target);',
it('treats reading searchParams as untrusted input', () => {
const source = [
'const target = new URL(req.url).searchParams.get("next");',
'const res = await fetch(target);',
it('does not treat describe() as an escaper', () => {
// A looser form of the alias pattern matched `describe(`, which would have
// silenced every finding inside every test file in every repository.
const source = ['describe("thing", () => {', ' el.innerHTML = `<b>${name}</b>`;'].join('\n');
});

it('does not treat an arbitrary identifier ending in -esc- as one', () => {
expect(ruleIds('a.js', 'el.innerHTML = `<b>${rescale(name)}</b>`;')).toContain(
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

159 finding(s)

HIGH/CRITICAL: 52 | MEDIUM: 107

Severity Rule Location
HIGH sql-template-interpolation apps/cli/src/scan/__tests__/code-rules.test.ts:30
HIGH secret-aws-access-key apps/cli/src/scan/__tests__/engine.test.ts:19
HIGH secret-aws-access-key apps/cli/src/scan/__tests__/engine.test.ts:20
HIGH secret-github-token apps/cli/src/scan/__tests__/engine.test.ts:25
HIGH secret-github-token apps/cli/src/scan/__tests__/engine.test.ts:27
HIGH secret-aws-access-key apps/cli/src/scan/__tests__/engine.test.ts:40
HIGH secret-aws-access-key apps/cli/src/scan/__tests__/engine.test.ts:41
HIGH secret-github-token apps/cli/src/scan/__tests__/engine.test.ts:69
HIGH secret-aws-access-key apps/cli/src/scan/secret-rules.ts:192
HIGH js-shell-exec-interpolation modules/code-scanner/src/__tests__/sast.test.ts:31
HIGH js-shell-exec-interpolation modules/code-scanner/src/__tests__/sast.test.ts:102
HIGH js-shell-exec-interpolation modules/code-scanner/src/__tests__/sast.test.ts:108
HIGH secret-private-key modules/code-scanner/src/__tests__/secrets.test.ts:36
HIGH secret-github-token modules/code-scanner/src/__tests__/secrets.test.ts:138
HIGH secret-aws-access-key modules/code-scanner/src/__tests__/secrets.test.ts:145
HIGH secret-aws-access-key modules/code-scanner/src/__tests__/secrets.test.ts:155
HIGH secret-aws-access-key modules/code-scanner/src/secrets/rules.ts:74
HIGH secret-aws-access-key prd/0003-detect-hardcoded-secrets-before-they-are-committed-or-served.md:126
HIGH secret-aws-access-key scripts/smoke-test.sh:112
HIGH js-unsafe-yaml-load apps/cli/src/scan/__tests__/code-rules.test.ts:182
HIGH js-unescaped-html-sink apps/cli/src/scan/__tests__/code-rules.test.ts:215
HIGH js-ssrf-outbound-request apps/cli/src/scan/__tests__/code-rules.test.ts:239
HIGH js-ssrf-outbound-request apps/cli/src/scan/__tests__/code-rules.test.ts:247
HIGH js-ssrf-outbound-request apps/cli/src/scan/__tests__/code-rules.test.ts:255
HIGH secret-database-url apps/cli/src/scan/__tests__/engine.test.ts:52
HIGH manifest-typosquat apps/mobile/package.json:43
HIGH secret-generic-credential apps/web/src/__tests__/coinpay.test.ts:39
HIGH secret-generic-credential apps/web/src/__tests__/coinpay.test.ts:49
HIGH secret-generic-credential apps/web/src/__tests__/coinpay.test.ts:59
HIGH secret-generic-credential apps/web/src/__tests__/purchase-flow.test.ts:42
HIGH secret-generic-credential apps/web/src/app/api/auth/login/__tests__/route.test.ts:70
HIGH secret-generic-credential apps/web/src/app/api/auth/login/__tests__/route.test.ts:90
HIGH secret-generic-credential apps/web/src/app/api/auth/login/__tests__/route.test.ts:99
HIGH secret-generic-credential apps/web/src/app/api/auth/login/__tests__/route.test.ts:135
HIGH secret-generic-credential apps/web/src/app/api/auth/signup/__tests__/route.test.ts:84
HIGH secret-generic-credential apps/web/src/app/api/auth/signup/__tests__/route.test.ts:96
HIGH secret-generic-credential apps/web/src/app/api/auth/signup/__tests__/route.test.ts:104
HIGH secret-generic-credential apps/web/src/app/api/auth/signup/__tests__/route.test.ts:115
HIGH secret-generic-credential apps/web/src/app/api/auth/signup/__tests__/route.test.ts:140
HIGH secret-generic-credential apps/web/src/app/api/auth/signup/__tests__/route.test.ts:156
HIGH secret-generic-credential apps/web/src/app/api/auth/signup/__tests__/route.test.ts:167
HIGH secret-generic-credential apps/web/src/app/api/auth/signup/__tests__/route.test.ts:182
HIGH secret-generic-credential apps/web/src/app/api/auth/signup/__tests__/route.test.ts:191
HIGH secret-generic-credential apps/web/src/app/api/auth/verify-phone/__tests__/route.test.ts:208
HIGH secret-generic-credential apps/web/src/app/api/usage/__tests__/route.test.ts:57
HIGH secret-database-url modules/code-scanner/src/__tests__/secrets.test.ts:124
HIGH secret-generic-credential modules/code-scanner/src/__tests__/secrets.test.ts:184
HIGH secret-generic-credential modules/code-scanner/src/__tests__/secrets.test.ts:189
HIGH secret-database-url modules/code-scanner/src/__tests__/secrets.test.ts:244
HIGH secret-generic-credential modules/spend-guard/config/example.conf.toml:13

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

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit faea8a7 into master Aug 8, 2026
9 of 10 checks passed
@ralyodio
ralyodio deleted the scan-accuracy-0.4.0 branch August 8, 2026 01:32
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.

2 participants