@clear-capabilities/agentic-security-scanner 0.130.0 → 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.
- package/CHANGELOG.md +122 -0
- package/bin/agentic-security.js +19 -2
- package/dist/113.index.js +292 -3
- package/dist/207.index.js +7 -4
- package/dist/238.index.js +218 -0
- package/dist/259.index.js +975 -0
- package/dist/435.index.js +2 -2
- package/dist/526.index.js +292 -3
- package/dist/agentic-security.mjs +23 -62
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +17 -9
- package/src/engine.js +16 -0
- package/src/ir/parser-js.js +8 -0
- package/src/mcp/tools.js +2 -2
- package/src/posture/CLAUDE.md +83 -6
- package/src/posture/attestation.js +7 -4
- package/src/posture/corpus-enroll.js +303 -0
- package/src/posture/corpus-match.js +52 -0
- package/src/posture/custom-rules.js +2 -2
- package/src/posture/execution-proof.js +44 -4
- package/src/posture/fix-metrics.js +197 -0
- package/src/posture/fix-verify.js +76 -2
- package/src/posture/root-cause-sweep.js +0 -0
- package/src/runScan.js +2 -6
- package/src/sandbox/CLAUDE.md +168 -46
- package/src/sandbox/backend-namespace.js +292 -40
- package/src/sandbox/backend-userspace.js +2 -19
- package/src/sandbox/capabilities.js +132 -4
- package/src/sandbox/limits.js +21 -0
- package/src/sandbox/result.js +1 -1
- package/src/util/glob.js +173 -0
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 };
|
|
@@ -21,6 +21,7 @@ import * as path from 'node:path';
|
|
|
21
21
|
import { runFullScan } from '../engine.js';
|
|
22
22
|
import { gateFixOutput } from './fix-honesty-gate.js';
|
|
23
23
|
import { runProjectTests } from './test-runner.js';
|
|
24
|
+
import { recordFixAttempt } from './fix-metrics.js';
|
|
24
25
|
|
|
25
26
|
const SEVERITY_RANK = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
26
27
|
|
|
@@ -164,10 +165,23 @@ export async function verifyFix({
|
|
|
164
165
|
depFileContents,
|
|
165
166
|
fixMeta,
|
|
166
167
|
testTimeoutMs,
|
|
168
|
+
recordMetrics = true,
|
|
169
|
+
poc,
|
|
167
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
|
+
|
|
168
179
|
const rescan = await verifyPatch({ scanRoot, originalFindingStableId, files, depFileContents });
|
|
180
|
+
_lap('rescan');
|
|
169
181
|
const lint = runProjectLinter(scanRoot, Object.keys(files || {}));
|
|
182
|
+
_lap('lint');
|
|
170
183
|
const tests = runProjectTests(scanRoot, testTimeoutMs != null ? { timeoutMs: testTimeoutMs } : {});
|
|
184
|
+
_lap('tests');
|
|
171
185
|
// True when a candidate patch was supplied but has not been written, so the
|
|
172
186
|
// suite necessarily ran against the pre-patch tree. Surfaced in the summary
|
|
173
187
|
// and on the result so a caller cannot mistake it for a verified patch.
|
|
@@ -177,7 +191,39 @@ export async function verifyFix({
|
|
|
177
191
|
if (fixMeta && typeof fixMeta === 'object') {
|
|
178
192
|
try { honesty = gateFixOutput(fixMeta); } catch { honesty = null; }
|
|
179
193
|
}
|
|
180
|
-
|
|
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 };
|
|
181
227
|
const summary = [
|
|
182
228
|
`re-scan: ${rescan.ok ? 'PASS' : 'FAIL — ' + rescan.reason}`,
|
|
183
229
|
`linter: ${lint.runner === 'none' ? 'skipped (no linter config)'
|
|
@@ -193,6 +239,34 @@ export async function verifyFix({
|
|
|
193
239
|
: tests.passed ? `PASS${_testedPrePatch ? ' — on the CURRENT on-disk tree, NOT the candidate patch' : ''}`
|
|
194
240
|
: `FAIL (exit ${tests.exitCode})`}`,
|
|
195
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'})`,
|
|
196
247
|
].filter(Boolean).join('\n');
|
|
197
|
-
|
|
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 };
|
|
198
272
|
}
|
|
Binary file
|
package/src/runScan.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import * as fs from 'node:fs/promises';
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
import * as cp from 'node:child_process';
|
|
6
|
-
import
|
|
6
|
+
import { listFiles } from './util/glob.js';
|
|
7
7
|
import { runFullScan, shouldScan } from './engine.js';
|
|
8
8
|
import { appendScanSnapshot } from './posture/security-trend.js';
|
|
9
9
|
import { recover as recoverFixHistory } from './posture/fix-history.js';
|
|
@@ -26,11 +26,7 @@ const DEFAULT_IGNORE = [
|
|
|
26
26
|
];
|
|
27
27
|
|
|
28
28
|
export async function readTree(root, { ignore = [] } = {}) {
|
|
29
|
-
const entries = await
|
|
30
|
-
cwd: root, dot: true, onlyFiles: true,
|
|
31
|
-
ignore: [...DEFAULT_IGNORE, ...ignore], followSymbolicLinks: false,
|
|
32
|
-
suppressErrors: true,
|
|
33
|
-
});
|
|
29
|
+
const entries = await listFiles(root, { ignore: [...DEFAULT_IGNORE, ...ignore] });
|
|
34
30
|
const fileContents = {};
|
|
35
31
|
const depFileContents = {};
|
|
36
32
|
for (const rel of entries) {
|