@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.
- package/CHANGELOG.md +247 -0
- package/bin/agentic-security.js +39 -3
- package/dist/113.index.js +294 -5
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +7 -4
- package/dist/238.index.js +218 -0
- package/dist/259.index.js +975 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +2 -2
- package/dist/526.index.js +294 -5
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +18 -57
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +19 -10
- package/src/engine.js +48 -1
- package/src/ir/parser-js.js +8 -0
- package/src/llm-validator/cost-ceiling.js +199 -0
- package/src/llm-validator/index.js +241 -12
- package/src/llm-validator/local-endpoint.js +90 -0
- package/src/mcp/tools.js +2 -2
- package/src/posture/CLAUDE.md +83 -6
- package/src/posture/accuracy-scorecard.js +37 -6
- package/src/posture/attestation.js +7 -4
- package/src/posture/corpus-enroll.js +303 -0
- package/src/posture/corpus-match.js +67 -0
- package/src/posture/custom-rules.js +2 -2
- package/src/posture/execution-proof.js +44 -4
- package/src/posture/fix-metrics.js +197 -0
- package/src/posture/fix-verify.js +76 -2
- package/src/posture/integrity.js +42 -9
- package/src/posture/learning.js +8 -1
- package/src/posture/model-routing.js +26 -0
- package/src/posture/model-trust.js +174 -0
- package/src/posture/poc-inprocess.js +165 -0
- package/src/posture/prove-findings.js +148 -0
- package/src/posture/root-cause-sweep.js +0 -0
- package/src/posture/rule-overrides.js +64 -3
- package/src/posture/state-dir.js +25 -0
- package/src/posture/vuln-archaeology.js +231 -0
- package/src/report/index.js +7 -0
- package/src/runScan.js +2 -6
- package/src/sandbox/CLAUDE.md +190 -46
- package/src/sandbox/backend-namespace.js +328 -48
- package/src/sandbox/backend-userspace.js +6 -19
- package/src/sandbox/capabilities.js +132 -4
- package/src/sandbox/limits.js +21 -0
- package/src/sandbox/result.js +1 -1
- package/src/sast/CLAUDE.md +4 -0
- package/src/sast/crypto-specialist.js +247 -0
- 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
|
-
|
|
124
|
-
|
|
125
|
-
.
|
|
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[] }
|
package/src/posture/state-dir.js
CHANGED
|
@@ -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 };
|
package/src/report/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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) {
|