@clear-capabilities/agentic-security-scanner 0.130.0 → 0.133.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +247 -0
  2. package/bin/agentic-security.js +39 -3
  3. package/dist/113.index.js +294 -5
  4. package/dist/178.index.js +1 -1
  5. package/dist/207.index.js +7 -4
  6. package/dist/238.index.js +218 -0
  7. package/dist/259.index.js +975 -0
  8. package/dist/384.index.js +1 -1
  9. package/dist/435.index.js +2 -2
  10. package/dist/526.index.js +294 -5
  11. package/dist/637.index.js +1 -1
  12. package/dist/agentic-security.mjs +18 -57
  13. package/dist/agentic-security.mjs.sha256 +1 -1
  14. package/package.json +19 -10
  15. package/src/engine.js +48 -1
  16. package/src/ir/parser-js.js +8 -0
  17. package/src/llm-validator/cost-ceiling.js +199 -0
  18. package/src/llm-validator/index.js +241 -12
  19. package/src/llm-validator/local-endpoint.js +90 -0
  20. package/src/mcp/tools.js +2 -2
  21. package/src/posture/CLAUDE.md +83 -6
  22. package/src/posture/accuracy-scorecard.js +37 -6
  23. package/src/posture/attestation.js +7 -4
  24. package/src/posture/corpus-enroll.js +303 -0
  25. package/src/posture/corpus-match.js +67 -0
  26. package/src/posture/custom-rules.js +2 -2
  27. package/src/posture/execution-proof.js +44 -4
  28. package/src/posture/fix-metrics.js +197 -0
  29. package/src/posture/fix-verify.js +76 -2
  30. package/src/posture/integrity.js +42 -9
  31. package/src/posture/learning.js +8 -1
  32. package/src/posture/model-routing.js +26 -0
  33. package/src/posture/model-trust.js +174 -0
  34. package/src/posture/poc-inprocess.js +165 -0
  35. package/src/posture/prove-findings.js +148 -0
  36. package/src/posture/root-cause-sweep.js +0 -0
  37. package/src/posture/rule-overrides.js +64 -3
  38. package/src/posture/state-dir.js +25 -0
  39. package/src/posture/vuln-archaeology.js +231 -0
  40. package/src/report/index.js +7 -0
  41. package/src/runScan.js +2 -6
  42. package/src/sandbox/CLAUDE.md +190 -46
  43. package/src/sandbox/backend-namespace.js +328 -48
  44. package/src/sandbox/backend-userspace.js +6 -19
  45. package/src/sandbox/capabilities.js +132 -4
  46. package/src/sandbox/limits.js +21 -0
  47. package/src/sandbox/result.js +1 -1
  48. package/src/sast/CLAUDE.md +4 -0
  49. package/src/sast/crypto-specialist.js +247 -0
  50. package/src/util/glob.js +173 -0
