Skip to content

feat(cli): emit SARIF natively, and detect the code-level vulnerability classes - #73

Merged
ralyodio merged 2 commits into
masterfrom
feat/scanner-sarif-integration
Aug 3, 2026
Merged

feat(cli): emit SARIF natively, and detect the code-level vulnerability classes#73
ralyodio merged 2 commits into
masterfrom
feat/scanner-sarif-integration

Conversation

@ralyodio

@ralyodio ralyodio commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Implements what malware-test-prs/docs/SCANNER_INTEGRATION.md documents as missing on the ThreatCrush side.

The measurement

That doc scored this CLI at 15.6% true-positive rate, 0.0% false-positive rate and concluded, correctly: "ThreatCrush is a secrets scanner." It found every hardcoded credential and none of the code-level classes — no SQLi, XSS, SSRF, command injection, deserialisation, or XXE.

Metric Before After
True positive rate 15.6% (12/77) 90.32% (84/93)
False positive rate 0.0% 0.0% (0/46)
Unattributed findings 0 0
Findings outside corpus 0

Reproduce:

git clone https://github.com/profullstack/malware-test-prs && cd malware-test-prs
threatcrush scan vulns --format sarif --output tc.sarif
python3 scripts/validate-coverage.py --sarif tc.sarif --catalog vulns/VULNERABILITY_CATALOG.json
# → PASS

Keeping the 0%

The false-positive denominator is a control group of SAFE: lines — each one a correct implementation of the pattern the vulnerable code beside it gets wrong. Every rule is built against both halves. Three mechanisms:

  1. Shape, not keyword. db.query("SELECT … $1", [id]) and db.query("SELECT … '" + id + "'") both contain SELECT. Only the second concatenates.
  2. Guard windows. An allow-list two lines up, a realpath on the line, an ObjectInputFilter installed before the readObject().
  3. Confidence. A bare construct match is capped at medium, structurally. Only visible untrusted input escalates. --fail-on critical therefore won't fire on "this construct exists".

Two guards were wrong in ways only the corpus surfaced:

  • a comment reading # no allow-list, no scheme restriction was read as an allow-list;
  • def sanitize_path_vulnerable(path): was read as a sanitiser, silencing three ReDoS findings.

Comments and definition lines are now excluded from guard windows, and Python docstrings from scanning entirely — a file whose header describes its own vulnerability shouldn't produce findings about that description.

Known gaps, on purpose

All nine remaining misses are the four classes in KNOWN_GAPS: CSRF (3), TOCTOU (2), integer overflow (2), generic dynamic-assignment prototype pollution (2). Each needs whole-function reasoning, and each line-oriented approximation flags ordinary software. Nothing is missed by accident.

CLI surface

The doc records that scan accepted a path and nothing else, so the testbed parsed terminal output into SARIF and hit three bugs — one of which (paths relative to the scan root) made a working scan read as 0% coverage, silently. Native SARIF removes that pipe entirely.

--format text|json|sarif, --output, --fail-on, --path-prefix, --deps, --verbose. URIs resolve against the working directory; startLine clamped to ≥ 1; non-text formats route human output to stderr so > out.sarif is valid.

Bugs found while testing

  • threatcrush scan file.js reported a clean scan of a file it never opened — the walker only handled directories, and readdirSync on a file was caught as an unreadable directory.
  • Unreadable paths were silently dropped, so a tree the scanner couldn't read reported as a tree with no findings. Now surfaced.

Verification

  • 49 vitest tests (pnpm --filter @profullstack/threatcrush test), tsc --noEmit clean, tsup builds.
  • Exit codes checked end to end: 0 clean, 1 at/above --fail-on, 2 scan failure.
  • SARIF stdout verified as pure JSON with the banner routed to stderr.
  • Real-code noise check across three codebases; remaining hits are the scanner's own test fixtures, which is what inline suppression is for.

Docs: docs/SCANNING.md.

🤖 Generated with Claude Code

