@clear-capabilities/agentic-security-scanner 0.128.1 → 0.132.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 (87) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/bin/agentic-security.js +52 -2
  3. package/dist/11.index.js +2 -2
  4. package/dist/113.index.js +498 -7
  5. package/dist/178.index.js +1 -1
  6. package/dist/207.index.js +220 -0
  7. package/dist/238.index.js +218 -0
  8. package/dist/259.index.js +975 -0
  9. package/dist/384.index.js +1 -1
  10. package/dist/415.index.js +1 -1
  11. package/dist/435.index.js +4 -4
  12. package/dist/526.index.js +844 -0
  13. package/dist/637.index.js +1 -1
  14. package/dist/830.index.js +1 -1
  15. package/dist/agentic-security.mjs +106 -194
  16. package/dist/agentic-security.mjs.sha256 +1 -1
  17. package/package.json +33 -17
  18. package/src/dataflow/CLAUDE.md +4 -1
  19. package/src/dataflow/async-sequencing.js +8 -3
  20. package/src/dataflow/catalog.js +278 -11
  21. package/src/dataflow/cross-repo.js +1 -1
  22. package/src/dataflow/cross-service-taint.js +1 -1
  23. package/src/dataflow/engine.js +182 -61
  24. package/src/dataflow/ifds.js +10 -5
  25. package/src/dataflow/index.js +15 -3
  26. package/src/dataflow/points-to.js +8 -2
  27. package/src/dataflow/proof-gate.js +7 -0
  28. package/src/dataflow/sanitizer-gate.js +89 -0
  29. package/src/dataflow/tabulation.js +14 -3
  30. package/src/engine.js +170 -7
  31. package/src/integrations/index.js +1 -1
  32. package/src/ir/CLAUDE.md +49 -4
  33. package/src/ir/call-sites.js +66 -0
  34. package/src/ir/callgraph.js +174 -7
  35. package/src/ir/class-hierarchy.js +22 -2
  36. package/src/ir/index.js +138 -51
  37. package/src/ir/ir-stats.js +126 -0
  38. package/src/ir/parser-cpp.js +829 -0
  39. package/src/ir/parser-cs.js +4 -1
  40. package/src/ir/parser-go.js +4 -1
  41. package/src/ir/parser-js.js +13 -1
  42. package/src/ir/parser-kt.js +4 -1
  43. package/src/ir/parser-php.js +10 -3
  44. package/src/ir/parser-py-cst.js +62 -10
  45. package/src/ir/tree-sitter-loader.js +13 -1
  46. package/src/llm-validator/index.js +9 -2
  47. package/src/llm-validator/redact.js +157 -0
  48. package/src/mcp/tools.js +2 -2
  49. package/src/posture/CLAUDE.md +193 -1
  50. package/src/posture/accuracy-scorecard.js +317 -0
  51. package/src/posture/api-contract.js +1 -1
  52. package/src/posture/attestation.js +202 -0
  53. package/src/posture/auditor-walkthrough.js +12 -3
  54. package/src/posture/compliance-policy.js +1 -1
  55. package/src/posture/corpus-enroll.js +303 -0
  56. package/src/posture/corpus-match.js +52 -0
  57. package/src/posture/cross-lang-openapi.js +1 -1
  58. package/src/posture/custom-rules.js +3 -3
  59. package/src/posture/execution-proof.js +92 -0
  60. package/src/posture/exploitability-probability.js +1 -1
  61. package/src/posture/falsification.js +45 -1
  62. package/src/posture/fix-metrics.js +197 -0
  63. package/src/posture/fix-verify.js +129 -2
  64. package/src/posture/license-policy.js +1 -1
  65. package/src/posture/profile.js +1 -1
  66. package/src/posture/proof-tier.js +33 -0
  67. package/src/posture/relevance.js +379 -0
  68. package/src/posture/root-cause-sweep.js +0 -0
  69. package/src/posture/rule-overrides.js +1 -1
  70. package/src/posture/sca-policy.js +1 -1
  71. package/src/posture/scan-checkpoint.js +277 -0
  72. package/src/posture/suppressions.js +1 -1
  73. package/src/posture/test-runner.js +147 -0
  74. package/src/posture/verification-separation.js +131 -0
  75. package/src/report/index.js +11 -0
  76. package/src/runScan.js +5 -7
  77. package/src/sandbox/CLAUDE.md +340 -0
  78. package/src/sandbox/backend-disabled.js +14 -0
  79. package/src/sandbox/backend-namespace.js +335 -0
  80. package/src/sandbox/backend-userspace.js +83 -0
  81. package/src/sandbox/capabilities.js +181 -0
  82. package/src/sandbox/index.js +30 -0
  83. package/src/sandbox/limits.js +63 -0
  84. package/src/sandbox/result.js +104 -0
  85. package/src/sca/dep-confusion.js +1 -1
  86. package/src/util/glob.js +173 -0
  87. package/src/util/yaml.js +24 -0