@@ -0,0 +1,148 @@
1
+ // Promote findings to `execution-proven` during a scan (R2 — the automatic half).
2
+ //
3
+ // Before this, `proveFinding` existed and was tested but had no call site in a
4
+ // scan, so `last-scan.json` could never contain an execution-proven finding and
5
+ // corpus auto-enrolment had to be driven by hand. This annotator closes that:
6
+ // it synthesizes a sandbox-runnable PoC for eligible findings, runs it inside
7
+ // R1's sandbox, and lets the sandbox decide the tier.
8
+ //
9
+ // OPT-IN, AND IT STAYS OPT-IN. This executes code derived from the scanned
10
+ // project. That is a different risk class from static analysis, and it is slow
11
+ // — one sandboxed process per candidate. Making it default-on would change
12
+ // what `scan` means. Enable with `AGENTIC_SECURITY_PROVE=1`.
13
+ //
14
+ // FAIL-CLOSED IN BOTH DIRECTIONS:
15
+ // - No sandbox → nothing is executed and no tier is promoted. An
16
+ // unavailable sandbox disables the feature, it never bypasses it.
17
+ // - A PoC that could not run leaves the finding at its static tier. Only a
18
+ // PoC that RAN and produced the marker yields `execution-proven`;
19
+ // `attachProofTier` enforces that independently of anything here.
20
+ //
21
+ // BOUNDED TWICE, BECAUSE ONE BOUND WAS NOT ENOUGH. `maxCandidates` caps how
22
+ // many findings are proved in one scan, and the cap is REPORTED rather than
23
+ // applied silently — a scan that quietly proved the first N findings and said
24
+ // nothing would look like a scan that found only N provable ones.
25
+ //
26
+ // A count cap alone bounds nothing in time, though, and a per-run timeout is
27
+ // only as good as the backend's ability to enforce it — which CI proved is not
28
+ // something to take on faith (the namespace backend's timeout did not stop a
29
+ // payload at all until `killSignal: 'SIGKILL'` landed). So there is also an
30
+ // AGGREGATE wall-clock budget checked between candidates. It cannot interrupt a
31
+ // call already in flight (`spawnSync` blocks the thread), but it bounds the
32
+ // total and stops the loop rather than letting a slow host multiply one bad
33
+ // case by `maxCandidates`.
34
+ //
35
+ // WHAT THIS ACTUALLY EXECUTES, STATED PLAINLY. The generated PoC does
36
+ // `import handler from './<target file>'`, and an ES module import runs the
37
+ // target file's ENTIRE TOP-LEVEL BODY before any handler is called. So enabling
38
+ // this on a repository you do not trust executes that repository's top-level
39
+ // code. Confinement contains what it can — no filesystem writes outside the
40
+ // sandbox root, no network egress, both verified by executing tests on each
41
+ // backend — but CPU and wall-clock are bounded only by the budgets here. Do not
42
+ // enable this on untrusted code without accepting that.
43
+
44
+ import { synthesizeInProcessPoc } from './poc-inprocess.js';
45
+ import { proveFinding } from './execution-proof.js';
46
+ import { sandboxAvailable } from '../sandbox/index.js';
47
+
48
+ const DEFAULT_MAX = 25;
49
+ // Aggregate wall-clock across all candidates in one scan.
50
+ const DEFAULT_TOTAL_BUDGET_MS = 120000;
51
+
52
+ export function proveEnabled(env = process.env) {
53
+ return env.AGENTIC_SECURITY_PROVE === '1';
54
+ }
55
+
56
+ /**
57
+ * @param {object[]} findings annotated findings (mutated in place)
58
+ * @param {object} opts
59
+ * @param {Map|object} opts.fileContents file -> source, as the engine already carries
60
+ * @returns {object} a summary suitable for surfacing on the scan
61
+ */
62
+ export async function annotateExecutionProofs(findings, {
63
+ fileContents = null, maxCandidates = DEFAULT_MAX, timeoutMs = 10000,
64
+ totalBudgetMs = DEFAULT_TOTAL_BUDGET_MS, env = process.env, now = Date.now,
65
+ } = {}) {
66
+ const summary = {
67
+ enabled: false, attempted: 0, proven: 0, failed: 0, inconclusive: 0,
68
+ skipped: 0, capped: 0, budgetExhausted: 0, reason: null,
69
+ };
70
+ if (!Array.isArray(findings) || !findings.length) return summary;
71
+ if (!proveEnabled(env)) {
72
+ summary.reason = 'not enabled (set AGENTIC_SECURITY_PROVE=1)';
73
+ return summary;
74
+ }
75
+ if (!sandboxAvailable()) {
76
+ // Deliberately not an error: an unavailable confinement primitive means
77
+ // execution features switch OFF, per R1's constraint.
78
+ summary.reason = 'no confinement backend available; execution proof disabled';
79
+ return summary;
80
+ }
81
+ summary.enabled = true;
82
+
83
+ const read = (file) => {
84
+ if (!fileContents) return null;
85
+ if (typeof fileContents.get === 'function') return fileContents.get(file) ?? null;
86
+ return fileContents[file] ?? null;
87
+ };
88
+
89
+ const candidates = [];
90
+ for (const f of findings) {
91
+ if (!f || typeof f !== 'object') continue;
92
+ const content = read(f.file);
93
+ const syn = synthesizeInProcessPoc(f, content);
94
+ if (!syn.ok) { summary.skipped++; continue; }
95
+ candidates.push({ finding: f, poc: syn.poc, content });
96
+ }
97
+
98
+ if (candidates.length > maxCandidates) {
99
+ summary.capped = candidates.length - maxCandidates;
100
+ candidates.length = maxCandidates;
101
+ }
102
+
103
+ const startedAt = now();
104
+ for (const c of candidates) {
105
+ // Checked BEFORE each call, since a call in flight cannot be interrupted.
106
+ // Reported, never silent: findings left unproven because the budget ran out
107
+ // are a different statement from findings that could not be proved.
108
+ if (now() - startedAt >= totalBudgetMs) {
109
+ summary.budgetExhausted = candidates.length - summary.attempted;
110
+ break;
111
+ }
112
+ summary.attempted++;
113
+ // The PoC imports the vulnerable file, so it must exist in the sandbox
114
+ // root alongside it.
115
+ const files = {};
116
+ for (const rel of c.poc.requires || []) files[rel] = c.content;
117
+ let proved;
118
+ try {
119
+ proved = await proveFinding({ ...c.finding, poc: c.poc }, { files, timeoutMs });
120
+ } catch (e) {
121
+ summary.inconclusive++;
122
+ continue;
123
+ }
124
+ c.finding.poc = c.poc;
125
+ c.finding.proofTier = proved.proofTier;
126
+ c.finding.proofEvidence = proved.proofEvidence;
127
+ if (proved.proofTier === 'execution-proven') summary.proven++;
128
+ else if (proved.proofTier === 'proof-failed') summary.failed++;
129
+ else summary.inconclusive++;
130
+ }
131
+ return summary;
132
+ }
133
+
134
+ /** One-line human summary; null when the feature did not run. */
135
+ export function renderProofSummary(s) {
136
+ if (!s || !s.enabled) return null;
137
+ const bits = [`${s.proven} execution-proven of ${s.attempted} attempted`];
138
+ if (s.failed) bits.push(`${s.failed} ran without demonstrating the bug (triage signal, NOT a false-positive verdict)`);
139
+ if (s.inconclusive) bits.push(`${s.inconclusive} inconclusive`);
140
+ if (s.capped) bits.push(`${s.capped} eligible finding(s) NOT attempted (per-scan cap)`);
141
+ if (s.budgetExhausted) {
142
+ bits.push(`${s.budgetExhausted} eligible finding(s) NOT attempted (aggregate time budget exhausted) — `
143
+ + 'unproven here means unattempted, not unprovable');
144
+ }
145
+ return bits.join('; ') + '.';
146
+ }
147
+
148
+ export const _internals = { DEFAULT_MAX, DEFAULT_TOTAL_BUDGET_MS };
Binary file
@@ -120,11 +120,72 @@ export function applyOverrides(findings, scanRoot) {
120
120
  disable = new Set();
121
121
  }
