@clear-capabilities/agentic-security-scanner 0.132.0 → 0.134.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 +228 -0
  2. package/bin/agentic-security.js +103 -1
  3. package/dist/113.index.js +3 -3
  4. package/dist/178.index.js +1 -1
  5. package/dist/384.index.js +1 -1
  6. package/dist/499.index.js +86 -0
  7. package/dist/526.index.js +3 -3
  8. package/dist/609.index.js +741 -0
  9. package/dist/637.index.js +1 -1
  10. package/dist/agentic-security.mjs +56 -56
  11. package/dist/agentic-security.mjs.sha256 +1 -1
  12. package/package.json +9 -4
  13. package/src/discovery/CLAUDE.md +38 -0
  14. package/src/discovery/confirm.js +47 -0
  15. package/src/discovery/disprove.js +79 -0
  16. package/src/discovery/hunter.js +116 -0
  17. package/src/discovery/index.js +159 -0
  18. package/src/discovery/judge.js +97 -0
  19. package/src/discovery/lenses.js +69 -0
  20. package/src/discovery/llm-invoke.js +31 -0
  21. package/src/discovery/partition.js +92 -0
  22. package/src/engine.js +151 -1
  23. package/src/llm-validator/cost-ceiling.js +199 -0
  24. package/src/llm-validator/index.js +254 -35
  25. package/src/llm-validator/local-endpoint.js +90 -0
  26. package/src/llm-validator/providers.js +227 -0
  27. package/src/posture/CLAUDE.md +76 -0
  28. package/src/posture/accuracy-scorecard.js +37 -6
  29. package/src/posture/autopilot.js +225 -0
  30. package/src/posture/comparison.js +181 -0
  31. package/src/posture/corpus-match.js +29 -14
  32. package/src/posture/execution-proof.js +25 -1
  33. package/src/posture/fleet.js +0 -0
  34. package/src/posture/integrity.js +42 -9
  35. package/src/posture/learning.js +8 -1
  36. package/src/posture/logic-claims.js +266 -0
  37. package/src/posture/model-routing.js +26 -0
  38. package/src/posture/model-trust.js +174 -0
  39. package/src/posture/poc-inprocess.js +567 -0
  40. package/src/posture/proof-artifact.js +101 -0
  41. package/src/posture/prove-findings.js +172 -0
  42. package/src/posture/rule-overrides.js +64 -3
  43. package/src/posture/state-dir.js +25 -0
  44. package/src/posture/vuln-archaeology.js +231 -0
  45. package/src/report/index.js +16 -0
  46. package/src/sandbox/CLAUDE.md +27 -5
  47. package/src/sandbox/backend-namespace.js +39 -11
  48. package/src/sandbox/backend-userspace.js +4 -0
  49. package/src/sast/CLAUDE.md +4 -0
  50. package/src/sast/crypto-specialist.js +247 -0
