@clear-capabilities/agentic-security-scanner 0.130.0 → 0.133.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +247 -0
  2. package/bin/agentic-security.js +39 -3
  3. package/dist/113.index.js +294 -5
  4. package/dist/178.index.js +1 -1
  5. package/dist/207.index.js +7 -4
  6. package/dist/238.index.js +218 -0
  7. package/dist/259.index.js +975 -0
  8. package/dist/384.index.js +1 -1
  9. package/dist/435.index.js +2 -2
  10. package/dist/526.index.js +294 -5
  11. package/dist/637.index.js +1 -1
  12. package/dist/agentic-security.mjs +18 -57
  13. package/dist/agentic-security.mjs.sha256 +1 -1
  14. package/package.json +19 -10
  15. package/src/engine.js +48 -1
  16. package/src/ir/parser-js.js +8 -0
  17. package/src/llm-validator/cost-ceiling.js +199 -0
  18. package/src/llm-validator/index.js +241 -12
  19. package/src/llm-validator/local-endpoint.js +90 -0
  20. package/src/mcp/tools.js +2 -2
  21. package/src/posture/CLAUDE.md +83 -6
  22. package/src/posture/accuracy-scorecard.js +37 -6
  23. package/src/posture/attestation.js +7 -4
  24. package/src/posture/corpus-enroll.js +303 -0
  25. package/src/posture/corpus-match.js +67 -0
  26. package/src/posture/custom-rules.js +2 -2
  27. package/src/posture/execution-proof.js +44 -4
  28. package/src/posture/fix-metrics.js +197 -0
  29. package/src/posture/fix-verify.js +76 -2
  30. package/src/posture/integrity.js +42 -9
  31. package/src/posture/learning.js +8 -1
  32. package/src/posture/model-routing.js +26 -0
  33. package/src/posture/model-trust.js +174 -0
  34. package/src/posture/poc-inprocess.js +165 -0
  35. package/src/posture/prove-findings.js +148 -0
  36. package/src/posture/root-cause-sweep.js +0 -0
  37. package/src/posture/rule-overrides.js +64 -3
  38. package/src/posture/state-dir.js +25 -0
  39. package/src/posture/vuln-archaeology.js +231 -0
  40. package/src/report/index.js +7 -0
  41. package/src/runScan.js +2 -6
  42. package/src/sandbox/CLAUDE.md +190 -46
  43. package/src/sandbox/backend-namespace.js +328 -48
  44. package/src/sandbox/backend-userspace.js +6 -19
  45. package/src/sandbox/capabilities.js +132 -4
  46. package/src/sandbox/limits.js +21 -0
  47. package/src/sandbox/result.js +1 -1
  48. package/src/sast/CLAUDE.md +4 -0
  49. package/src/sast/crypto-specialist.js +247 -0
  50. package/src/util/glob.js +173 -0
@@ -60,10 +60,13 @@ const PROVES =
60
60
  '(same rule id, severity, file, line, cwe, vuln, and multiplicity) produced by the same ' +
61
61
  'engine version, ruleset version, and bundle — regardless of emission order.';
62
62
  const DOES_NOT_PROVE =
63
- 'It does not prove cross-machine reproducibility: no run on a second machine, OS, or Node ' +
64
- 'version is compared here, and some detectors are environment-sensitive. A signature, when ' +
65
- 'present, is a symmetric per-install HMAC — tamper-evidence for this install, not ' +
66
- 'third-party non-repudiation.';
63
+ 'It does not prove cross-machine reproducibility: this attestation is one run on one machine, ' +
64
+ 'nothing here compares a second machine, OS, or Node version, and some detectors are ' +
65
+ 'environment-sensitive. That property is tested separately by the determinism-attest / ' +
66
+ 'determinism-compare CI jobs, which run the same commit on two operating systems and fail ' +
67
+ 'unless the digests match — evidence about the ENGINE, not about this attestation. ' +
68
+ 'A signature, when present, is a symmetric per-install HMAC — tamper-evidence for this ' +
69
+ 'install, not third-party non-repudiation.';
67
70
 
68
71
  function _str(v) { return v === undefined || v === null ? '' : String(v); }
69
72
 
