@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.
- package/CHANGELOG.md +228 -0
- package/bin/agentic-security.js +103 -1
- package/dist/113.index.js +3 -3
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/499.index.js +86 -0
- package/dist/526.index.js +3 -3
- package/dist/609.index.js +741 -0
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +56 -56
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +9 -4
- package/src/discovery/CLAUDE.md +38 -0
- package/src/discovery/confirm.js +47 -0
- package/src/discovery/disprove.js +79 -0
- package/src/discovery/hunter.js +116 -0
- package/src/discovery/index.js +159 -0
- package/src/discovery/judge.js +97 -0
- package/src/discovery/lenses.js +69 -0
- package/src/discovery/llm-invoke.js +31 -0
- package/src/discovery/partition.js +92 -0
- package/src/engine.js +151 -1
- package/src/llm-validator/cost-ceiling.js +199 -0
- package/src/llm-validator/index.js +254 -35
- package/src/llm-validator/local-endpoint.js +90 -0
- package/src/llm-validator/providers.js +227 -0
- package/src/posture/CLAUDE.md +76 -0
- package/src/posture/accuracy-scorecard.js +37 -6
- package/src/posture/autopilot.js +225 -0
- package/src/posture/comparison.js +181 -0
- package/src/posture/corpus-match.js +29 -14
- package/src/posture/execution-proof.js +25 -1
- package/src/posture/fleet.js +0 -0
- package/src/posture/integrity.js +42 -9
- package/src/posture/learning.js +8 -1
- package/src/posture/logic-claims.js +266 -0
- package/src/posture/model-routing.js +26 -0
- package/src/posture/model-trust.js +174 -0
- package/src/posture/poc-inprocess.js +567 -0
- package/src/posture/proof-artifact.js +101 -0
- package/src/posture/prove-findings.js +172 -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 +16 -0
- package/src/sandbox/CLAUDE.md +27 -5
- package/src/sandbox/backend-namespace.js +39 -11
- package/src/sandbox/backend-userspace.js +4 -0
- package/src/sast/CLAUDE.md +4 -0
- 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
|
-
|
|
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
|
@@ -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 } : {}),
|
package/src/sandbox/CLAUDE.md
CHANGED
|
@@ -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
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
// a
|
|
75
|
-
// the
|
|
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: {
|