Scored against profullstack/malware-test-prs, the CLI managed 15.6%
true-positive rate at a 0.0% false-positive rate: it found every
hardcoded credential and none of the code-level classes. No SQL
injection, XSS, SSRF, command injection, deserialisation or XXE.
ThreatCrush was a secrets scanner wearing a code scanner's name.

    True positive rate   15.6% (12/77)  ->  90.32% (84/93)
    False positive rate  0.0%           ->  0.0% (0/46)
    Unattributed         0              ->  0

The false-positive number is the one that was worth keeping. That
denominator is a control group of SAFE: lines, each a *correct*
implementation of the pattern the vulnerable code beside it gets wrong,
so every rule here is built against both halves — it has to fire on the
vulnerable shape and stay silent on the corrected one. Three mechanisms
do that: match on shape rather than keyword (a bound `$1` query and a
concatenated one both contain SELECT); guard windows, where an
allow-list or a realpath nearby exonerates the construct; and a
confidence model where a bare pattern match is capped at medium and only
visible untrusted input escalates it.

Two of those guards were wrong in ways only the corpus surfaced. A
comment reading "no allow-list validation" was being read as an
allow-list, and `def sanitize_path_vulnerable(path)` as a sanitiser —
both suppressed real findings. Comments and definition lines are now
excluded from guard windows, and Python docstrings from scanning
entirely, since a file whose header describes its own vulnerability
should not produce findings about that description.

All nine remaining misses are the four classes recorded in KNOWN_GAPS —
CSRF, TOCTOU, integer overflow, and generic dynamic-assignment prototype
pollution. Each needs whole-function reasoning, and each line-oriented
approximation flags ordinary software. A missing detection is a known
number; a rule that fires on every session read is a scanner nobody runs
twice.

Also adds the CLI surface the testbed had to work around. `scan` took a
path and nothing else, so the integration parsed terminal output into
SARIF and hit three bugs doing it — one of which (paths relative to the
scan root) made a working scan read as 0% coverage. Native SARIF removes
that pipe: `--format text|json|sarif`, `--output`, `--fail-on`,
`--path-prefix`, `--deps`, `--verbose`. URIs resolve against the working
directory, startLine is clamped to >= 1, and non-text formats keep stdout
clean by routing human output to stderr.

Two bugs found while testing: `threatcrush scan file.js` reported a
clean scan of a file it never opened, because the walker only handled
directories; and unreadable paths were silently dropped, so a tree the
scanner could not read reported as a tree with no findings. Both now
report what actually happened.

Adds inline suppression (threatcrush-disable-next-line), matching the
convention modules/code-scanner already uses, because the
highest-volume false positive in practice is a scanner's own test
fixtures. Suppressions are counted and reported.

49 tests, and docs/SCANNING.md.

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

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@socket-security

socket-security Bot commented Aug 3, 2026

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
License policy violation: npm rollup under unrecognized license

License: unrecognized license - This license was not allowed or given any lesser classification by the applicable policy (package/LICENSE.md)

From: ?npm/vitest@3.2.7npm/rollup@4.62.4

ℹ Read more on: This package | This alert | What is a license policy violation?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Find a package that does not violate your license policy or adjust your policy to allow this package's license.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/rollup@4.62.4. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

Comment thread apps/cli/src/scan/engine.ts Fixed
Two CI failures on this branch.

`pnpm install --frozen-lockfile` rejected the tree: adding vitest to
apps/cli/package.json without regenerating the root pnpm-lock.yaml left
the two out of sync. Regenerating also drops a stale modules/dep-scanner
entry for a package that no longer exists.

CodeQL flagged a file-system race in the walker, correctly. Calling
statSync(path) for the size check and then readFileSync(path) to read is
check-then-use: the path can be replaced between the two calls, so the
size that was checked is not the size that gets read. Open once and
fstat the descriptor instead — it refers to the same inode for both
operations whatever happens to the name.