@@ -0,0 +1,172 @@
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, DEFAULT_PROOF_TIMEOUT_MS } 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
+ /**
53
+ * The file set materialised into the sandbox root for one PoC.
54
+ *
55
+ * `requires` names the vulnerable source (the PoC imports it). `extraFiles`
56
+ * carries support files a template needs that are NOT that source — the SQL
57
+ * class ships a recording driver stub as `node_modules/<driver>/index.js`.
58
+ *
59
+ * `requires` WINS on a collision. Otherwise a template could name the
60
+ * vulnerable file in `extraFiles` and replace the very code the PoC is
61
+ * supposed to exploit with content of its own choosing, and the run would
62
+ * prove a fact about the template.
63
+ */
64
+ export function mergePocFiles(poc, content) {
65
+ const files = {};
66
+ for (const rel of poc?.requires || []) files[rel] = content;
67
+ for (const [rel, c] of Object.entries(poc?.extraFiles || {})) {
68
+ if (rel in files) continue;
69
+ if (typeof c === 'string') files[rel] = c;
70
+ }
71
+ return files;
72
+ }
73
+
74
+ export function proveEnabled(env = process.env) {
75
+ return env.AGENTIC_SECURITY_PROVE === '1';
76
+ }
77
+
78
+ /**
79
+ * @param {object[]} findings annotated findings (mutated in place)
80
+ * @param {object} opts
81
+ * @param {Map|object} opts.fileContents file -> source, as the engine already carries
82
+ * @returns {object} a summary suitable for surfacing on the scan
83
+ */
84
+ export async function annotateExecutionProofs(findings, {
85
+ // Shares one ceiling with execution-proof.js so the two cannot drift; see the
86
+ // rationale on DEFAULT_PROOF_TIMEOUT_MS there. Bounded overall by
87
+ // maxCandidates, so a generous per-PoC budget cannot run away.
88
+ fileContents = null, maxCandidates = DEFAULT_MAX, timeoutMs = DEFAULT_PROOF_TIMEOUT_MS,
89
+ totalBudgetMs = DEFAULT_TOTAL_BUDGET_MS, env = process.env, now = Date.now,
90
+ } = {}) {
91
+ const summary = {
92
+ enabled: false, attempted: 0, proven: 0, failed: 0, inconclusive: 0,
93
+ skipped: 0, capped: 0, budgetExhausted: 0, reason: null,
94
+ };
95
+ if (!Array.isArray(findings) || !findings.length) return summary;
96
+ if (!proveEnabled(env)) {
97
+ summary.reason = 'not enabled (set AGENTIC_SECURITY_PROVE=1)';
98
+ return summary;
99
+ }
100
+ if (!sandboxAvailable()) {
101
+ // Deliberately not an error: an unavailable confinement primitive means
102
+ // execution features switch OFF, per R1's constraint.
103
+ summary.reason = 'no confinement backend available; execution proof disabled';
104
+ return summary;
105
+ }
106
+ summary.enabled = true;
107
+
108
+ const read = (file) => {
109
+ if (!fileContents) return null;
110
+ if (typeof fileContents.get === 'function') return fileContents.get(file) ?? null;
111
+ return fileContents[file] ?? null;
112
+ };
113
+
114
+ const candidates = [];
115
+ for (const f of findings) {
116
+ if (!f || typeof f !== 'object') continue;
117
+ const content = read(f.file);
118
+ const syn = synthesizeInProcessPoc(f, content);
119
+ if (!syn.ok) { summary.skipped++; continue; }
120
+ candidates.push({ finding: f, poc: syn.poc, content });
121
+ }
122
+
123
+ if (candidates.length > maxCandidates) {
124
+ summary.capped = candidates.length - maxCandidates;
125
+ candidates.length = maxCandidates;
126
+ }
127
+
128
+ const startedAt = now();
129
+ for (const c of candidates) {
130
+ // Checked BEFORE each call, since a call in flight cannot be interrupted.
131
+ // Reported, never silent: findings left unproven because the budget ran out
132
+ // are a different statement from findings that could not be proved.
133
+ if (now() - startedAt >= totalBudgetMs) {
134
+ summary.budgetExhausted = candidates.length - summary.attempted;
135
+ break;
136
+ }
137
+ summary.attempted++;
138
+ // The PoC imports the vulnerable file, so it must exist in the sandbox
139
+ // root alongside it.
140
+ const files = mergePocFiles(c.poc, c.content);
141
+ let proved;
142
+ try {
143
+ proved = await proveFinding({ ...c.finding, poc: c.poc }, { files, timeoutMs });
144
+ } catch (e) {
145
+ summary.inconclusive++;
146
+ continue;
147
+ }
148
+ c.finding.poc = c.poc;
149
+ c.finding.proofTier = proved.proofTier;
150
+ c.finding.proofEvidence = proved.proofEvidence;
151
+ if (proved.proofTier === 'execution-proven') summary.proven++;
152
+ else if (proved.proofTier === 'proof-failed') summary.failed++;
153
+ else summary.inconclusive++;
154
+ }
155
+ return summary;
156
+ }
157
+
158
+ /** One-line human summary; null when the feature did not run. */
159
+ export function renderProofSummary(s) {
160
+ if (!s || !s.enabled) return null;
161
+ const bits = [`${s.proven} execution-proven of ${s.attempted} attempted`];
162
+ if (s.failed) bits.push(`${s.failed} ran without demonstrating the bug (triage signal, NOT a false-positive verdict)`);
163
+ if (s.inconclusive) bits.push(`${s.inconclusive} inconclusive`);
164
+ if (s.capped) bits.push(`${s.capped} eligible finding(s) NOT attempted (per-scan cap)`);
165
+ if (s.budgetExhausted) {
166
+ bits.push(`${s.budgetExhausted} eligible finding(s) NOT attempted (aggregate time budget exhausted) — `
167
+ + 'unproven here means unattempted, not unprovable');
168
+ }
169
+ return bits.join('; ') + '.';
170
+ }
171
+
172
+ export const _internals = { DEFAULT_MAX, DEFAULT_TOTAL_BUDGET_MS };
@@ -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 };
@@ -3,6 +3,7 @@ import * as crypto from 'node:crypto';
3
3
  import { _isCustomSuppressed } from '../engine.js';