@@ -0,0 +1,92 @@
1
+ // Promote a finding to execution-proven by running its proof-of-concept inside
2
+ // the confined execution sandbox and observing a real effect.
3
+ //
4
+ // Proof is a file the PoC writes, NOT an exit code: the sandbox cannot reliably
5
+ // distinguish "denied" from "ran and exited 0", so exit status is not evidence.
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { runConfined, sandboxAvailable, detectBackend } from '../sandbox/index.js';
10
+ import { attachProofTier, proofTierOf } from './proof-tier.js';
11
+
12
+ const PROOF_MARKER = 'PROVEN';
13
+
14
+ function _evidence(over = {}) {
15
+ return {
16
+ tier: 'taint-proven', backend: detectBackend(), ran: false, observed: null,
17
+ reason: null, exitCode: null, timedOut: false, at: new Date().toISOString(), ...over,
18
+ };
19
+ }
20
+
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 } = {}) {
55
+ const poc = finding?.poc;
56
+ if (!poc?.code) {
57
+ return attachProofTier(finding, _evidence({ tier: proofTierOf(finding), reason: 'no proof-of-concept attached' }));
58
+ }
59
+ if (poc.lang !== 'js') {
60
+ return attachProofTier(finding, _evidence({ tier: proofTierOf(finding), reason: `unsupported poc language: ${poc.lang}` }));
61
+ }
62
+ if (!sandboxAvailable()) {
63
+ return attachProofTier(finding, _evidence({ tier: proofTierOf(finding), reason: 'no confinement primitive available; refusing to execute' }));
64
+ }
65
+
66
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'proof-')));
67
+ try {
68
+ const badPath = _materialise(root, files);
69
+ if (badPath) {
70
+ return attachProofTier(finding, _evidence({ tier: proofTierOf(finding), reason: badPath }));
71
+ }
72
+ fs.writeFileSync(path.join(root, 'poc.mjs'), poc.code, 'utf8');
73
+ const r = runConfined([process.execPath, 'poc.mjs'], { root, timeoutMs, force });
74
+ const proven = fs.existsSync(path.join(root, PROOF_MARKER));
75
+ const ran = !r.timedOut && !_DID_NOT_EXECUTE.has(r.status);
76
+
77
+ return attachProofTier(finding, _evidence({
78
+ tier: proven ? 'execution-proven' : ran ? 'proof-failed' : proofTierOf(finding),
79
+ backend: r.backend,
80
+ ran,
81
+ observed: proven ? `proof marker '${PROOF_MARKER}' written by the proof-of-concept` : null,
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'
85
+ : r.timedOut ? 'proof-of-concept exceeded its time budget'
86
+ : 'proof-of-concept ran but did not demonstrate the predicted effect',
87
+ exitCode: r.exitCode, timedOut: r.timedOut,
88
+ }));
89
+ } finally {
90
+ fs.rmSync(root, { recursive: true, force: true });
91
+ }
92
+ }
@@ -79,7 +79,7 @@ const FACTORS = [
79
79
  name: 'source-from-network',
80
80
  factor: 1.3,
81
81
  test: (f) => (f.trace || f.chain || []).some(t =>
82
- /http-body|url-param|header|cookie/i.test(t.provenance || '')),
82
+ /http-body|url-param|header|cookie|network/i.test(t.provenance || '')),
83
83
  },