122
122
  const sevMap = o.severityOverrides || {};
123
- return findings
124
- .filter(f => !disable.has(f.vuln) && !disable.has(f.id))
125
- .map(f => sevMap[f.vuln] ? { ...f, severity: sevMap[f.vuln] } : f);
123
+ const kept = [];
124
+ for (const f of findings) {
125
+ const key = disable.has(f.vuln) ? f.vuln : (disable.has(f.id) ? f.id : null);
126
+ if (key !== null) {
127
+ // RECORD the suppression. A `disable:` that takes effect otherwise
128
+ // produces findings that are simply absent — indistinguishable, in the
129
+ // artifact a human reads, from a clean scan. Coverage reduction must be
130
+ // visible where the results are, not only on a stderr line nobody keeps.
131
+ _recordSuppression(scanRoot, key, f);
132
+ continue;
133
+ }
134
+ kept.push(sevMap[f.vuln] ? { ...f, severity: sevMap[f.vuln] } : f);
135
+ }
136
+ return kept;
137
+ }
138
+
139
+ // Per-scanRoot ledger of what `disable:` removed, and under what authority.
140
+ // Read by the CLI and attached to the scan output.
141
+ const _suppressions = new Map();
142
+
143
+ function _recordSuppression(scanRoot, key, finding) {
144
+ const root = String(scanRoot || '');
145
+ let e = _suppressions.get(root);
146
+ if (!e) { e = { rules: new Map(), total: 0 }; _suppressions.set(root, e); }
147
+ let r = e.rules.get(key);
148
+ if (!r) { r = { rule: key, count: 0, severities: {}, examples: [] }; e.rules.set(key, r); }
149
+ r.count++;
150
+ e.total++;
151
+ const sev = finding?.severity || 'unknown';
152
+ r.severities[sev] = (r.severities[sev] || 0) + 1;
153
+ // A bounded sample so a reader can see WHAT was removed, not just how much.
154
+ if (r.examples.length < 5 && finding?.file) {
155
+ r.examples.push(`${finding.file}${finding.line ? ':' + finding.line : ''}`);
156
+ }
126
157
  }