4
4
  import { alertFace, approveFace } from './mascot.js';
5
5
  import { SCANNER_VERSION } from '../posture/version.js';
6
+ import { proofBlock } from '../posture/proof-artifact.js';
6
7
 
7
8
  const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
8
9
  const SEV_TO_SARIF = { critical: 'error', high: 'error', medium: 'warning', low: 'note', info: 'none' };
@@ -412,6 +413,13 @@ export function toJSON(scan, meta={}, opts={}){
412
413
  // It carries its own `proves` / `doesNotProve` statement — do not quote
413
414
  // the digest as cross-machine reproducibility, which it is not.
414
415
  attestation: scan.attestation || null,
416
+ // Coverage reduction, surfaced in the ARTIFACT. `disable:` in rules.yml
417
+ // removes findings from the report, and a removed finding is
418
+ // indistinguishable from clean code unless the removal is stated. Null when
419
+ // nothing was suppressed, so consumers can omit the section rather than
420
+ // render an empty one. An AUTHORISED suppression is reported too — the
421
+ // signature proves who asked for it, not that the results are absent.
422
+ suppressedRules: scan.suppressedRules || null,
415
423
  _scanMeta: scan._scanMeta || null,
416
424
  };
417
425
  if (opts.includeSuppressed) out.suppressed = scan.suppressions||[];