Worth fixing rather than dismissing. A scanner spends its life walking
directories it does not control, and CWE-362 is a class this tool
reports on; the walker should not be an example of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ralyodio
ralyodio merged commit f95b81b into master Aug 3, 2026
9 checks passed
@ralyodio
ralyodio deleted the feat/scanner-sarif-integration branch August 3, 2026 10:29
ralyodio added a commit to profullstack/sh1pt that referenced this pull request Aug 3, 2026
Adds a `threatcrush-scan` action pack: runs [ThreatCrush](https://threatcrush.com) over pull requests and uploads SARIF to the Security tab. This is the unit the fleet installs for the org-wide rollout.

Ships two files: the workflow, and `.github/scripts/threatcrush-to-sarif.py` — a fail-closed converter for CLI versions older than native `--format`.

## Field-tested on a real repo, and it caught me out twice

I installed this on `moshcoder/moshpit-name` before rolling to 216 more. Four runs, in order:

**1 — green, and wrong.** Reported `0 findings`, having scanned nothing. The runner had `0.2.2`, which has no `--format`; the scan died with `error: unknown option '--format'` and commander exited `1` — *the same code the CLI uses for findings at or above `failOn`*. Read as a result, no SARIF written, empty-run fallback supplied one, repo declared clean.

**2 — red, comment still lied.** A capability check fixed the job status, but the comment *still* said "0 findings": it tested `status == "error"`, and a **skipped** step yields the empty string. Fail-open in the one branch written to prevent exactly this.

**3 — red and honest**, but it refused to scan anything until a new CLI shipped. Correct and useless.

**4 — green, genuinely scanned.** Detect the interface up front, convert legacy output when `--format` is absent.

## Design

| CLI | Path |
| --- | --- |
| Has `--format` | Native SARIF. Nothing is parsed. |
| Older | Text scan → `.github/scripts/threatcrush-to-sarif.py` |

The check is up front because **exit codes cannot separate "argument rejected" from "findings found"** — that ambiguity is what produced run 1.

The converter **fails closed**: unrecognised output exits non-zero and writes nothing. Emitting empty SARIF instead reports "0 findings", indistinguishable from a clean scan. It is written against the testbed's real captured output, because three details each break a naive parser: severity is bare for `CRITICAL` and bracketed for `[HIGH]`/`[MEDIUM]`/`[LOW]`; paths are relative to the scan root, so they resolve to nothing unprefixed; whole-file findings report line `0`, which SARIF rejects.

Beyond that: **the SARIF file is the evidence** (missing or empty ⇒ error, whatever the process returned); **the report is fail-closed** (findings render only on `clean`/`findings`, every other state is NOT RUN); and **`failOn` actually gates** — it was recording findings and letting the step pass, so the gate would never have failed a PR.

Other decisions: **no secrets** (installs fleet-wide without provisioning); **report-only by default** (a gate that fires on every pre-existing finding gets switched off within a day); **not `pull_request_target`** (runs with repo secrets in scope against untrusted contributor code — asserted in a test); **Node 20** for `better-sqlite3` prebuilts.

## Verification

- **Four live runs on a real repository**, which is what found both bugs.
- Converter against the testbed's real fixture: 9/9 findings parsed, both severity shapes, `:0` clamped, prefix applied. Scored through the testbed's own validator at **12.9% TPR / 0.0% FPR**, matching the published CLI's documented baseline.
- Fail-closed paths: garbage input, help screen, and a crashing CLI all exit non-zero and write no SARIF. A clean scan is correctly recognised as clean, not as an error.
- All four scan outcomes against stub CLIs: no SARIF → `exit 1 status=error`; clean → `exit 0 status=clean`; findings → `exit 1 status=findings`; crash → `exit 2 status=error`.
- Report Python across all four `SCAN_STATUS` values: `''` and `error` → NOT RUN; `clean`/`findings` → results table.
- Rendered through a faithful reimplementation of the fleet renderer, first checked against `vu1nz-scan` where it reproduces `moshcoder/moshcode`'s committed workflow byte-for-byte.
- `bash -n` on every shell block; manifest validated field-by-field against `actionPackManifestSchema` including `.strict()`.

## Coverage

The legacy path is a stopgap — `0.2.2` is a secrets scanner at 12.9%. Once profullstack/threatcrush#73 is merged and published, every installed workflow switches to native SARIF automatically and coverage goes to **90.32%** at the same 0.0% false-positive rate. No re-render needed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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