127
158
 
159
+ /**
160
+ * What `disable:` removed from this scan, and under what authority.
161
+ *
162
+ * Returns null when nothing was suppressed — callers omit the section rather
163
+ * than rendering an empty one. `authority` is the reason the gate allowed it,
164
+ * so a reader can tell a signed suppression from an env-var opt-out.
165
+ */
166
+ export function suppressionReport(scanRoot) {
167
+ const e = _suppressions.get(String(scanRoot || ''));
168
+ if (!e || !e.total) return null;
169
+ const gate = _disableAllowed(scanRoot);
170
+ return {
171
+ total: e.total,
172
+ authority: gate.reason,
173
+ rules: [...e.rules.values()].sort((a, b) => b.count - a.count || (a.rule < b.rule ? -1 : 1)),
174
+ note: 'These findings were REMOVED from the report by `disable:` in '
175
+ + '.agentic-security/rules.yml. They are not absent because the code is clean.',
176
+ };
177
+ }
178
+
179
+ /** One-line human summary; null when nothing was suppressed. */
180
+ export function renderSuppressionSummary(rep) {
181
+ if (!rep) return null;
182
+ const top = rep.rules.slice(0, 3).map(r => `${r.rule} (${r.count})`).join(', ');
183
+ return `${rep.total} finding(s) SUPPRESSED by rules.yml disable: [${top}${rep.rules.length > 3 ? ', …' : ''}] `
184
+ + `— authority: ${rep.authority}. These are removed results, not clean code.`;
185
+ }
186
+
187
+ export function _resetSuppressionsForTests() { _suppressions.clear(); }
188
+
128
189
  // Cache: compiled custom rules per scanRoot. Validated at first call;
129
190
  // subsequent calls for the same scan reuse the compiled regexes.
130
191
  const _compiledCustomRules = new Map(); // scanRoot → { compiled[], errors[] }
@@ -16,6 +16,8 @@
16
16
  import * as fs from 'node:fs';
17
17
  import * as path from 'node:path';
18
18
 
19
+ const STATE_DIR_NAME = '.agentic-security';
20
+
19
21
  const PROJECT_MARKERS = [
20
22
  '.git',
21
23
  'package.json',
@@ -83,6 +85,29 @@ export function isSafeStateDir(dir) {
83
85
  if (fs.existsSync(path.join(parent, m))) return true;
84
86
  } catch { /* ignore */ }
85
87
  }