@@ -702,6 +710,14 @@ export function toSARIF(scan, meta={}){
702
710
  // (verified | unsigned | pass-through). The legacy bool flags are
703
711
  // emitted alongside for one release of grace so existing dashboards
704
712
  // don't break; new integrations should switch to signatureStatus.
713
+ // PRD Epic 1.4 / 7.4 — the proof block. `proofLevel` is the
714
+ // reader-facing vocabulary (PROVEN / PROBABLE_FP / REACHABLE /
715
+ // PATTERN); `proofArtifactSha256` commits to the evidence that
716
+ // justified it, so a fix PR can reference the artifact it was
717
+ // reviewed against. Omitted entirely when the proof stage did not
718
+ // run — labelling every finding PATTERN would assert each was
719
+ // considered and found unprovable.
720
+ ...(proofBlock(f) || {}),
705
721
  signatureStatus: f.signatureStatus || (f._passThroughSigning ? 'pass-through' : (f._unsigned ? 'unsigned' : 'verified')),
706
722
  ...(f._unsigned ? { unsigned: true } : {}),
707
723
  ...(f._passThroughSigning ? { passThroughSigning: true } : {}),
@@ -164,11 +164,33 @@ write succeeds, out-of-root write is blocked and creates no file, a denied
164
164
  write is not reported as a clean run, the confined process cannot rebind the
165
165
  filesystem writable again, an ordinary non-zero exit stays `nonzero` rather
166
166
  than `blocked`, the parent environment is not handed over, outbound network is
167
- blocked, and a wall-clock overrun stops the direct child.
168
-
169
- Two limits carry over unchanged and are **not** claims this verification
170
- retires. The wall-clock case stops the *direct child*, not the process tree
171
- the same caveat the userspace backend carries. And this is one kernel and one
167
+ blocked, and the wall-clock behaviour is pinned as a KNOWN GAP (see below —
168
+ the timeout does not actually stop the payload here).
169
+
170
+ **The timeout: two wrong claims, settled by two CI runs.** This guide once
171
+ carried the userspace caveat verbatim ("stops the direct child, not the process
172
+ tree") and `backend-namespace.js` went further, reasoning that killing pid 1 of a
173
+ PID namespace would reap everything and beat userspace. A test was added to check
174
+ rather than assume, and CI corrected it twice:
175
+
176
+ 1. With the default **SIGTERM** the timeout did nothing: a 1200 ms budget against
177
+ a payload sleeping 30 s returned after `30057 ms`, payload run to completion.
178
+ The kernel drops default-action signals sent to a PID namespace's pid 1 from
179
+ outside it.
180
+ 2. With **SIGKILL** — which cannot be ignored — the call returns in about 1.2 s,
181
+ so the direct child IS bounded. A backgrounded grandchild still survived and
182
+ wrote its marker.
183
+
184
+ **Settled behaviour: SIGKILL bounds the direct child promptly; it does not reap
185
+ the process tree.** That is the same limitation the userspace backend carries —
186
+ not better, which is what this module claimed for a long time. Confinement is
187
+ unaffected: survivors stay inside the mount and network namespaces and can
188
+ neither write out of root nor reach the network. What is missing is a bound on
189
+ how long descendants run, so a caller needing one must impose it itself
190
+ (`posture/prove-findings.js` does). Pinned by "KNOWN GAP: the timeout bounds the
191
+ direct child but does NOT reap the tree", which fails in both directions.
192
+
193
+ One further limit is unchanged: this is one kernel and one
172
194
  image: a different kernel is a different host fact, which is exactly why the
173
195
  job runs per push rather than being recorded once and trusted forever.
174
196
 
@@ -62,17 +62,32 @@
62
62
  // expectation that "the remount should have worked" is exactly the class of
63
63
  // claim this module exists to refuse.
64
64
  //
65
- // TIMEOUT SCOPE. The wall-clock timeout is `spawnSync`'s, which signals only
66
- // the direct child. On this backend the direct child is the namespace tool
67
- // running as pid 1 of a new PID namespace (`--pid --fork`), so killing it is
68
- // expected to take the whole namespace's processes with it better than the
69
- // userspace backend, where a backgrounded grandchild demonstrably survives.
70
- // "Expected", NOT verified, and this one did not clear with the rest: the
71
- // escape suite has now RUN and passed on a Linux runner, but its wall-clock
72
- // case asserts only that the DIRECT CHILD is stopped. No test observes whether
73
- // a backgrounded grandchild dies with the PID namespace, so tree-kill remains
74
- // a reasoned expectation. Do not state it as a guarantee until a test asserts
75
- // the grandchild is gone.
65
+ // TIMEOUT SCOPE SETTLED BY TWO CI RUNS, AFTER TWO WRONG CLAIMS.
66
+ //
67
+ // This comment asserted for a long time that killing the direct child would
68
+ // reap the whole namespace, since that child is pid 1 of a new PID namespace
69
+ // (`--pid --fork`) and that this made the backend BETTER than the userspace
70
+ // one. A test was written to check rather than assume. What CI actually found,
71
+ // in two rounds:
72
+ //
73
+ // 1. With the default SIGTERM the timeout did nothing at all: a 1200 ms
74
+ // budget against a payload sleeping 30 s returned after `30057 ms`, having
75
+ // run the payload to completion. The kernel does not deliver
76
+ // default-action signals to a PID namespace's pid 1 from outside it, so
77
+ // with no handler installed SIGTERM is simply dropped.
78
+ // 2. With `killSignal: 'SIGKILL'` — which cannot be ignored — the call
79
+ // returns in about 1.2 s. The DIRECT CHILD is bounded. But a backgrounded
80
+ // grandchild still outlived it and wrote its marker, so the PID namespace
81
+ // does NOT reap the tree here.
82
+ //
83
+ // Settled: SIGKILL bounds the direct child promptly; it does not kill the tree.
84
+ // That is the SAME limitation the userspace backend carries, not an improvement
85
+ // on it. Confinement is unaffected — survivors remain inside the mount and
86
+ // network namespaces and can neither write out of root nor reach the network —
87
+ // but there is no bound on how long descendants run, and any caller needing one
88
+ // must impose it (see `posture/prove-findings.js`, which does). Pinned by
89
+ // "KNOWN GAP: the timeout bounds the direct child but does NOT reap the tree"
90
+ // in `sandbox-escape.test.js`, which fails in both directions.
76
91
  //
77
92
  // PRIVILEGE. Creating mount/PID/IPC/UTS/network namespaces directly requires
78
93
  // CAP_SYS_ADMIN, which an ordinary CI account does not have — asking for them
@@ -293,6 +308,19 @@ export function runNamespace(argv, {
293
308
  {
294
309
  encoding: 'utf8',
295
310
  timeout: timeoutMs,
311
+ // SIGKILL, not the SIGTERM default, and this is the whole reason the
312
+ // timeout did not work here. The direct child is pid 1 of a new PID
313
+ // namespace (`--pid --fork`), and the kernel does not deliver
314
+ // default-action signals to a namespace's pid 1 from outside it — a
315
+ // process with no handler installed for SIGTERM simply does not die.
316
+ // SIGKILL is the one signal that cannot be ignored or blocked, so it
317
+ // is the only signal that can bound a payload here.
318
+ //
319
+ // Found by CI, not by reasoning: the first Linux run of the tree-kill
320
+ // test recorded duration_ms 30057 against a 1200 ms budget with the
321
+ // payload run to completion. The comment above this function used to
322
+ // claim the opposite.
323
+ killSignal: 'SIGKILL',
296
324
  maxBuffer,
297
325
  cwd: resolvedRoot,
298
326
  env: {