@@ -0,0 +1,303 @@
1
+ // R2's differentiator — auto-enrol an execution-proven finding as a permanent
2
+ // CVE-replay corpus entry.
3
+ //
4
+ // The compounding asset: a finding that was PROVEN by execution becomes a
5
+ // regression test that the baseline gate defends forever. Every exploit the
6
+ // pipeline proves once, it can never silently stop detecting.
7
+ //
8
+ // THE CENTRAL RULE: nothing is written to the corpus that has not been scored.
9
+ // The v0.106.0 failure — fixtures committed without verifying they actually
10
+ // score, which then broke the gate for everyone — is the exact mistake this
11
+ // module must not automate. So enrolment builds the entry in a TEMPORARY
12
+ // directory, scans `pre/` and `post/` with the same matcher the gate uses
13
+ // (`corpus-match.js`), and moves it into the corpus only on `pre:TP post:TN`.
14
+ // A candidate that does not score is discarded and the reason returned. There
15
+ // is no force flag and no "probably fine" path.
16
+ //
17
+ // WHY A FIX IS MANDATORY. An entry needs a `post/` that scores TN, and the
18
+ // only honest source of one is a real fix. Enrolment therefore refuses a
19
+ // finding with no fixed content rather than synthesising a `post/` by deleting
20
+ // the vulnerable line — that would produce an entry that passes for a reason
21
+ // unrelated to the vulnerability, which is worse than no entry.
22
+ //
23
+ // WHY `capability/` AND NOT `regression/`. `regression/` is the CI-gated tier
24
+ // and graduation into it is a human decision with a stated policy (five
25
+ // consecutive passing snapshots — see bench/cve-replay/CONTRIBUTING.md). An
26
+ // automated writer promoting straight into the gated tier would let a machine
27
+ // decide what blocks everyone's build. New entries land in `capability/`,
28
+ // already passing, and graduate on the existing policy.
29
+ //
30
+ // NOTHING THROWS (posture convention): every path returns
31
+ // `{ok:false, refused:true, reason}` instead.
32
+
33
+ import fs from 'node:fs';
34
+ import os from 'node:os';
35
+ import path from 'node:path';
36
+ import { preHit, postHit, matcherFor } from './corpus-match.js';
37
+
38
+ const DEFAULT_TIER = 'capability';
39
+
40
+ // Entry ids must be safe to use as a directory name and stable across runs.
41
+ const ID_SAFE = /^[A-Za-z0-9._-]+$/;
42
+
43
+ function refuse(reason) { return { ok: false, refused: true, reason }; }
44
+
45
+ // A finding is enrollable only if the pipeline actually RAN its exploit. This
46
+ // re-checks the evidence rather than trusting `proofTier` alone: the tier is a
47
+ // string on an object that may have crossed a process boundary, and the
48
+ // consequence of trusting a forged one is a permanent corpus entry.
49
+ export function isEnrollable(finding) {
50
+ if (!finding || typeof finding !== 'object') return refuse('no finding supplied');
51
+ if (finding.proofTier !== 'execution-proven') {
52
+ return refuse(
53
+ `only execution-proven findings may enrol; this one is '${finding.proofTier || 'untiered'}'. `
54
+ + 'A statically-reasoned finding has not earned a permanent regression entry.',
55
+ );
56
+ }
57
+ const ev = finding.proofEvidence;
58
+ if (!ev || ev.ran !== true) {
59
+ return refuse('proofEvidence does not record a run (ran !== true) — the tier is not backed by evidence');
60
+ }
61
+ if (ev.tier !== 'execution-proven') {
62
+ return refuse(`proofEvidence.tier ('${ev.tier}') disagrees with proofTier — refusing rather than picking one`);
63
+ }
64
+ if (!ev.observed) {
65
+ return refuse('proofEvidence records no observed effect — an execution-proven tier with nothing observed is not evidence');
66
+ }
67
+ return { ok: true };
68
+ }
69
+
70
+ function _slug(s, fallback) {
71
+ const out = String(s || '').trim().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
72
+ return out || fallback;
73
+ }
74
+
75
+ /**
76
+ * The entry id. Derived from the finding so re-enrolling the same finding is
77
+ * idempotent (it will be refused as a duplicate) rather than accumulating
78
+ * near-identical entries.
79
+ */
80
+ export function entryIdFor(finding) {
81
+ const fam = _slug(finding?.family || finding?.cwe, 'finding');
82
+ const sid = _slug(finding?.stableId || finding?.id, 'unknown');
83
+ return `proven-${fam}-${sid}`.slice(0, 120);
84
+ }
85
+
86
+ /**
87
+ * Build the manifest + file map for a candidate entry, without writing it.
88
+ *
89
+ * @param {object} finding an execution-proven finding
90
+ * @param {object} opts
91
+ * @param {object} opts.preFiles rel→content, the VULNERABLE tree
92
+ * @param {object} opts.postFiles rel→content, the FIXED tree
93
+ */
94
+ export function buildCandidate(finding, { preFiles, postFiles, addedAt } = {}) {
95
+ const gate = isEnrollable(finding);
96
+ if (!gate.ok) return gate;
97
+
98
+ if (!finding.cwe) return refuse('finding has no cwe — the manifest matcher would be meaningless');
99
+ if (!finding.vuln) return refuse('finding has no vuln — nothing to match on');
100
+ if (!finding.file) return refuse('finding has no file — cannot name the expected file');
101
+
102
+ const pre = preFiles && typeof preFiles === 'object' ? preFiles : null;
103
+ const post = postFiles && typeof postFiles === 'object' ? postFiles : null;
104
+ if (!pre || !Object.keys(pre).length) return refuse('no pre/ content supplied — nothing to prove the detector fires on');
105
+ if (!post || !Object.keys(post).length) {
106
+ return refuse(
107
+ 'no post/ content supplied. An entry with no fixed tree cannot score post:TN, and '
108
+ + 'synthesising one by deleting the vulnerable code would pass for the wrong reason.',
109
+ );
110
+ }
111
+
112
+ // A `post` identical to `pre` cannot be a fix. Catching it here turns a
113
+ // guaranteed post:FP into a clear refusal.
114
+ const same = Object.keys(pre).length === Object.keys(post).length
115
+ && Object.entries(pre).every(([k, v]) => post[k] === v);
116
+ if (same) return refuse('post/ is byte-identical to pre/ — no fix was applied, so the entry cannot score post:TN');
117
+
118
+ for (const [label, files] of [['pre', pre], ['post', post]]) {
119
+ for (const [rel, content] of Object.entries(files)) {
120
+ if (typeof content !== 'string') return refuse(`${label}/${rel} content is not a string`);
121
+ if (path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) {
122
+ return refuse(`${label}/${rel} escapes the entry directory`);
123
+ }
124
+ }
125
+ }
126
+
127
+ const id = entryIdFor(finding);
128
+ if (!ID_SAFE.test(id)) return refuse(`derived entry id '${id}' is not a safe directory name`);
129
+
130
+ const expectedFile = path.basename(String(finding.file));
131
+ if (!Object.keys(pre).some(rel => path.basename(rel) === expectedFile)) {
132
+ return refuse(`the finding's file '${expectedFile}' is not among the pre/ files — the entry would not test the finding`);
133
+ }
134
+
135
+ const manifest = {
136
+ cve: id,
137
+ cwe: finding.cwe,
138
+ family: finding.family || 'unknown',
139
+ language: finding.language || _languageOf(expectedFile),
140
+ summary: `execution-proven ${finding.family || finding.cwe}: ${String(finding.vuln).slice(0, 120)}`,
141
+ expected: {
142
+ file: expectedFile,
143
+ // Match on the exact vuln string this finding carried. A broader regex
144
+ // would let an unrelated detector satisfy the entry.
145
+ vuln_match: _escapeRegex(String(finding.vuln)),
146
+ },
147
+ source: 'execution-proven',
148
+ added_at: addedAt || new Date().toISOString().slice(0, 10),
149
+ provenance: {
150
+ stableId: finding.stableId || null,
151
+ proofBackend: finding.proofEvidence?.backend || null,
152
+ observed: finding.proofEvidence?.observed || null,
153
+ provenAt: finding.proofEvidence?.at || null,
154
+ },
155
+ };
156
+
157
+ return { ok: true, id, manifest, preFiles: pre, postFiles: post };
158
+ }
159
+
160
+ function _escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
161
+
162
+ function _languageOf(file) {
163
+ const ext = path.extname(file).toLowerCase();
164
+ return {
165
+ '.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript',
166
+ '.ts': 'typescript', '.tsx': 'typescript', '.jsx': 'javascript',
167
+ '.py': 'python', '.java': 'java', '.go': 'go', '.rb': 'ruby',
168
+ '.php': 'php', '.cs': 'csharp', '.rs': 'rust',
169
+ }[ext] || 'unknown';
170
+ }
171
+
172
+ function _writeTree(dir, files) {
173
+ for (const [rel, content] of Object.entries(files)) {
174
+ const abs = path.join(dir, rel);
175
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
176
+ fs.writeFileSync(abs, content, 'utf8');
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Materialise a candidate into a staging directory and SCORE it, using the
182
+ * same matcher the corpus gate uses.
183
+ *
184
+ * @param {function} runScan injected so this module stays free of an engine
185
+ * import cycle and so tests can drive it without a full scan.
186
+ * @returns {{ok:boolean, status:string, preHit:boolean, postHit:boolean, reason?:string}}
187
+ */
188
+ async function scoreCandidate(candidate, runScan, { stagingDir } = {}) {
189
+ const dir = stagingDir || fs.mkdtempSync(path.join(os.tmpdir(), 'corpus-cand-'));
190
+ try {
191
+ const preDir = path.join(dir, 'pre');
192
+ const postDir = path.join(dir, 'post');
193
+ fs.mkdirSync(preDir, { recursive: true });
194
+ fs.mkdirSync(postDir, { recursive: true });
195
+ _writeTree(preDir, candidate.preFiles);
196
+ _writeTree(postDir, candidate.postFiles);
197
+ fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(candidate.manifest, null, 2) + '\n', 'utf8');
198
+
199
+ const matcher = matcherFor(candidate.manifest);
200
+ let pre, post;
201
+ try {
202
+ ({ scan: pre } = await runScan(preDir));
203
+ } catch (e) {
204
+ return { ok: false, status: 'scan-error', preHit: false, postHit: false, reason: `pre/ scan failed: ${e.message}` };
205
+ }
206
+ try {
207
+ ({ scan: post } = await runScan(postDir));
208
+ } catch (e) {
209
+ return { ok: false, status: 'scan-error', preHit: false, postHit: false, reason: `post/ scan failed: ${e.message}` };
210
+ }
211
+
212
+ const hitPre = preHit(pre, candidate.manifest, matcher);
213
+ const hitPost = postHit(post, candidate.manifest, matcher);
214
+ const status = `pre:${hitPre ? 'TP' : 'FN'} post:${hitPost ? 'FP' : 'TN'}`;
215
+
216
+ if (!hitPre) {
217
+ return {
218
+ ok: false, status, preHit: hitPre, postHit: hitPost, dir,
219
+ reason: 'the detector does not fire on pre/ — the entry would be committed already failing. '
220
+ + 'A PoC proved this finding at runtime but the minimised fixture does not reproduce it statically.',
221
+ };
222
+ }
223
+ if (hitPost) {
224
+ return {
225
+ ok: false, status, preHit: hitPre, postHit: hitPost, dir,
226
+ reason: 'the detector still fires on post/ — the fix does not clear the finding, so the entry cannot score TN.',
227
+ };
228
+ }
229
+ return { ok: true, status, preHit: hitPre, postHit: hitPost, dir };
230
+ } catch (e) {
231
+ return { ok: false, status: 'error', preHit: false, postHit: false, reason: e.message };
232
+ }
233
+ }
234
+
235
+ /**
236
+ * The full path: gate → build → score → commit.
237
+ *
238
+ * Writes into `<corpusRoot>/<tier>/<id>/` ONLY when the candidate scored
239
+ * `pre:TP post:TN`. Anything else leaves the corpus untouched.
240
+ */
241
+ export async function enrollProvenFinding(finding, {
242
+ corpusRoot, preFiles, postFiles, runScan, tier = DEFAULT_TIER, addedAt, dryRun = false,
243
+ } = {}) {
244
+ if (!corpusRoot) return refuse('no corpusRoot supplied');
245
+ if (typeof runScan !== 'function') return refuse('no runScan supplied — an entry may not be committed unscored');
246
+
247
+ const candidate = buildCandidate(finding, { preFiles, postFiles, addedAt });
248
+ if (!candidate.ok) return candidate;
249
+
250
+ const dest = path.join(corpusRoot, tier, candidate.id);
251
+ if (fs.existsSync(dest)) {
252
+ return refuse(`entry '${candidate.id}' already exists in ${tier}/ — refusing to overwrite a corpus entry`);
253
+ }
254
+
255
+ const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'corpus-cand-'));
256
+ try {
257
+ const scored = await scoreCandidate(candidate, runScan, { stagingDir: staging });
258
+ if (!scored.ok) {
259
+ return {
260
+ ok: false, refused: true, id: candidate.id, status: scored.status,
261
+ reason: `not enrolled (${scored.status}): ${scored.reason}`,
262
+ };
263
+ }
264
+ if (dryRun) {
265
+ return { ok: true, dryRun: true, id: candidate.id, status: scored.status, dir: null, manifest: candidate.manifest };
266
+ }
267
+
268
+ // Scan state accumulated inside the staged trees must not be committed —
269
+ // it would be scanned as part of the fixture on the next run.
270
+ _stripState(staging);
271
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
272
+ fs.renameSync(staging, dest);
273
+ return {
274
+ ok: true,
275
+ id: candidate.id,
276
+ tier,
277
+ status: scored.status,
278
+ dir: dest,
279
+ manifest: candidate.manifest,
280
+ // Said explicitly because a caller that stops here leaves the repo in a
281
+ // state where the gate reports a nudge rather than a pass.
282
+ followUp: 'run `npm run bench:cve-replay:update-baseline` and commit the regenerated corpus-baseline.json',
283
+ };
284
+ } catch (e) {
285
+ return refuse(`enrolment failed: ${e.message}`);
286
+ } finally {
287
+ // If the rename happened, staging no longer exists and this is a no-op.
288
+ try { fs.rmSync(staging, { recursive: true, force: true }); } catch { /* best effort */ }
289
+ }
290
+ }
291
+
292
+ function _stripState(dir) {
293
+ for (const sub of ['pre', 'post']) {
294
+ const s = path.join(dir, sub, '.agentic-security');
295
+ try { fs.rmSync(s, { recursive: true, force: true }); } catch { /* best effort */ }
296
+ }
297
+ }
298
+
299
+ // `scoreCandidate` is deliberately NOT exported: an external caller could
300
+ // score a candidate and then write it by some other route, which is exactly
301
+ // the unscored-write path this module exists to make unavailable. Enrolment
302
+ // scores and writes as one operation or not at all.
303
+ export const _internals = { DEFAULT_TIER, scoreCandidate, _languageOf, _escapeRegex, _stripState };
@@ -0,0 +1,67 @@
1
+ // How a CVE-replay corpus entry is scored against a scan result.
2
+ //
3
+ // Extracted from `bench/cve-replay/runner.mjs` so the corpus GATE and corpus
4
+ // ENROLLMENT (`corpus-enroll.js`) cannot drift apart. That drift is not
5
+ // hypothetical: enrollment only writes an entry it has verified scores
6
+ // `pre:TP post:TN`, and if it verified that with a different matcher than the
7
+ // gate uses, it would cheerfully commit entries that fail CI. One
8
+ // implementation, two callers.
9
+ //
10
+ // THE PRE/POST MATCHERS ARE NOW SYMMETRIC. They were not: `pre` accepted a hit
11
+ // on `vuln` OR `family` and regex-tested `cwe`, while `post` was strict on
12
+ // `vuln` and required an exact `cwe`. An entry therefore faced a LOOSER bar to
13
+ // score a true positive than a false positive, which flatters the corpus —
14
+ // exactly the direction a measurement must not lean. `CONTRIBUTING.md` recorded
15
+ // it as known imprecision to resolve before the corpus grows toward 500.
16
+ //
17
+ // WHICH DIRECTION, AND WHY. The two sides were unified on the LOOSE predicate,
18
+ // not the strict one. The question a corpus entry asks is "does the scanner
19
+ // still report this vulnerability?", and a scanner that reports it under the
20
+ // family name rather than the exact vuln string is still reporting it. Loose-
21
+ // on-both means an entry must genuinely go quiet to score `post:TN` — a HIGHER
22
+ // bar for a pass. Unifying on the strict predicate would instead have made
23
+ // `pre:TP` harder and `post:TN` easier, i.e. it would have made the corpus
24
+ // easier to satisfy. When a symmetry fix can go either way, take the direction
25
+ // that makes the gate harder to pass.
26
+ //
27
+ // This re-verdicts every committed entry, so it is a corpus migration: the full
28
+ // baseline was re-run against it and any entry whose verdict moved was fixed or
29
+ // recorded, never baselined over.
30
+ //
31
+ // The scanner emits into several arrays — `findings` (SAST), `secrets`,
32
+ // `supplyChain` (SCA) and `logicVulns` (business-logic + behavioural) — and a
33
+ // CVE can land in any of them, so all four are consulted.
34
+
35
+ const CHANNELS = ['findings', 'secrets', 'supplyChain', 'logicVulns'];
36
+
37
+ /** The regex an entry's manifest scores with. */
38
+ export function matcherFor(manifest) {
39
+ return new RegExp(manifest?.expected?.vuln_match || manifest?.family || manifest?.cwe || '(?!)', 'i');
40
+ }
41
+
42
+ function _any(scan, predicate) {
43
+ for (const channel of CHANNELS) {
44
+ const arr = scan?.[channel];
45
+ if (Array.isArray(arr) && arr.some(predicate)) return true;
46
+ }
47
+ return false;
48
+ }
49
+
50
+ // One predicate, both sides. See the header for why the symmetric form is the
51
+ // LOOSE one.
52
+ function _matches(f, manifest, matcher) {
53
+ return (matcher.test(f.vuln || '') || matcher.test(f.family || '')) &&
54
+ (manifest?.cwe ? f.cwe === manifest.cwe || matcher.test(f.cwe || '') : true);
55
+ }
56
+
57
+ /** Did the vulnerable (`pre/`) tree produce a matching finding? */
58
+ export function preHit(scan, manifest, matcher = matcherFor(manifest)) {
59
+ return _any(scan, f => _matches(f, manifest, matcher));
60
+ }
61
+
62
+ /** Did the fixed (`post/`) tree still produce a matching finding? */
63
+ export function postHit(scan, manifest, matcher = matcherFor(manifest)) {
64
+ return _any(scan, f => _matches(f, manifest, matcher));
65
+ }
66
+
67
+ export const _internals = { CHANNELS };
@@ -30,7 +30,7 @@
30
30
  import * as fs from 'node:fs';