88
+ // A directory NESTED inside an already-valid state root is safe too.
89
+ //
90
+ // Without this the check only ever accepted `<project>/.agentic-security`
91
+ // itself, because it looks for a project marker in the immediate parent and
92
+ // `.agentic-security` deliberately does not count as one. Every nested state
93
+ // directory — `llm-cache/`, `fix-history/`, `sbom-history/` — therefore
94
+ // failed, and `safeWriteState` silently refused to write there.
95
+ //
96
+ // The consequence was not theoretical: `llm-validator`'s `writeCache` goes
97
+ // through `safeWriteState`, so the validator cache never persisted a single
98
+ // entry. Every scan re-queried the model for every finding, while a
99
+ // `validator-cache stats|gc` subcommand existed to manage a cache that was
100
+ // always empty. Found by a positive-control test asserting that a
101
+ // legitimately written entry round-trips.
102
+ //
103
+ // This does not weaken the guard. Its purpose is to stop `.agentic-security/`
104
+ // being created in unrelated directories; a subdirectory of a state root that
105
+ // has already been validated is exactly the case it was never meant to catch.
106
+ const segments = path.resolve(dir).split(path.sep);
107
+ const idx = segments.lastIndexOf(STATE_DIR_NAME);
108
+ if (idx > 0 && idx < segments.length - 1) {
109
+ return isSafeStateDir(segments.slice(0, idx + 1).join(path.sep));
110
+ }
86
111
  return false;
87
112
  }
88
113
 
