@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
package/src/posture/integrity.js
CHANGED
|
@@ -33,16 +33,23 @@ function _keyDir() {
|
|
|
33
33
|
}
|
|
34
34
|
function _keyPath() { return path.join(_keyDir(), 'scan-key'); }
|
|
35
35
|
|
|
36
|
+
// Where the active key came from. A signature is only as meaningful as the key
|
|
37
|
+
// behind it, and `env` means "whoever set the environment could sign this" —
|
|
38
|
+
// a reader of a scan artifact deserves to know which case they are in.
|
|
39
|
+
let _keySource = null;
|
|
40
|
+
export function keyProvenance() { return _keySource || 'unresolved'; }
|
|
41
|
+
|
|
36
42
|
function _readOrGenerateKey() {
|
|
37
43
|
const fromEnv = process.env.AGENTIC_SECURITY_HMAC_KEY;
|
|
38
44
|
if (fromEnv && /^[0-9a-fA-F]{32,}$/.test(fromEnv.trim())) {
|
|
45
|
+
_keySource = 'env';
|
|
39
46
|
return Buffer.from(fromEnv.trim(), 'hex');
|
|
40
47
|
}
|
|
41
48
|
const fp = _keyPath();
|
|
42
49
|
try {
|
|
43
50
|
if (fs.existsSync(fp)) {
|
|
44
51
|
const hex = fs.readFileSync(fp, 'utf8').trim();
|
|
45
|
-
if (/^[0-9a-fA-F]{32,}$/.test(hex)) return Buffer.from(hex, 'hex');
|
|
52
|
+
if (/^[0-9a-fA-F]{32,}$/.test(hex)) { _keySource = 'per-install'; return Buffer.from(hex, 'hex'); }
|
|
46
53
|
}
|
|
47
54
|
} catch { /* fall through to generate */ }
|
|
48
55
|
// Generate, mode 0600.
|
|
@@ -50,13 +57,39 @@ function _readOrGenerateKey() {
|
|
|
50
57
|
try {
|
|
51
58
|
fs.mkdirSync(_keyDir(), { recursive: true, mode: 0o700 });
|
|
52
59
|
fs.writeFileSync(fp, buf.toString('hex') + '\n', { mode: 0o600 });
|
|
53
|
-
|
|
60
|
+
_keySource = 'per-install-new';
|
|
61
|
+
} catch {
|
|
62
|
+
// Could not persist — this key lives for this process only, so nothing
|
|
63
|
+
// signed with it will verify on any later run. Callers must be able to see
|
|
64
|
+
// that, or a permanently-unverifiable signature looks like a valid one.
|
|
65
|
+
_keySource = 'ephemeral';
|
|
66
|
+
}
|
|
54
67
|
return buf;
|
|
55
68
|
}
|
|
56
69
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
70
|
+
// REMOVED (2026-08-08): the legacy hostname-derived key.
|
|
71
|
+
//
|
|
72
|
+
// It was `sha256(_HMAC_SALT + ':' + os.hostname())`. `_HMAC_SALT` is a constant
|
|
73
|
+
// in published, npm-shipped source and a hostname is not a secret — it appears
|
|
74
|
+
// in CI logs, build artifacts and error messages. So the "signature" could be
|
|
75
|
+
// forged by anyone who knew the target's hostname, which is to say by anyone.
|
|
76
|
+
//
|
|
77
|
+
// This was known. The 0.62.0 changelog introduced the per-install key precisely
|
|
78
|
+
// because the old one was "hostname-derived and publicly forgeable in CI /
|
|
79
|
+
// containers", and kept verification of the legacy key "for one release to
|
|
80
|
+
// migrate existing signed scans". The comment here said "Remove after one minor
|
|
81
|
+
// release." It was still accepted at 0.132.0 — SEVENTY minor releases later.
|
|
82
|
+
//
|
|
83
|
+
// What it cost: `rule-overrides.js` gates the `disable:` list on
|
|
84
|
+
// `verifyLastScan`, so a forged signature silently switched off arbitrary
|
|
85
|
+
// detectors and the scan reported clean. Demonstrated end to end before removal:
|
|
86
|
+
// with a hostname-forged `rules.yml.sig`, a command-injection finding went from
|
|
87
|
+
// 1 reported to 0.
|
|
88
|
+
//
|
|
89
|
+
// A migration window that nobody closes is not a migration window; it is the
|
|
90
|
+
// vulnerability, kept on purpose. Signatures made under the legacy key no longer
|
|
91
|
+
// verify — that is the intended consequence. Re-sign with `agentic-security
|
|
92
|
+
// rules sign`.
|
|
60
93
|
|
|
61
94
|
let _cachedKey = null;
|
|
62
95
|
function _key() {
|
|
@@ -85,13 +118,13 @@ export function verifyLastScan(body, sigFile) {
|
|
|
85
118
|
return crypto.timingSafeEqual(Buffer.from(stored, 'hex'), Buffer.from(expected, 'hex'));
|
|
86
119
|
} catch { return false; }
|
|
87
120
|
};
|
|
121
|
+
// ONE key. There is deliberately no fallback: a second accepted key is a
|
|
122
|
+
// second thing that can be forged, and the last one was forgeable by anyone
|
|
123
|
+
// who could read a hostname.
|
|
88
124
|
if (tryKey(_key())) return true;
|
|
89
|
-
// Legacy hostname-key path — accepted for verification only, not for new
|
|
90
|
-
// signatures. Remove after one minor release.
|
|
91
|
-
if (tryKey(_legacyHostnameKey())) return true;
|
|
92
125
|
return false;
|
|
93
126
|
}
|
|
94
127
|
|
|
95
128
|
// Test-only helpers (premortem-tracked):
|
|
96
|
-
export function _resetKeyCacheForTests() { _cachedKey = null; }
|
|
129
|
+
export function _resetKeyCacheForTests() { _cachedKey = null; _keySource = null; }
|
|
97
130
|
export function _keyFilePathForTests() { return _keyPath(); }
|
package/src/posture/learning.js
CHANGED
|
@@ -90,7 +90,14 @@ export function applyFeedback(scanRoot, findings) {
|
|
|
90
90
|
if (process.env.AGENTIC_SECURITY_LEARN !== '1') return { kept: findings, suppressed };
|
|
91
91
|
const data = loadFeedback(scanRoot);
|
|
92
92
|
if (!data.entries || !data.entries.length) return { kept: findings, suppressed };
|
|
93
|
-
|
|
93
|
+
// Floor of 2, not 1. A quorum of 1 means a SINGLE triage verdict suppresses a
|
|
94
|
+
// finding — and because suppression also matches on `family + filePattern`
|
|
95
|
+
// below, one verdict can silence a whole family across a path. That is
|
|
96
|
+
// precisely the case the root CLAUDE.md warns about ("think about what a
|
|
97
|
+
// malicious-PR-author could suppress"), and clamping to 1 let an env var
|
|
98
|
+
// request it. A value below 2 is now raised to 2 rather than honoured.
|
|
99
|
+
const _requested = parseInt(process.env.AGENTIC_SECURITY_LEARN_QUORUM || '2', 10);
|
|
100
|
+
const quorum = Number.isFinite(_requested) ? Math.max(2, _requested) : 2;
|
|
94
101
|
// Keep the most recent 500 entries by `at`.
|
|
95
102
|
const sorted = [...data.entries].sort((a, b) => String(a.at || '').localeCompare(String(b.at || ''))).slice(-500);
|
|
96
103
|
const fpCountById = new Map();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { applyMeasuredTrust } from './model-trust.js';
|
|
1
2
|
// Capability-based model routing for cost-sensitive subagent dispatch.
|
|
2
3
|
//
|
|
3
4
|
// A declarative CWE/severity → model policy. When the orchestrator is about to
|
|
@@ -105,6 +106,31 @@ export function routeModelForFinding(finding) {
|
|
|
105
106
|
reason: `${cwe ? `${cwe} at ` : ''}${severity || 'low'} severity is a simple / hardening class — ${LABEL[MODEL_CHEAPEST]} at low effort.` };
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
// R13 — measured trust, layered over the capability policy above.
|
|
110
|
+
//
|
|
111
|
+
// The policy is a set of hand-written beliefs about which classes are hard.
|
|
112
|
+
// This lets a class be downgraded to the cheapest model ONLY once that model's
|
|
113
|
+
// measured miss rate clears a Wilson 95% upper bound for that class. The
|
|
114
|
+
// capability route is the floor: measured evidence can permit a downgrade the
|
|
115
|
+
// policy already allows, never promote a class the policy thinks is hard.
|
|
116
|
+
//
|
|
117
|
+
// Without a ledger the behaviour is byte-identical to before — no evidence is
|
|
118
|
+
// not permission, and the default remains the stronger model.
|
|
119
|
+
export function trustKeyFor(finding) {
|
|
120
|
+
return `${MODEL_CHEAPEST}::${parseCwe(finding?.cwe) || 'no-cwe'}::${(finding?.severity || 'unknown').toLowerCase()}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function routeModelWithTrust(finding, ledger = null) {
|
|
124
|
+
const route = routeModelForFinding(finding);
|
|
125
|
+
if (!ledger || route.model === MODEL_CHEAPEST) return { ...route, trust: null };
|
|
126
|
+
return applyMeasuredTrust(
|
|
127
|
+
route,
|
|
128
|
+
{ model: MODEL_CHEAPEST, effort: 'low' },
|
|
129
|
+
ledger,
|
|
130
|
+
trustKeyFor(finding),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
108
134
|
// Route a list of findings. Returns [{ finding, model, effort, reason }, …].
|
|
109
135
|
export function routeModelForFindings(findings) {
|
|
110
136
|
const list = Array.isArray(findings) ? findings : [];
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// R13 — multi-model routing gated on MEASURED trust.
|
|
2
|
+
//
|
|
3
|
+
// `model-routing.js` already routes by capability: crypto and auth go to the
|
|
4
|
+
// strongest model, hardening to the cheapest. That policy is a set of
|
|
5
|
+
// hand-written beliefs about which classes are hard. It has never been checked
|
|
6
|
+
// against what the cheap model actually gets wrong.
|
|
7
|
+
//
|
|
8
|
+
// This module supplies the check. A decision class may be downgraded to a
|
|
9
|
+
// cheaper model only once that model's measured MISS RATE clears a statistical
|
|
10
|
+
// bound — specifically the Wilson 95% UPPER bound, not the point estimate.
|
|
11
|
+
//
|
|
12
|
+
// WHY THE UPPER BOUND, AND WHY THAT IS THE WHOLE IDEA. "0 misses in 5 samples"
|
|
13
|
+
// has a point estimate of 0% and looks perfect. Its Wilson upper bound is
|
|
14
|
+
// about 52%: the data is equally consistent with a model that misses half the
|
|
15
|
+
// time. Routing on the point estimate would let five lucky samples hand a
|
|
16
|
+
// security decision to a model that fails constantly. The upper bound is what
|
|
17
|
+
// converts "we have not seen it fail" into "we have enough evidence that it
|
|
18
|
+
// rarely fails", and it is precisely what makes small-n evidence unusable
|
|
19
|
+
// rather than flattering. Small n cannot clear a tight threshold at all — that
|
|
20
|
+
// is the feature, not a limitation to engineer around.
|
|
21
|
+
//
|
|
22
|
+
// FAIL CLOSED. No evidence for a class means no downgrade. The expensive model
|
|
23
|
+
// is the safe default, so absence of data must never read as permission. This
|
|
24
|
+
// is the same rule as everywhere else here: an unverified check is not a pass.
|
|
25
|
+
//
|
|
26
|
+
// A MISS IS ASYMMETRIC. `record()` takes agreement with the trusted model, and
|
|
27
|
+
// what is counted is DISAGREEMENT WHERE THE CHEAP MODEL WAS WRONG — a missed
|
|
28
|
+
// true positive. A cheap model that is merely noisier (extra false positives)
|
|
29
|
+
// costs triage time; one that misses real vulnerabilities costs a breach. Only
|
|
30
|
+
// the second gates routing.
|
|
31
|
+
|
|
32
|
+
// STATUS — MECHANISM ONLY. Nothing in `src/` or `bin/` constructs a ledger, and
|
|
33
|
+
// nothing calls `record()`: there is no adjudication source comparing a cheap
|
|
34
|
+
// model's verdicts against a trusted model's, so no class ever accumulates
|
|
35
|
+
// evidence. The consequence is benign, because the design fails closed — with
|
|
36
|
+
// no observations every class stays on the stronger model — but this is a
|
|
37
|
+
// mechanism waiting for data, not a working routing capability. Do not describe
|
|
38
|
+
// it as one until something feeds it.
|
|
39
|
+
|
|
40
|
+
import { wilsonInterval } from './calibration.js';
|
|
41
|
+
|
|
42
|
+
// The default bar a cheap model must clear: its true miss rate must be below
|
|
43
|
+
// 5% with 95% confidence. Deliberately strict — this gates security decisions,
|
|
44
|
+
// and the cost of being wrong is a missed vulnerability, not a wasted token.
|
|
45
|
+
export const DEFAULT_MAX_MISS_RATE = 0.05;
|
|
46
|
+
|
|
47
|
+
// Below this, no bound is tight enough to matter and we refuse on principle
|
|
48
|
+
// rather than letting an unusually clean run through on a technicality.
|
|
49
|
+
export const MIN_SAMPLES = 30;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* An observation ledger, keyed by decision class.
|
|
53
|
+
*
|
|
54
|
+
* A "decision class" is whatever granularity routing decides at — this module
|
|
55
|
+
* does not impose one. `model-routing.js` decides by CWE family and severity,
|
|
56
|
+
* so `${model}::${cwe}` is the natural key there.
|
|
57
|
+
*/
|
|
58
|
+
export function createTrustLedger({ maxMissRate = DEFAULT_MAX_MISS_RATE, minSamples = MIN_SAMPLES } = {}) {
|
|
59
|
+
/** @type {Map<string, {n:number, misses:number}>} */
|
|
60
|
+
const classes = new Map();
|
|
61
|
+
|
|
62
|
+
function _get(key) {
|
|
63
|
+
let c = classes.get(key);
|
|
64
|
+
if (!c) { c = { n: 0, misses: 0 }; classes.set(key, c); }
|
|
65
|
+
return c;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
maxMissRate,
|
|
70
|
+
minSamples,
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Record one adjudicated decision.
|
|
74
|
+
* @param {string} key decision class
|
|
75
|
+
* @param {boolean} missed did the cheap model MISS something the trusted
|
|
76
|
+
* model caught? Extra false positives are not misses.
|
|
77
|
+
*/
|
|
78
|
+
record(key, missed) {
|
|
79
|
+
if (typeof key !== 'string' || !key) return false;
|
|
80
|
+
const c = _get(key);
|
|
81
|
+
c.n++;
|
|
82
|
+
if (missed) c.misses++;
|
|
83
|
+
return true;
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
observations(key) {
|
|
87
|
+
const c = classes.get(key);
|
|
88
|
+
return c ? { ...c } : { n: 0, misses: 0 };
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* May this class be routed to the cheaper model?
|
|
93
|
+
* @returns {{allowed:boolean, reason:string, n:number, misses:number,
|
|
94
|
+
* missRate:number|null, upperBound:number|null}}
|
|
95
|
+
*/
|
|
96
|
+
verdict(key) {
|
|
97
|
+
const { n, misses } = this.observations(key);
|
|
98
|
+
if (n < minSamples) {
|
|
99
|
+
return {
|
|
100
|
+
allowed: false, n, misses, missRate: n ? misses / n : null, upperBound: null,
|
|
101
|
+
reason: `only ${n} adjudicated sample(s) for '${key}'; ${minSamples} are required before a `
|
|
102
|
+
+ 'downgrade can be justified. No evidence is not permission — the stronger model stands.',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
// wilsonInterval is written for a SUCCESS count; the miss rate's interval
|
|
106
|
+
// is the same computation with misses as the successes.
|
|
107
|
+
const [, upper] = wilsonInterval(misses, n);
|
|
108
|
+
const missRate = misses / n;
|
|
109
|
+
if (upper > maxMissRate) {
|
|
110
|
+
return {
|
|
111
|
+
allowed: false, n, misses, missRate, upperBound: upper,
|
|
112
|
+
reason: `measured miss rate ${(missRate * 100).toFixed(1)}% over ${n} samples, but the 95% upper `
|
|
113
|
+
+ `bound is ${(upper * 100).toFixed(1)}% — above the ${(maxMissRate * 100).toFixed(1)}% bar. `
|
|
114
|
+
+ 'The point estimate is not the claim; the data is still consistent with a worse model.',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
allowed: true, n, misses, missRate, upperBound: upper,
|
|
119
|
+
reason: `${misses} miss(es) in ${n} samples; 95% upper bound ${(upper * 100).toFixed(1)}% is within the `
|
|
120
|
+
+ `${(maxMissRate * 100).toFixed(1)}% bar, so the cheaper model is justified for '${key}'.`,
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
/** Every class with its verdict, for reporting. */
|
|
125
|
+
report() {
|
|
126
|
+
const out = {};
|
|
127
|
+
for (const key of [...classes.keys()].sort()) out[key] = this.verdict(key);
|
|
128
|
+
return out;
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Apply measured trust on top of a capability route.
|
|
135
|
+
*
|
|
136
|
+
* The capability route is the FLOOR, never overridden upward by this module:
|
|
137
|
+
* measured trust can only permit a downgrade that policy already proposed, it
|
|
138
|
+
* cannot promote a class the policy thinks is hard. Evidence about a cheap
|
|
139
|
+
* model's miss rate says nothing about whether a hard class deserves a strong
|
|
140
|
+
* one.
|
|
141
|
+
*
|
|
142
|
+
* @param {object} route from routeModelForFinding: {model, effort, reason}
|
|
143
|
+
* @param {object} proposal the cheaper alternative: {model, effort}
|
|
144
|
+
* @param {object} ledger createTrustLedger()
|
|
145
|
+
* @param {string} key decision class
|
|
146
|
+
*/
|
|
147
|
+
export function applyMeasuredTrust(route, proposal, ledger, key) {
|
|
148
|
+
if (!route || !proposal || !ledger) return { ...route, trust: null };
|
|
149
|
+
const v = ledger.verdict(key);
|
|
150
|
+
if (!v.allowed) {
|
|
151
|
+
return {
|
|
152
|
+
...route,
|
|
153
|
+
trust: { ...v, applied: false },
|
|
154
|
+
reason: `${route.reason} Downgrade withheld: ${v.reason}`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
model: proposal.model,
|
|
159
|
+
effort: proposal.effort ?? route.effort,
|
|
160
|
+
trust: { ...v, applied: true },
|
|
161
|
+
reason: `Downgraded to ${proposal.model} on measured evidence: ${v.reason}`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** One-line summary of a trust report. */
|
|
166
|
+
export function renderTrustSummary(report) {
|
|
167
|
+
const keys = Object.keys(report || {});
|
|
168
|
+
if (!keys.length) return null;
|
|
169
|
+
const allowed = keys.filter(k => report[k].allowed);
|
|
170
|
+
const short = keys.filter(k => report[k].n < MIN_SAMPLES);
|
|
171
|
+
return `measured-trust routing: ${allowed.length}/${keys.length} class(es) cleared the `
|
|
172
|
+
+ `95% upper-bound bar; ${short.length} still below the ${MIN_SAMPLES}-sample minimum `
|
|
173
|
+
+ '(those keep the stronger model).';
|
|
174
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// In-process proof-of-concept synthesis (R2 — closing the automatic loop).
|
|
2
|
+
//
|
|
3
|
+
// WHY A SECOND POC KIND. `poc-generator.js` emits *HTTP* PoCs: they take a URL
|
|
4
|
+
// and a param and hit a running server. Those are the right artifact for a
|
|
5
|
+
// human reproducing a bug against a deployed app, but they cannot be executed
|
|
6
|
+
// by `execution-proof.js` — the sandbox denies network egress and there is no
|
|
7
|
+
// server inside it. That is the concrete reason nothing in the scan pipeline
|
|
8
|
+
// ever produced an `execution-proven` finding: the PoCs it generated were
|
|
9
|
+
// unrunnable *by construction* in the only place proofs are allowed to run.
|
|
10
|
+
//
|
|
11
|
+
// This module emits the other kind: a self-contained script that imports the
|
|
12
|
+
// vulnerable file directly, calls the exported handler with a crafted request,
|
|
13
|
+
// and lets the injected payload write the proof marker. No server, no network,
|
|
14
|
+
// no fixture discovery.
|
|
15
|
+
//
|
|
16
|
+
// DELIBERATELY NARROW. It refuses far more than it accepts, and that is the
|
|
17
|
+
// design rather than a gap to close later. A PoC that runs but proves nothing
|
|
18
|
+
// is worse than no PoC: `proof-failed` is a triage signal about the FINDING,
|
|
19
|
+
// so a template that misfires manufactures evidence against real bugs. Every
|
|
20
|
+
// refusal below is a case where the generated script would have been a guess.
|
|
21
|
+
//
|
|
22
|
+
// SAFETY. The payload is a shell redirect that creates a marker file inside
|
|
23
|
+
// the sandbox root (`> PROVEN`). It is the least destructive thing that still
|
|
24
|
+
// proves arbitrary command execution: no network, no writes outside the root,
|
|
25
|
+
// no privilege attempts, and it is only ever executed inside R1's sandbox with
|
|
26
|
+
// egress denied and the filesystem confined.
|
|
27
|
+
|
|
28
|
+
const MARKER = 'PROVEN';
|
|
29
|
+
|
|
30
|
+
// Only families where "the injected payload ran" is observable from a marker
|
|
31
|
+
// file. Others (XSS, weak crypto) need a browser or a judgement call about
|
|
32
|
+
// output, and a marker-file proof would be a category error.
|
|
33
|
+
const SUPPORTED = new Set(['command-injection', 'code-injection']);
|
|
34
|
+
|
|
35
|
+
const JS_EXT = /\.(js|cjs|mjs)$/i;
|
|
36
|
+
|
|
37
|
+
// A handler we can call: `module.exports = function (req, res)` or
|
|
38
|
+
// `module.exports.name = function (req, res)` / `exports.name = ...`.
|
|
39
|
+
// Arrow and function forms both count. The two-parameter (req, res) shape is
|
|
40
|
+
// required — a one-arg export is not an Express-style handler and calling it
|
|
41
|
+
// with a fake request would be inventing an interface.
|
|
42
|
+
const HANDLER_RES = [
|
|
43
|
+
/module\.exports\s*=\s*(?:async\s+)?function\s*\w*\s*\(\s*(\w+)\s*,\s*(\w+)\s*\)/,
|
|
44
|
+
/module\.exports\s*=\s*(?:async\s+)?\(\s*(\w+)\s*,\s*(\w+)\s*\)\s*=>/,
|
|
45
|
+
];
|
|
46
|
+
const NAMED_HANDLER_RES = [
|
|
47
|
+
/(?:module\.)?exports\.(\w+)\s*=\s*(?:async\s+)?function\s*\w*\s*\(\s*(\w+)\s*,\s*(\w+)\s*\)/,
|
|
48
|
+
/(?:module\.)?exports\.(\w+)\s*=\s*(?:async\s+)?\(\s*(\w+)\s*,\s*(\w+)\s*\)\s*=>/,
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
// The request property the handler reads. Anchored to the request identifier
|
|
52
|
+
// the export actually binds, so a file that reads `req.query` while exporting
|
|
53
|
+
// `(request, response)` does not produce a PoC built on the wrong name.
|
|
54
|
+
function _requestSource(content, reqIdent) {
|
|
55
|
+
const esc = reqIdent.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
56
|
+
for (const prop of ['query', 'body', 'params']) {
|
|
57
|
+
const re = new RegExp(`\\b${esc}\\.${prop}\\.(\\w+)`);
|
|
58
|
+
const m = content.match(re);
|
|
59
|
+
if (m) return { prop, key: m[1] };
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The sink must interpolate into a SHELL, not an argv array. `exec`/`execSync`
|
|
65
|
+
// run through a shell so `; > PROVEN` executes; `execFile`/`spawn` with an
|
|
66
|
+
// array do not, and a marker PoC against those would fail for a reason that
|
|
67
|
+
// has nothing to do with whether the finding is real.
|
|
68
|
+
const SHELL_SINK = /\b(?:exec|execSync)\s*\(/;
|
|
69
|
+
const ARGV_SINK = /\b(?:execFile|execFileSync|spawn|spawnSync)\s*\(/;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Synthesize a sandbox-runnable PoC, or return a refusal explaining why not.
|
|
73
|
+
*
|
|
74
|
+
* @returns {{ok:true, poc:object} | {ok:false, reason:string}}
|
|
75
|
+
*/
|
|
76
|
+
export function synthesizeInProcessPoc(finding, fileContent) {
|
|
77
|
+
if (!finding || typeof finding !== 'object') return { ok: false, reason: 'no finding' };
|
|
78
|
+
if (!SUPPORTED.has(finding.family)) {
|
|
79
|
+
return { ok: false, reason: `family '${finding.family || 'unknown'}' has no marker-observable in-process template` };
|
|
80
|
+
}
|
|
81
|
+
if (!finding.file || !JS_EXT.test(finding.file)) {
|
|
82
|
+
return { ok: false, reason: 'in-process PoCs are JavaScript-only today' };
|
|
83
|
+
}
|
|
84
|
+
if (typeof fileContent !== 'string' || !fileContent.trim()) {
|
|
85
|
+
return { ok: false, reason: 'the vulnerable file content was not available' };
|
|
86
|
+
}
|
|
87
|
+
if (/^\s*(?:import|export)\s/m.test(fileContent) && !/module\.exports/.test(fileContent)) {
|
|
88
|
+
return { ok: false, reason: 'ES-module source: the CommonJS handler shapes do not apply' };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!SHELL_SINK.test(fileContent)) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
reason: ARGV_SINK.test(fileContent)
|
|
95
|
+
? 'the sink passes an argv array, so a shell-metacharacter payload would not execute — absence of proof here would say nothing about the finding'
|
|
96
|
+
: 'no shell-executing sink found in the file',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Default export first, then a named one.
|
|
101
|
+
let call = null, reqIdent = null;
|
|
102
|
+
for (const re of HANDLER_RES) {
|
|
103
|
+
const m = fileContent.match(re);
|
|
104
|
+
if (m) { call = { kind: 'default', name: null }; reqIdent = m[1]; break; }
|
|
105
|
+
}
|
|
106
|
+
if (!call) {
|
|
107
|
+
for (const re of NAMED_HANDLER_RES) {
|
|
108
|
+
const m = fileContent.match(re);
|
|
109
|
+
if (m) { call = { kind: 'named', name: m[1] }; reqIdent = m[2]; break; }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (!call) {
|
|
113
|
+
return { ok: false, reason: 'no exported two-argument (req, res) handler found — nothing to call without inventing an interface' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const src = _requestSource(fileContent, reqIdent);
|
|
117
|
+
if (!src) {
|
|
118
|
+
return { ok: false, reason: `the handler does not read query/body/params off '${reqIdent}', so the injection point is unknown` };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const base = finding.file.split(/[\\/]/).pop();
|
|
122
|
+
const imported = call.kind === 'default' ? 'handler' : `{ ${call.name} }`;
|
|
123
|
+
const invoke = call.kind === 'default' ? 'handler' : call.name;
|
|
124
|
+
|
|
125
|
+
// The timer is unref'd so the process exits as soon as the handler responds.
|
|
126
|
+
// Without that it stays alive for the full timeout even after the exploit
|
|
127
|
+
// has landed, and on a loaded machine it can outlive the proof budget — at
|
|
128
|
+
// which point `attachProofTier` demotes a real proof to its static tier
|
|
129
|
+
// because `ran` is false. Correct, but it throws away a genuine result.
|
|
130
|
+
const code = [
|
|
131
|
+
`// Auto-generated in-process proof-of-concept for ${finding.file}.`,
|
|
132
|
+
'// Proves arbitrary command execution by having the injected payload',
|
|
133
|
+
`// create the marker file '${MARKER}' inside the sandbox root.`,
|
|
134
|
+
`import ${imported} from './${base}';`,
|
|
135
|
+
'await new Promise((resolve) => {',
|
|
136
|
+
' const res = {',
|
|
137
|
+
' send: () => resolve(), json: () => resolve(), end: () => resolve(),',
|
|
138
|
+
' status: () => ({ send: () => resolve(), json: () => resolve(), end: () => resolve() }),',
|
|
139
|
+
' };',
|
|
140
|
+
` const req = { ${src.prop}: ${JSON.stringify({ [src.key]: `x; > ${MARKER}` })} };`,
|
|
141
|
+
` try { ${invoke}(req, res); } catch { resolve(); }`,
|
|
142
|
+
' setTimeout(resolve, 4000).unref();',
|
|
143
|
+
'});',
|
|
144
|
+
].join('\n');
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
ok: true,
|
|
148
|
+
poc: {
|
|
149
|
+
lang: 'js',
|
|
150
|
+
kind: 'in-process',
|
|
151
|
+
family: finding.family,
|
|
152
|
+
cwe: finding.cwe || null,
|
|
153
|
+
marker: MARKER,
|
|
154
|
+
paramKey: src.key,
|
|
155
|
+
paramSource: src.prop,
|
|
156
|
+
handler: call.kind === 'default' ? 'module.exports' : `exports.${call.name}`,
|
|
157
|
+
// The file the PoC imports. `execution-proof.js` materialises this into
|
|
158
|
+
// the sandbox root; without it the import fails and nothing is proved.
|
|
159
|
+
requires: [base],
|
|
160
|
+
code,
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export const _internals = { MARKER, SUPPORTED, _requestSource };
|
|
@@ -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 };
|