@clear-capabilities/agentic-security-scanner 0.132.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 +125 -0
- package/bin/agentic-security.js +20 -1
- package/dist/113.index.js +3 -3
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/526.index.js +3 -3
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +22 -22
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +4 -3
- package/src/engine.js +32 -1
- 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/posture/accuracy-scorecard.js +37 -6
- package/src/posture/corpus-match.js +29 -14
- 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/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/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
|
@@ -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/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: {
|
|
@@ -73,6 +73,10 @@ export function runUserspace(argv, {
|
|
|
73
73
|
{
|
|
74
74
|
encoding: 'utf8',
|
|
75
75
|
timeout: timeoutMs,
|
|
76
|
+
// Match the namespace backend: SIGKILL cannot be ignored, SIGTERM can.
|
|
77
|
+
// A payload that installs a SIGTERM handler would otherwise outlive its
|
|
78
|
+
// own budget while the caller is told it timed out.
|
|
79
|
+
killSignal: 'SIGKILL',
|
|
76
80
|
maxBuffer,
|
|
77
81
|
cwd: resolvedRoot,
|
|
78
82
|
env: buildConfinedEnv({ root: resolvedRoot, env }),
|
package/src/sast/CLAUDE.md
CHANGED
|
@@ -25,6 +25,10 @@ SAST detector modules. Each file exports one or more `scan*()` functions returni
|
|
|
25
25
|
|
|
26
26
|
**Cross-cutting vuln classes** — `authz.js`, `csrf.js` (POST/PUT/PATCH/DELETE state-changing routes without CSRF defence; defence-aware suppression covers Express/Fastify/Flask/Django/FastAPI/Spring/Symfony **and Go (gin/echo/mux), Rails routes, ASP.NET MVC** — recognizes `gorilla/csrf`/`protect_from_forgery`/`[ValidateAntiForgeryToken]` defences and exempts token-auth (`[ApiController]`, Bearer scheme); bare ASP.NET `[Authorize]` still flags as cookie auth is CSRF-vulnerable), `code-injection-multilang.js` (CWE-94 for Java/C#/Go/Kotlin — dynamic code/expression evaluators on a NON-LITERAL argument: javax.script `eval`, GroovyShell, Spring SpEL `parseExpression`, MVEL/OGNL, Roslyn `CSharpScript`, `DataTable.Compute`, yaegi `interp.Eval`, `text/template` Parse of a user-controlled body; literal arguments don't match. JS/Python/Ruby eval stay with the flow engine + per-language modules), `csv-injection.js` (formula injection into spreadsheet cells, CWE-1236), `secret-concat.js` (language-agnostic hardcoded-secret SPLIT across concatenated literals — `'AKIA' + 'IOSF…'` / `'ghp' + '_…'` / `'sk' + '_live_…'` — reassembled and matched against provider prefixes; complements the contiguous-token secrets scanner and the C#-only split-concat rule), `host-header.js`, `jndi.js`, `jwt-exp.js`, `ldap-injection.js` (CWE-90 across JS/Java/Python **and** PHP/Go/C#/Ruby/Kotlin — filter built by concat/interpolation; an inline call-guard and a file-level escape-API guard suppress `ldap_escape`/`EscapeFilter`/`escape_filter_chars`/`Net::LDAP::Filter`/`EqualityFilter` forms), `xpath-injection.js` (CWE-643 across Java/Python/JS **and** PHP/Go/Ruby/C#/Kotlin — XPath expression built by concat/interpolation: `DOMXPath->query`, `SelectNodes`, Nokogiri `.xpath`, htmlquery/xmlpath, `XPath.compile`; embedded-quote-tolerant literal matching; parameterized/variable-bound APIs and static literals don't match), `mass-assignment.js`, `mutation-xss.js`, `nosql-injection.js`, `prototype-pollution.js`, `response-splitting.js` (CWE-113 CRLF/header injection across JS/Python/Java/PHP/Go/Ruby/C#/Kotlin — a response header value set from a request source without stripping CR/LF; recognizes CRLF-strip sanitizers — `.replace(/[\r\n]/)`, chained `.replace("\r")`, Ruby `gsub`/`delete`, Go `strings.NewReplacer`, PHP `str_replace` — and a request-scope param heuristic for the JVM/C# single-file shape), `ssrf-cloud-metadata.js`, `xss-reflected-multilang.js` (cross-language reflected XSS for Go/Ruby/PHP/C#/Kotlin/Java — user input written into an HTML response via concat/interpolation, e.g. Java servlet `response.getWriter().write("<…" + q)`, with a per-language escaper exclusion so `htmlspecialchars`/`HtmlEncode`/`template.HTMLEscapeString`/ERB `<%= %>`/OWASP `Encode.forHtml` forms don't match; JS/Python XSS stays with the flow engine + framework structural detectors), `stored-taint.js` (second-order / stored injection — **opt-in** via `AGENTIC_SECURITY_STORED_TAINT=1`), `toctou.js`, `wrong-context-sanitizer.js` (HTML-entity encoder used in a URL context — wrong-context output encoding, CWE-79), `zip-slip.js`, `file-upload.js` (CWE-434 unrestricted file upload for JS/Python — Multer configured with no `fileFilter`/`limits`, and a write whose destination is built from the client-supplied filename (`originalname`/`req.files.*.name`/`.filename`); suppressed by a `basename`/uuid/`secure_filename`/sanitizer in the window).
|
|
27
27
|
|
|
28
|
+
**Specialist audit classes (R16)** — `crypto-specialist.js`. Narrow, high-credibility crypto-hygiene rules that no injection detector will ever find, because nothing is tainted: every value is already trusted and the defect is in how it is *handled*. Two classes, each chosen because it has an unambiguous correct form to point at. **CWE-208 non-constant-time comparison** — a secret compared with `===`/`.equals()`/`memcmp` short-circuits on the first differing byte, leaking how many leading bytes matched; silent whenever the line already uses `timingSafeEqual`/`compare_digest`/`ConstantTimeCompare`/`hash_equals`/`MessageDigest.isEqual`/`FixedTimeEquals`/`CRYPTO_memcmp`. **CWE-316 non-zeroizable secret material** — a Java `String` password (immutable, so it survives in the heap until GC and lands in heap dumps), and a C `memset(secret, 0, …)` that nothing reads afterwards (a dead store an optimiser may delete, which is why `explicit_bzero`/`memset_s` exist); a file already using a guaranteed wipe is left alone.
|
|
29
|
+
|
|
30
|
+
Both key on the SECRET-NESS of the identifier, never on the comparison or the type alone — `if (a === b)` and `String name` are not findings. Length checks (`sig.length === 64`) and sentinel presence checks (`apiKey === null`) are excluded: the first leaks nothing an attacker cannot already measure, and the second is not a secret comparison at all. Keep it that way. A noisy specialist rule is worse than none, because it teaches people to ignore the whole class.
|
|
31
|
+
|
|
28
32
|
**Cloud/infra** — `db-rls.js` (Supabase RLS), `env-hygiene.js` (NEXT_PUBLIC_ leaks, .env.example real values), `mobile-manifest.js`, `pipeline.js` (CI/CD integrity), `rate-limit.js`, `webhook.js`.
|
|
29
33
|
|
|
30
34
|
**LLM / agent** — `llm.js`, `llm-owasp.js`, `llm-trading-agent.js`, `mcp-audit.js`, `model-load.js`, `prompt-firewall.js`, `prompt-template.js`.
|