@@ -0,0 +1,231 @@
1
+ // R14 — vulnerability archaeology.
2
+ //
3
+ // The engine scores the CURRENT tree, and `material-change.js` scores a diff.
4
+ // Neither reads history. But a repository's history holds a signal nothing in
5
+ // the present tree does: WHERE this team has introduced security bugs before,
6
+ // and how often those places needed fixing again.
7
+ //
8
+ // The output is not a finding. It is a per-file risk prior — "this file has
9
+ // been security-fixed four times in the last two years" — that ranks where
10
+ // attention is worth spending. Emitting archaeology as findings would be
11
+ // wrong twice over: the bugs are already fixed, and a historical fix is not
12
+ // evidence of a present defect.
13
+ //
14
+ // HOW A SECURITY FIX IS RECOGNISED, AND WHY THAT IS THE HARD PART. There is no
15
+ // reliable marker. Commit messages are the only broad signal, and they are
16
+ // written by humans in a hurry. So the classifier is deliberately conservative
17
+ // and every match carries the evidence that produced it:
18
+ //
19
+ // - A CVE/GHSA identifier is strong evidence and is treated as such.
20
+ // - A vocabulary of fix verbs paired with vulnerability nouns ("fix XSS",
21
+ // "patch the traversal") is medium evidence.
22
+ // - A bare vulnerability noun is weak: "add XSS tests", "refactor the auth
23
+ // module" and "document CSRF" all mention the noun without fixing
24
+ // anything. These are counted separately and never inflate the strong tier.
25
+ //
26
+ // FALSE POSITIVES ARE EXPECTED AND MUST STAY VISIBLE. A message-based
27
+ // classifier cannot be precise, so the module reports its tiers separately
28
+ // rather than collapsing them into one confident number, and every hotspot
29
+ // carries the commit subjects behind it so a human can dismiss a bad match in
30
+ // seconds. A single blended "risk score" would hide exactly the errors a
31
+ // reader needs to see.
32
+ //
33
+ // Offline and cheap: one `git log` invocation, bounded by commit count and a
34
+ // timeout, degrading to an empty result in a non-repository.
35
+
36
+ import { execFileSync } from 'node:child_process';
37
+
38
+ const CVE_RE = /\b(?:CVE-\d{4}-\d{4,7}|GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4})\b/i;
39
+
40
+ const FIX_VERBS = /\b(?:fix(?:e[sd])?|patch(?:e[sd])?|resolv(?:e[sd])|remediat(?:e[sd])|harden(?:ed)?|mitigat(?:e[sd])|prevent(?:ed)?|sanitiz(?:e[sd])|escap(?:e[sd])|clos(?:e[sd]))\b/i;
41
+
42
+ const VULN_NOUNS = new RegExp([
43
+ 'xss', 'cross[- ]site scripting', 'csrf', 'cross[- ]site request forgery',
44
+ 'sql[- ]?injection', 'sqli', 'command[- ]?injection', 'code[- ]?injection',
45
+ 'path[- ]?traversal', 'directory[- ]?traversal', 'ssrf', 'xxe',
46
+ 'deserializ', 'prototype[- ]pollution', 'open[- ]redirect', 'redos',
47
+ 'privilege escalation', 'auth(?:entication|orization)? bypass', 'idor',
48
+ 'insecure[- ]direct[- ]object', 'race condition', 'use[- ]after[- ]free',
49
+ 'buffer overflow', 'integer overflow', 'timing attack', 'security', 'vulnerab',
50
+ 'exploit', 'injection', 'sanitiz', 'unsafe[- ]eval', 'hardcoded (?:secret|password|key|credential)',
51
+ ].join('|'), 'i');
52
+
53
+ // Messages that mention a vulnerability noun while plainly not fixing one.
54
+ // Checked BEFORE the verb pair, because "add tests for the XSS fix" contains
55
+ // both a fix verb and a noun yet fixes nothing.
56
+ //
57
+ // KEPT DELIBERATELY NARROW. Words like "comment", "format", "example", "spec"
58
+ // and "demo" look like chore markers but are ordinary code vocabulary — "fix
59
+ // XSS in the comment renderer" is a real fix, and an over-broad list demotes it
60
+ // to the weak tier. Every word here has to be one that describes the COMMIT'S
61
+ // PURPOSE and is unlikely to name a code component. When in doubt leave a word
62
+ // out: a missed demotion costs one over-counted hotspot, while a wrong one
63
+ // hides a real security fix from the ranking entirely.
64
+ const NON_FIX_RE = /\b(?:test(?:s|ing|ed)?|doc(?:s|ument(?:s|ed|ing|ation)?)?|readme|changelog|typo|rename[ds]?|lint|bump(?:ed)?|revert(?:ed)?|wip|todo)\b/i;
65
+
66
+ export const TIERS = Object.freeze(['identified', 'likely', 'mentioned']);
67
+
68
+ /**
69
+ * Classify one commit subject.
70
+ * @returns {{tier:string, evidence:string}|null}
71
+ */
72
+ export function classifyCommit(subject) {
73
+ if (typeof subject !== 'string' || !subject.trim()) return null;
74
+ const s = subject.trim();
75
+
76
+ const cve = s.match(CVE_RE);
77
+ if (cve) return { tier: 'identified', evidence: `references ${cve[0]}` };
78
+
79
+ const noun = s.match(VULN_NOUNS);
80
+ if (!noun) return null;
81
+
82
+ if (NON_FIX_RE.test(s)) {
83
+ return { tier: 'mentioned', evidence: `mentions "${noun[0]}" but reads as test/doc/chore work` };
84
+ }
85
+ const verb = s.match(FIX_VERBS);
86
+ if (verb) return { tier: 'likely', evidence: `"${verb[0]}" + "${noun[0]}"` };
87
+ return { tier: 'mentioned', evidence: `mentions "${noun[0]}" with no fix verb` };
88
+ }
89
+
90
+ function _gitLog(scanRoot, { maxCommits, timeoutMs }) {
91
+ // NUL-delimited records so subjects containing newlines cannot split a
92
+ // record — a commit message is arbitrary user text and must not be able to
93
+ // forge a record boundary.
94
+ const out = execFileSync('git', [
95
+ '-C', scanRoot, 'log', '-n', String(maxCommits), '--no-merges', '--no-color',
96
+ '--name-only', '--format=%x00%H%x1f%aI%x1f%s',
97
+ ], { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
98
+ const commits = [];
99
+ for (const block of out.split('\0')) {
100
+ if (!block.trim()) continue;
101
+ const nl = block.indexOf('\n');
102
+ const header = nl === -1 ? block : block.slice(0, nl);
103
+ const [sha, date, ...rest] = header.split('\x1f');
104
+ if (!sha) continue;
105
+ const files = nl === -1 ? [] : block.slice(nl + 1).split('\n').map(l => l.trim()).filter(Boolean);
106
+ commits.push({ sha, date: date || null, subject: rest.join('\x1f') || '', files });
107
+ }
108
+ return commits;
109
+ }
110
+
111
+ /**
112
+ * Mine history for security-relevant commits and turn them into per-file priors.
113
+ *
114
+ * @returns {{available:boolean, reason:string|null, commitsScanned:number,
115
+ * byTier:object, hotspots:Array, commits:Array}}
116
+ */
117
+ export function mineVulnHistory(scanRoot, { maxCommits = 500, timeoutMs = 30000, maxHotspots = 25, minConcentration = 0.15 } = {}) {
118
+ const empty = {
119
+ available: false, reason: null, commitsScanned: 0,
120
+ byTier: { identified: 0, likely: 0, mentioned: 0 }, hotspots: [], commits: [],
121
+ };
122
+ if (!scanRoot) return { ...empty, reason: 'no scan root' };
123
+
124
+ let commits;
125
+ try {
126
+ commits = _gitLog(scanRoot, { maxCommits, timeoutMs });
127
+ } catch (e) {
128
+ // Not a repository, git absent, shallow clone, timeout — all "no history
129
+ // available", never an error that fails a scan.
130
+ return { ...empty, reason: `git history unavailable: ${String(e.message || e).split('\n')[0]}` };
131
+ }
132
+
133
+ const byTier = { identified: 0, likely: 0, mentioned: 0 };
134
+ const perFile = new Map();
135
+ const matched = [];
136
+
137
+ const _file = (f) => {
138
+ let e = perFile.get(f);
139
+ if (!e) { e = { file: f, identified: 0, likely: 0, mentioned: 0, totalCommits: 0, subjects: [] }; perFile.set(f, e); }
140
+ return e;
141
+ };
142
+
143
+ // Total touches first, across EVERY commit. Without this the ranking rewards
144
+ // churn: a release commit whose subject says "fix XSS ..." also touches the
145
+ // changelog, the manifest and the version file, so the most-edited files in
146
+ // the repository float to the top of a raw count and the actual vulnerable
147
+ // source is buried. Measured on this repository, the raw ranking returned
148
+ // CLAUDE.md and package.json as the top two hotspots.
149
+ for (const c of commits) for (const f of c.files) _file(f).totalCommits++;
150
+
151
+ for (const c of commits) {
152
+ const cls = classifyCommit(c.subject);
153
+ if (!cls) continue;
154
+ byTier[cls.tier]++;
155
+ matched.push({ sha: c.sha.slice(0, 12), date: c.date, subject: c.subject, tier: cls.tier, evidence: cls.evidence, files: c.files.length });
156
+ for (const file of c.files) {
157
+ const e = _file(file);
158
+ e[cls.tier]++;
159
+ // Keep a bounded sample of the evidence, so a hotspot can be judged
160
+ // without re-reading the log.
161
+ if (e.subjects.length < 5) e.subjects.push({ sha: c.sha.slice(0, 12), tier: cls.tier, subject: c.subject });
162
+ }
163
+ }
164
+
165
+ const hotspots = [...perFile.values()]
166
+ // `mentioned` deliberately does NOT contribute to the ranking weight. It is
167
+ // the tier most likely to be wrong, so letting it rank files would sort by
168
+ // classifier error. It is still reported per file so a reader can see it.
169
+ .map(e => {
170
+ const weight = e.identified * 3 + e.likely;
171
+ // What fraction of this file's history is security work. A file touched
172
+ // by 200 commits of which 3 were security fixes is not a hotspot; one
173
+ // touched 4 times, 3 of them security fixes, is.
174
+ const concentration = e.totalCommits ? (e.identified + e.likely) / e.totalCommits : 0;
175
+ return { ...e, weight, concentration: Number(concentration.toFixed(3)) };
176
+ })
177
+ .filter(e => e.weight > 0 && e.concentration >= minConcentration)
178
+ // Rank by concentration first: it is the churn-corrected signal. Weight
179
+ // breaks ties so that, among equally-concentrated files, more security
180
+ // history ranks higher.
181
+ .sort((a, b) => b.concentration - a.concentration || b.weight - a.weight || (a.file < b.file ? -1 : 1))
182
+ .slice(0, maxHotspots);
183
+
184
+ return {
185
+ available: true,
186
+ reason: null,
187
+ commitsScanned: commits.length,
188
+ // Stated so a reader knows whether they are looking at all of history.
189
+ truncated: commits.length >= maxCommits,
190
+ byTier,
191
+ hotspots,
192
+ commits: matched.slice(0, 100),
193
+ };
194
+ }
195
+
196
+ /**
197
+ * Attach the historical prior to findings whose file is a hotspot.
198
+ * Advisory only: never changes severity, never removes anything.
199
+ */
200
+ export function annotateHistoricalRisk(findings, history) {
201
+ if (!Array.isArray(findings) || !history?.available) return 0;
202
+ const byFile = new Map(history.hotspots.map(h => [h.file, h]));
203
+ let n = 0;
204
+ for (const f of findings) {
205
+ const h = f && f.file ? byFile.get(f.file) : null;
206
+ if (!h) continue;
207
+ f.historicalRisk = {
208
+ priorSecurityFixes: h.identified + h.likely,
209
+ identified: h.identified,
210
+ likely: h.likely,
211
+ note: 'advisory prior from git history — not evidence about this finding',
212
+ };
213
+ n++;
214
+ }
215
+ return n;
216
+ }
217
+
218
+ /** One-line summary; null when there is no history to report. */
219
+ export function renderArchaeology(h) {
220
+ if (!h) return null;
221
+ if (!h.available) return `vulnerability archaeology: skipped (${h.reason}).`;
222
+ const { identified, likely, mentioned } = h.byTier;
223
+ if (!identified && !likely && !mentioned) {
224
+ return `vulnerability archaeology: no security-relevant commits in the last ${h.commitsScanned}.`;
225
+ }
226
+ return `vulnerability archaeology: ${identified} CVE-identified, ${likely} likely and ${mentioned} `
227
+ + `merely-mentioning commit(s) across ${h.commitsScanned} scanned${h.truncated ? ' (history truncated)' : ''}; `
228
+ + `${h.hotspots.length} file hotspot(s). Ranking ignores the "mentioned" tier — it is the least reliable.`;
229
+ }
230
+
231
+ export const _internals = { CVE_RE, FIX_VERBS, VULN_NOUNS, NON_FIX_RE, _gitLog };
@@ -412,6 +412,13 @@ export function toJSON(scan, meta={}, opts={}){
412
412
  // It carries its own `proves` / `doesNotProve` statement — do not quote
413
413
  // the digest as cross-machine reproducibility, which it is not.
414
414
  attestation: scan.attestation || null,
415
+ // Coverage reduction, surfaced in the ARTIFACT. `disable:` in rules.yml
416
+ // removes findings from the report, and a removed finding is
417
+ // indistinguishable from clean code unless the removal is stated. Null when
418
+ // nothing was suppressed, so consumers can omit the section rather than
419
+ // render an empty one. An AUTHORISED suppression is reported too — the
420
+ // signature proves who asked for it, not that the results are absent.
421
+ suppressedRules: scan.suppressedRules || null,
415
422
  _scanMeta: scan._scanMeta || null,
416
423
  };
417
424
  if (opts.includeSuppressed) out.suppressed = scan.suppressions||[];
package/src/runScan.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import * as fs from 'node:fs/promises';
4
4
  import * as path from 'node:path';
5
5
  import * as cp from 'node:child_process';
6
- import fg from 'fast-glob';
6
+ import { listFiles } from './util/glob.js';
7
7
  import { runFullScan, shouldScan } from './engine.js';
8
8
  import { appendScanSnapshot } from './posture/security-trend.js';
9
9
  import { recover as recoverFixHistory } from './posture/fix-history.js';
@@ -26,11 +26,7 @@ const DEFAULT_IGNORE = [
26
26
  ];
27
27
 
28
28
  export async function readTree(root, { ignore = [] } = {}) {
29
- const entries = await fg('**/*', {
30
- cwd: root, dot: true, onlyFiles: true,
31
- ignore: [...DEFAULT_IGNORE, ...ignore], followSymbolicLinks: false,
32
- suppressErrors: true,
33
- });
29
+ const entries = await listFiles(root, { ignore: [...DEFAULT_IGNORE, ...ignore] });
34
30
  const fileContents = {};
35
31
  const depFileContents = {};
36
32
  for (const rel of entries) {