84
84
  {
85
85
  name: 'critical-severity-detector',
@@ -16,6 +16,10 @@
16
16
  // the deterministic core runs fully offline.
17
17
 
18
18
  import { isValidSanitizerFor } from '../dataflow/sanitizer-proof.js';
19
+ import {
20
+ recordProducer, assertSeparation, recordVerdict, consensusOf, producerIdOf,
21
+ VERIFIER_FALSIFICATION, VERIFIER_LLM_REVIEW,
22
+ } from './verification-separation.js';
19
23
 
20
24
  const DEMOTE_FACTOR = 0.4; // mirror proof-gate.js
21
25
  const TIERS = ['low', 'medium', 'high']; // confidence / exploitability tier order
@@ -76,6 +80,15 @@ export function classifyFinding(finding, fileContents) {
76
80
  return { verdict: 'survived', reasons: ['no context-matched control found between source and sink'] };
77
81
  }
78
82
 
83
+ // Map a falsification-style verdict onto the verification vocabulary.
84
+ // 'blocked'/'refuted' = the finding was disproved on this lens; 'survived' =
85
+ // the attempt to disprove it failed, so the finding stands on this lens.
86
+ function _verdictFor(v) {
87
+ if (v === 'blocked' || v === 'refuted' || v === 'false-positive') return 'refuted';
88
+ if (v === 'survived' || v === 'upheld' || v === 'true-positive') return 'upheld';
89
+ return 'undecided';
90
+ }
91
+
79
92
  /**
80
93
  * Default-on annotator. Adds `finding.falsification = { verdict, reasons }` to
81
94
  * every taint-style finding; demotes + quarantines the ones falsified as blocked.
@@ -95,6 +108,23 @@ export function annotateFalsification(findings, fileContents, opts = {}) {
95
108
  catch { res = { verdict: 'unproven', reasons: ['classification error'] }; }
96
109
  f.falsification = { verdict: res.verdict, reasons: res.reasons };
97
110
 
111
+ // R7 — enforced separation. The detector produced this finding; the
112
+ // falsification pass is a *different* party, and records its verdict only
113
+ // after the separation check passes. Recall-preserving: a 'refuted'
114
+ // verdict is recorded, never acted on by deletion or severity change.
115
+ try {
116
+ recordProducer(f, producerIdOf(f));
117
+ if (assertSeparation(f, VERIFIER_FALSIFICATION).ok) {
118
+ recordVerdict(f, {
119
+ verifierId: VERIFIER_FALSIFICATION,
120
+ lens: 'control-flow',
121
+ verdict: _verdictFor(res.verdict),
122
+ reason: res.reasons && res.reasons[0],
123
+ });
124
+ }
125
+ f.verification.consensus = consensusOf(f);
126
+ } catch { /* verification bookkeeping is advisory; never break the scan */ }
127
+
98
128
  if (res.verdict === 'blocked') {
99
129
  f.quarantined = true;
100
130
  if (typeof f.confidence === 'number') {
@@ -113,7 +143,21 @@ export function annotateFalsification(findings, fileContents, opts = {}) {
113
143
  for (const f of survivors) {
114
144
  try {
115
145
  const llm = opts.llmReview(f);
116
- if (llm) f.falsification.llm = llm;
146
+ if (llm) {
147
+ f.falsification.llm = llm;
148
+ // A second, independently-identified verifier arguing the opposing
149
+ // case — this is what makes a contested finding visible as contested
150
+ // rather than resolved by whoever spoke last.
151
+ if (assertSeparation(f, VERIFIER_LLM_REVIEW).ok) {
152
+ recordVerdict(f, {
153
+ verifierId: VERIFIER_LLM_REVIEW,
154
+ lens: 'llm-review',
155
+ verdict: _verdictFor(llm.verdict),
156
+ reason: llm.reason,
157
+ });
158
+ f.verification.consensus = consensusOf(f);
159
+ }
160
+ }
117
161
  } catch { /* the LLM tier is advisory; never let it break the scan */ }
118
162
  }
119
163
  }
@@ -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 };
@@ -6,6 +6,10 @@
6
6
  // 1. The original finding's stableId no longer fires on the patched file.
7
7
  // 2. No new findings at severity ≥ medium were introduced by the patch.
8
8
  // 3. The project's existing linter (when present) passes on the patched file.
9
+ // 4. The project's own test suite (when detectable) still passes. This is
10
+ // the R5 gap-closer: a patch that silently deletes the feature would
11
+ // satisfy (1) and (2) just as well as a real fix — only running the
12
+ // tests catches that. See `test-runner.js` for detection + execution.
9
13
  //
10
14
  // If any of those fail, the caller is expected to NOT apply the patch and
11
15
  // instead surface a "fix plan" — a numbered list of steps the engineer can
@@ -16,6 +20,8 @@ import * as fs from 'node:fs';
16
20
  import * as path from 'node:path';
17
21
  import { runFullScan } from '../engine.js';
18
22
  import { gateFixOutput } from './fix-honesty-gate.js';
23
+ import { runProjectTests } from './test-runner.js';
24
+ import { recordFixAttempt } from './fix-metrics.js';
19
25
 
20
26
  const SEVERITY_RANK = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
21
27
 
@@ -119,27 +125,148 @@ function runLinter(cwd, cmd, args) {
119
125
  // dishonest or over-claiming fix fails the gate. When `fixMeta` is absent
120
126
  // (the deterministic MCP write path, which has no claims to check) the honesty
121
127
  // gate is skipped and behavior is unchanged.
128
+ // R5 (partial) — the test-suite stage. Runs the target project's own tests,
129
+ // in the target project's own directory, against whatever is currently on
130
+ // disk there. See `test-runner.js`'s header comment for why that run is
131
+ // deliberately NOT routed through the R1 PoC-confinement sandbox: this is
132
+ // the project's own already-trusted suite, not untrusted synthesized code.
133
+ //
134
+ // Caveat that matters for callers: `verifyPatch` above re-scans the
135
+ // candidate patch purely in memory (no write to disk), but a test runner
136
+ // needs real files — there is no cheap way to hand a runner an in-memory
137
+ // overlay. So this leg reports on the CURRENT on-disk tree, not the
138
+ // candidate `files` map, when `verifyFix` is used as a pre-write preview
139
+ // (e.g. the `verify_fix` MCP tool). Callers that apply the patch first and
140
+ // then re-verify get the strongest signal; that ordering is not enforced
141
+ // here — it's the caller's responsibility, same as it already is for the
142
+ // closed-loop `fix-verify-loop.js` path.
143
+ // Does the caller's candidate patch differ from what is on disk right now?
144
+ // If so, any test run necessarily exercised the pre-patch tree. Compared by
145
+ // content so a patch that happens to match disk (already applied) is correctly
146
+ // treated as NOT pre-patch.
147
+ function _candidateDiffersFromDisk(scanRoot, files) {
148
+ if (!files || typeof files !== 'object') return false;
149
+ for (const [rel, content] of Object.entries(files)) {
150
+ if (typeof content !== 'string') continue;
151
+ try {
152
+ const abs = path.resolve(scanRoot, rel);
153
+ if (fs.readFileSync(abs, 'utf8') !== content) return true;
154
+ } catch {
155
+ return true; // candidate file absent on disk -> definitely not applied
156
+ }
157
+ }
158
+ return false;
159
+ }
160
+
122
161
  export async function verifyFix({
123
162
  scanRoot,
124
163
  originalFindingStableId,
125
164
  files,
126
165
  depFileContents,
127
166
  fixMeta,
167
+ testTimeoutMs,
168
+ recordMetrics = true,
169
+ poc,
128
170
  } = {}) {
171
+ // R5 (reporting half) — time each stage as it runs. Measured here rather
172
+ // than inside each stage because only this function knows the boundaries of
173
+ // one verification ATTEMPT, which is the unit the distribution is over.
174
+ const stages = {};
175
+ const t0 = Date.now();
176
+ let mark = t0;
177
+ const _lap = (name) => { const now = Date.now(); stages[name] = now - mark; mark = now; };
178
+
129
179
  const rescan = await verifyPatch({ scanRoot, originalFindingStableId, files, depFileContents });
180
+ _lap('rescan');
130
181
  const lint = runProjectLinter(scanRoot, Object.keys(files || {}));
182
+ _lap('lint');
183
+ const tests = runProjectTests(scanRoot, testTimeoutMs != null ? { timeoutMs: testTimeoutMs } : {});
184
+ _lap('tests');
185
+ // True when a candidate patch was supplied but has not been written, so the
186
+ // suite necessarily ran against the pre-patch tree. Surfaced in the summary
187
+ // and on the result so a caller cannot mistake it for a verified patch.
188
+ const _testedPrePatch = !tests.skipped && _candidateDiffersFromDisk(scanRoot, files);
189
+ const testsOk = tests.skipped ? true : tests.passed === true;
131
190
  let honesty = null;
132
191
  if (fixMeta && typeof fixMeta === 'object') {
133
192
  try { honesty = gateFixOutput(fixMeta); } catch { honesty = null; }
134
193
  }
135
- const ok = rescan.ok && (lint.ok || lint.skipped) && (honesty ? honesty.ok : true);
194
+ _lap('honesty');
195
+
196
+ // R5 — the PoC leg. Re-run the finding's proof-of-concept against the
197
+ // CANDIDATE patch inside R1's sandbox. A patch that still lets the PoC
198
+ // demonstrate the predicted effect has not fixed anything, however green the
199
+ // re-scan looks: the re-scan only proves the DETECTOR stopped firing, which
200
+ // a cosmetic edit can achieve. Execution is the stronger claim.
201
+ //
202
+ // Direction matters and is asymmetric on purpose. `execution-proven` after
203
+ // the patch is a hard FAIL. Anything else is NOT a pass — a PoC that failed
204
+ // to run, or a sandbox that could not start, is recorded as `inconclusive`
205
+ // and left out of the verdict entirely. Treating "could not prove it" as
206
+ // "fixed" is exactly the false confidence this leg exists to prevent.
207
+ let pocLeg = { status: 'not-requested', reason: null, tier: null };
208
+ if (poc?.code) {
209
+ try {
210
+ const { proveFinding } = await import('./execution-proof.js');
211
+ const proved = await proveFinding({ ...(poc.finding || {}), poc }, { files });
212
+ const tier = proved.proofTier;
213
+ pocLeg = tier === 'execution-proven'
214
+ ? { status: 'still-exploitable', tier, reason: proved.proofEvidence?.observed || null }
215
+ : proved.proofEvidence?.ran
216
+ ? { status: 'no-longer-proven', tier, reason: proved.proofEvidence?.reason || null }
217
+ : { status: 'inconclusive', tier, reason: proved.proofEvidence?.reason || null };
218
+ } catch (e) {
219
+ pocLeg = { status: 'inconclusive', tier: null, reason: `proof harness error: ${e.message}` };
220
+ }
221
+ }
222
+ _lap('poc');
223
+ const pocOk = pocLeg.status !== 'still-exploitable';
224
+
225
+ const ok = rescan.ok && (lint.ok || lint.skipped) && testsOk && pocOk && (honesty ? honesty.ok : true);
226
+ const durations = { ...stages, totalMs: Date.now() - t0 };
136
227
  const summary = [
137
228
  `re-scan: ${rescan.ok ? 'PASS' : 'FAIL — ' + rescan.reason}`,
138
229
  `linter: ${lint.runner === 'none' ? 'skipped (no linter config)'
139
230
  : lint.skipped ? `${lint.runner} not installed`
140
231
  : lint.ok ? `${lint.runner} PASS`
141
232
  : `${lint.runner} FAIL (exit ${lint.exitCode})`}`,
233
+ // Say which tree the suite actually ran against. `files` is a candidate
234
+ // patch held in memory; the runner needs real files, so it sees whatever is
235
+ // on disk. Reporting a bare "PASS" here would let a caller believe the
236
+ // PATCH passed the tests when the suite may have run on unpatched code.
237
+ `tests: ${tests.skipped ? `skipped (${tests.reason})`
238
+ : tests.timedOut ? 'FAIL (timed out)'
239
+ : tests.passed ? `PASS${_testedPrePatch ? ' — on the CURRENT on-disk tree, NOT the candidate patch' : ''}`
240
+ : `FAIL (exit ${tests.exitCode})`}`,
142
241
  honesty ? `honesty: ${honesty.ok ? `PASS (${honesty.tier})` : 'FAIL — ' + honesty.violations.join('; ')}` : null,
242
+ // Never render `inconclusive` as a pass — say plainly that nothing was proven.
243
+ pocLeg.status === 'not-requested' ? null
244
+ : pocLeg.status === 'still-exploitable' ? `poc: FAIL — the proof-of-concept still demonstrates the vulnerability against the patch`
245
+ : pocLeg.status === 'no-longer-proven' ? 'poc: PASS (ran against the patch and no longer demonstrates the vulnerability)'
246
+ : `poc: inconclusive — not counted either way (${pocLeg.reason || 'no detail reported'})`,
143
247
  ].filter(Boolean).join('\n');
144
- return { ok, rescan, lint, honesty, summary };
248
+ // Persist the attempt so the distribution can be reported from real runs.
249
+ // `testsRan` is the load-bearing field: it is what keeps "verified with no
250
+ // test suite to run" out of the headline time-to-validated-fix bucket.
251
+ // A patch that was never written to disk is recorded too, but flagged — its
252
+ // suite ran against the pre-patch tree, so its timing is real while its
253
+ // verdict is about a different tree.
254
+ if (recordMetrics && scanRoot) {
255
+ recordFixAttempt(scanRoot, {
256
+ at: new Date().toISOString(),
257
+ stableId: originalFindingStableId || null,
258
+ ok,
259
+ testsRan: !tests.skipped,
260
+ testsPassed: tests.skipped ? null : tests.passed === true,
261
+ testedPrePatch: _testedPrePatch,
262
+ lintRan: !(lint.skipped || lint.runner === 'none'),
263
+ honestyGated: honesty != null,
264
+ pocStatus: pocLeg.status,
265
+ files: Object.keys(files || {}).length,
266
+ stages,
267
+ totalMs: durations.totalMs,
268
+ });
269
+ }
270
+
271
+ return { ok, rescan, lint, tests, testedPrePatch: _testedPrePatch, honesty, poc: pocLeg, durations, summary };
145
272
  }
@@ -14,7 +14,7 @@
14
14
 
15
15
  import * as fs from 'node:fs';
16
16
  import * as path from 'node:path';
17
- import * as yaml from 'js-yaml';
17
+ import * as yaml from '../util/yaml.js';
18
18
 
19
19
  const DEFAULT_POLICY = {
20
20
  allow: [],
@@ -5,7 +5,7 @@
5
5
 
6
6
  import * as fs from 'node:fs';
7
7
  import * as path from 'node:path';
8
- import * as yaml from 'js-yaml';
8
+ import * as yaml from '../util/yaml.js';
9
9
  import { statePath, safeWriteState, resolveProjectRoot } from './state-dir.js';
10
10
 
11
11
  export const PROFILES = ['vibecoder', 'pro'];
@@ -0,0 +1,33 @@
1
+ // How strongly a finding is backed by evidence.
2
+ //
3
+ // execution-proven — a proof-of-concept RAN inside the sandbox and produced
4
+ // the predicted observable effect. The strongest claim.
5
+ // proof-failed — a proof-of-concept ran and did NOT demonstrate the bug.
6
+ // A triage signal, NOT an automatic false-positive verdict:
7
+ // absence of proof is not proof of absence.
8
+ // taint-proven — the analyser's static reasoning found it; nothing executed.
9
+ // unproven — no analyser backing recorded.
10
+ export const PROOF_TIERS = Object.freeze([
11
+ 'execution-proven', 'proof-failed', 'taint-proven', 'unproven',
12
+ ]);
13
+
14
+ // Parsers that represent real analysis rather than a plain pattern match.
15
+ const _ANALYSED = new Set(['IR-TAINT', 'MULTI-SINK']);
16
+
17
+ export function proofTierOf(finding) {
18
+ if (finding?.proofTier) return finding.proofTier;
19
+ return _ANALYSED.has(finding?.parser) ? 'taint-proven' : 'unproven';
20
+ }
21
+
22
+ export function attachProofTier(finding, evidence) {
23
+ if (!PROOF_TIERS.includes(evidence?.tier)) {
24
+ throw new Error(`unknown proof tier: ${evidence?.tier}`);
25
+ }
26
+ let tier = evidence.tier;
27
+ // Guard the central honesty rule: nothing that did not RUN may be called
28
+ // execution-proven or proof-failed. Fall back to the finding's static standing.
29
+ if (!evidence.ran && (tier === 'execution-proven' || tier === 'proof-failed')) {
30
+ tier = proofTierOf({ ...finding, proofTier: undefined });
31
+ }
32
+ return { ...finding, proofTier: tier, proofEvidence: { ...evidence, tier } };
33
+ }