31
31
  import * as path from 'node:path';
32
32
  import * as yaml from '../util/yaml.js';
33
- import fg from 'fast-glob';
33
+ import { globFiles } from '../util/glob.js';
34
34
  import { loadTrustedKeys, verifyRulePack } from './rule-pack-signing.js';
35
35
 
36
36
  const LANG_EXTS = {
@@ -337,7 +337,7 @@ export async function runRuleTests(scanRoot, fixtureGlob) {
337
337
  console.log(`No custom rules found in ${rulesDir(scanRoot)}`);
338
338
  return { ok: true, rules: 0, fired: 0 };
339
339
  }
340
- const files = await fg(fixtureGlob, { dot: false, onlyFiles: true });
340
+ const files = await globFiles(fixtureGlob);
341
341
  console.log(`Loaded ${rules.length} rule(s); testing against ${files.length} file(s).\n`);
342
342
  let fired = 0;
343
343
  for (const fp of files) {
@@ -18,7 +18,40 @@ function _evidence(over = {}) {
18
18
  };
19
19
  }
20
20
 
21
- export async function proveFinding(finding, { timeoutMs = 10000 } = {}) {
21
+ // A run that never got as far as executing the PoC. `ran:false` for these is
22
+ // the whole point: 'proof-failed' asserts "the PoC ran and the predicted effect
23
+ // did not appear", which is a triage signal about the FINDING. A sandbox that
24
+ // could not start says nothing about the finding at all, and must leave it at
25
+ // its static tier rather than manufacturing a failed exploit attempt.
26
+ const _DID_NOT_EXECUTE = new Set(['disabled', 'error']);
27
+
28
+ // Materialise caller-supplied files into the sandbox root so a PoC can import
29
+ // the code it is supposed to exploit. Paths are confined to the root: an
30
+ // absolute path or one that climbs out is refused rather than clamped, because
31
+ // silently rewriting a path would put a file somewhere the caller did not ask
32
+ // for and the PoC would then exercise the wrong code.
33
+ function _materialise(root, files) {
34
+ for (const [rel, content] of Object.entries(files || {})) {
35
+ if (typeof content !== 'string') continue;
36
+ const abs = path.resolve(root, rel);
37
+ if (abs !== root && !abs.startsWith(root + path.sep)) {
38
+ return `refusing to write '${rel}': it resolves outside the sandbox root`;
39
+ }
40
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
41
+ fs.writeFileSync(abs, content, 'utf8');
42
+ }
43
+ return null;
44
+ }
45
+
46
+ /**
47
+ * @param {object} finding carries `poc: {lang, code}`
48
+ * @param {object} [opts]
49
+ * @param {object} [opts.files] rel→content written into the sandbox root before
50
+ * the PoC runs. This is what lets the SAME PoC be run against a candidate
51
+ * patch: pass the patched contents and a still-`execution-proven` verdict
52
+ * means the fix did not close the hole.
53
+ */
54
+ export async function proveFinding(finding, { timeoutMs = 10000, force, files } = {}) {
22
55
  const poc = finding?.poc;
23
56
  if (!poc?.code) {
24
57
  return attachProofTier(finding, _evidence({ tier: proofTierOf(finding), reason: 'no proof-of-concept attached' }));
@@ -32,16 +65,23 @@ export async function proveFinding(finding, { timeoutMs = 10000 } = {}) {
32
65
 
33
66
  const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'proof-')));
34
67
  try {
68
+ const badPath = _materialise(root, files);
69
+ if (badPath) {
70
+ return attachProofTier(finding, _evidence({ tier: proofTierOf(finding), reason: badPath }));
71
+ }
35
72
  fs.writeFileSync(path.join(root, 'poc.mjs'), poc.code, 'utf8');
36
- const r = runConfined([process.execPath, 'poc.mjs'], { root, timeoutMs });
73
+ const r = runConfined([process.execPath, 'poc.mjs'], { root, timeoutMs, force });
37
74
  const proven = fs.existsSync(path.join(root, PROOF_MARKER));
75
+ const ran = !r.timedOut && !_DID_NOT_EXECUTE.has(r.status);
38
76
 
39
77
  return attachProofTier(finding, _evidence({
40
- tier: proven ? 'execution-proven' : 'proof-failed',
78
+ tier: proven ? 'execution-proven' : ran ? 'proof-failed' : proofTierOf(finding),
41
79
  backend: r.backend,
42
- ran: !r.timedOut && r.status !== 'disabled',
80
+ ran,
43
81
  observed: proven ? `proof marker '${PROOF_MARKER}' written by the proof-of-concept` : null,
44
82
  reason: proven ? null
83
+ : r.status === 'error' ? `the confinement sandbox could not start, so the proof-of-concept never executed (${r.backend} backend): ${String(r.stderr || '').trim() || 'no detail reported'}`
84
+ : r.status === 'disabled' ? 'confined execution is disabled; the proof-of-concept was refused and never executed'
45
85
  : r.timedOut ? 'proof-of-concept exceeded its time budget'
46
86
  : 'proof-of-concept ran but did not demonstrate the predicted effect',
47
87
  exitCode: r.exitCode, timedOut: r.timedOut,
@@ -0,0 +1,197 @@
1
+ // Time-to-validated-fix (R5, the reporting half).
2
+ //
3
+ // `verifyFix` already RUNS the stages and `test-runner.js` already times the
4
+ // slowest one. What did not exist was anything durable to read afterwards, so
5
+ // "how long does a fix actually take to validate" had no answer from real runs
6
+ // — only an estimate (`time-to-fix.js` guesses engineering hours from family
7
+ // and patch shape, before anything runs). This module is the opposite: it
8
+ // records what the pipeline observed and reports the distribution.
9
+ //
10
+ // THE HONESTY RULES, which are most of why this file is longer than a mean:
11
+ //
12
+ // 1. A failed attempt is NOT a data point about how long a fix takes. Fixes
13
+ // that fail verification fail fast (a re-scan that still sees the finding
14
+ // never reaches the test suite), so blending them into one average makes
15
+ // the pipeline look faster the worse it performs. Validated and failed
16
+ // attempts are summarised separately and never merged.
17
+ //
18
+ // 2. "Tests skipped" is not "tests passed". A project with no detectable
19
+ // suite can reach `ok:true` having run only the re-scan and the linter.
20
+ // That is a weaker claim than a fix whose suite executed, and it is also
21
+ // much faster, so counting the two together would quietly deflate the
22
+ // headline. They get their own bucket: `validated` means the suite ran and
23
+ // passed, `validatedWithoutTests` means there was no suite to run.
24
+ //
25
+ // 3. Every figure carries its `n`, and a percentile computed from too few
26
+ // samples is labelled unreliable rather than omitted or silently reported.
27
+ // Same precedent as the accuracy scorecard's `{n, d}` rates: a number
28
+ // without its denominator is not a measurement.
29
+ //
30
+ // Storage is append-only JSONL at `<scanRoot>/.agentic-security/fix-metrics.jsonl`,
31
+ // one record per verification attempt. Nothing here throws (posture
32
+ // convention) — an unwritable or corrupt log degrades to "no metrics", never
33
+ // to a failed verification.
34
+
35
+ import fs from 'node:fs';
36
+ import path from 'node:path';
37
+ import { isSafeStateDir } from './state-dir.js';
38
+
39
+ const STATE_DIR = '.agentic-security';
40
+ const LOG_FILE = 'fix-metrics.jsonl';
41
+
42
+ // Below this many samples a percentile is an artifact of the sample, not a
43
+ // property of the pipeline. Reported anyway (hiding it invites re-deriving it
44
+ // wrong downstream) but flagged, so a caller cannot quote it as settled.
45
+ const RELIABLE_N = 10;
46
+
47
+ // The stages verifyFix runs, in execution order. Kept here so the recorder and
48
+ // the summariser cannot drift apart on stage naming.
49
+ export const FIX_STAGES = Object.freeze(['rescan', 'lint', 'tests', 'honesty', 'poc']);
50
+
51
+ function _logPath(scanRoot) {
52
+ return path.join(scanRoot, STATE_DIR, LOG_FILE);
53
+ }
54
+
55
+ /**
56
+ * Append one verification attempt. Best-effort and silent on failure: metrics
57
+ * must never be able to fail a fix that otherwise verified.
58
+ *
59
+ * @returns {boolean} whether the record was written (for tests, not callers).
60
+ */
61
+ export function recordFixAttempt(scanRoot, record) {
62
+ if (!scanRoot || !record || typeof record !== 'object') return false;
63
+ try {
64
+ const dir = path.join(scanRoot, STATE_DIR);
65
+ if (!isSafeStateDir(dir)) return false;
66
+ fs.mkdirSync(dir, { recursive: true });
67
+ // One writeSync of one newline-terminated line: a concurrent reader sees
68
+ // whole records or nothing, and a torn tail is dropped on read.
69
+ fs.appendFileSync(_logPath(scanRoot), JSON.stringify(record) + '\n', 'utf8');
70
+ return true;
71
+ } catch { return false; }
72
+ }
73
+
74
+ /**
75
+ * Read every well-formed attempt. A line that does not parse is skipped, not
76
+ * fatal — the last line of an interrupted write is the expected case.
77
+ */
78
+ export function loadFixAttempts(scanRoot) {
79
+ try {
80
+ const raw = fs.readFileSync(_logPath(scanRoot), 'utf8');
81
+ const out = [];
82
+ for (const line of raw.split('\n')) {
83
+ if (!line.trim()) continue;
84
+ try {
85
+ const rec = JSON.parse(line);
86
+ if (rec && typeof rec === 'object' && typeof rec.totalMs === 'number') out.push(rec);
87
+ } catch { /* torn or hand-edited line — drop it, keep the rest */ }
88
+ }
89
+ return out;
90
+ } catch { return []; }
91
+ }
92
+
93
+ // Nearest-rank percentile over an ascending array. Nearest-rank rather than
94
+ // interpolated because these are observed durations, and an interpolated p50
95
+ // reports a duration that no run actually took.
96
+ function _pct(sorted, p) {
97
+ if (!sorted.length) return null;
98
+ const rank = Math.ceil((p / 100) * sorted.length);
99
+ return sorted[Math.min(sorted.length - 1, Math.max(0, rank - 1))];
100
+ }
101
+
102
+ function _dist(values) {
103
+ const v = values.filter(x => typeof x === 'number' && Number.isFinite(x) && x >= 0).sort((a, b) => a - b);
104
+ if (!v.length) return { n: 0, minMs: null, p50Ms: null, p90Ms: null, maxMs: null, meanMs: null, reliable: false };
105
+ const sum = v.reduce((a, b) => a + b, 0);
106
+ return {
107
+ n: v.length,
108
+ minMs: v[0],
109
+ p50Ms: _pct(v, 50),
110
+ p90Ms: _pct(v, 90),
111
+ maxMs: v[v.length - 1],
112
+ meanMs: Math.round(sum / v.length),
113
+ // Says whether the percentiles above may be quoted, not whether the count
114
+ // is real. n and min/max/mean are exact at any sample size.
115
+ reliable: v.length >= RELIABLE_N,
116
+ };
117
+ }
118
+
119
+ // Which bucket an attempt belongs to. Deliberately total: every attempt lands
120
+ // in exactly one, so the bucket counts always sum to the attempt count and a
121
+ // mis-shaped record cannot silently vanish from the denominator.
122
+ export function bucketOf(a) {
123
+ if (!a?.ok) return 'failed';
124
+ return a.testsRan ? 'validated' : 'validatedWithoutTests';
125
+ }
126
+
127
+ /**
128
+ * Summarise a set of attempts into the reported distribution.
129
+ *
130
+ * `validated` is the headline: attempts that verified AND whose test suite
131
+ * actually ran and passed. The other two buckets exist so that headline cannot
132
+ * be inflated by counting weaker or faster outcomes inside it.
133
+ */
134
+ export function summarizeFixDurations(attempts) {
135
+ const all = Array.isArray(attempts) ? attempts : [];
136
+ const buckets = { validated: [], validatedWithoutTests: [], failed: [] };
137
+ for (const a of all) buckets[bucketOf(a)].push(a);
138
+
139
+ const byStage = {};
140
+ for (const stage of FIX_STAGES) {
141
+ // Per-stage timings come from validated runs only. A stage's duration in a
142
+ // failed run is truncated by the failure (the pipeline stops), so mixing
143
+ // them in would understate every stage after the first failure point.
144
+ byStage[stage] = _dist(buckets.validated.map(a => a?.stages?.[stage]));
145
+ }
146
+
147
+ return {
148
+ attempts: all.length,
149
+ counts: {
150
+ validated: buckets.validated.length,
151
+ validatedWithoutTests: buckets.validatedWithoutTests.length,
152
+ failed: buckets.failed.length,
153
+ },
154
+ timeToValidatedFix: _dist(buckets.validated.map(a => a.totalMs)),
155
+ timeToValidatedFixWithoutTests: _dist(buckets.validatedWithoutTests.map(a => a.totalMs)),
156
+ timeToFailure: _dist(buckets.failed.map(a => a.totalMs)),
157
+ byStage,
158
+ reliableAtOrAbove: RELIABLE_N,
159
+ };
160
+ }
161
+
162
+ /** Read + summarise in one step. */
163
+ export function fixDurationReport(scanRoot) {
164
+ return summarizeFixDurations(loadFixAttempts(scanRoot));
165
+ }
166
+
167
+ function _ms(v) {
168
+ if (v == null) return '—';
169
+ return v >= 1000 ? `${(v / 1000).toFixed(1)}s` : `${v}ms`;
170
+ }
171
+
172
+ /**
173
+ * One-paragraph human summary. Returns null when there is nothing measured —
174
+ * callers print nothing rather than printing an empty table.
175
+ */
176
+ export function renderFixDurationSummary(sum) {
177
+ if (!sum || !sum.attempts) return null;
178
+ const d = sum.timeToValidatedFix;
179
+ const parts = [];
180
+ if (d.n) {
181
+ parts.push(
182
+ `time-to-validated-fix: median ${_ms(d.p50Ms)}, p90 ${_ms(d.p90Ms)} `
183
+ + `(n=${d.n}${d.reliable ? '' : `, below ${sum.reliableAtOrAbove} — percentiles not yet reliable`})`,
184
+ );
185
+ } else {
186
+ parts.push('time-to-validated-fix: no fix has both verified and had its test suite run yet');
187
+ }
188
+ if (sum.counts.validatedWithoutTests) {
189
+ parts.push(`${sum.counts.validatedWithoutTests} verified with no detectable test suite (excluded from the median above)`);
190
+ }
191
+ if (sum.counts.failed) {
192
+ parts.push(`${sum.counts.failed} failed verification, median ${_ms(sum.timeToFailure.p50Ms)} (counted separately)`);
193
+ }
194
+ return parts.join('; ') + '.';
195
+ }
196
+
197
+ export const _internals = { _dist, _pct, RELIABLE_N };