@clear-capabilities/agentic-security-scanner 0.170.0 → 0.172.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 +38 -0
- package/bin/agentic-security.js +6 -0
- package/dist/1336.index.js +891 -719
- package/dist/550.index.js +548 -0
- package/dist/7405.index.js +868 -0
- package/dist/9382.index.js +4220 -0
- package/dist/9579.index.js +1197 -0
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +3 -3
- package/src/context-intelligence/CLAUDE.md +21 -0
- package/src/context-intelligence/adapters/existing-ir.js +14 -0
- package/src/context-intelligence/adapters/haskell-hie.js +138 -0
- package/src/context-intelligence/build.js +68 -0
- package/src/context-intelligence/candidates.js +115 -0
- package/src/context-intelligence/canonical.js +95 -0
- package/src/context-intelligence/capabilities.js +33 -0
- package/src/context-intelligence/cli.js +422 -0
- package/src/context-intelligence/config.js +90 -0
- package/src/context-intelligence/controls.js +194 -0
- package/src/context-intelligence/diff.js +97 -0
- package/src/context-intelligence/entrypoints.js +336 -0
- package/src/context-intelligence/evaluation/index.js +228 -0
- package/src/context-intelligence/evaluation/runner.js +139 -0
- package/src/context-intelligence/evaluation/scripted-reader.js +279 -0
- package/src/context-intelligence/files.js +80 -0
- package/src/context-intelligence/graph-schema.js +227 -0
- package/src/context-intelligence/imports.js +123 -0
- package/src/context-intelligence/manifest.js +99 -0
- package/src/context-intelligence/packer.js +224 -0
- package/src/context-intelligence/persist.js +40 -0
- package/src/context-intelligence/prompts/packs.js +82 -0
- package/src/context-intelligence/queue.js +355 -0
- package/src/context-intelligence/reachability.js +182 -0
- package/src/context-intelligence/repo-info.js +16 -0
- package/src/context-intelligence/review.js +321 -0
- package/src/context-intelligence/spans.js +147 -0
- package/src/context-intelligence/store.js +203 -0
- package/src/posture/artifact-registry.js +2 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
//
|
|
2
|
+
// `agentic-security context eval`: the frozen synthetic suite, scored.
|
|
3
|
+
//
|
|
4
|
+
// Honest by construction:
|
|
5
|
+
// * every scorecard says on its face that the suite and the reader are synthetic and that no accuracy claim follows;
|
|
6
|
+
// * intervals come from the repo's existing grouped bootstrap (a case is one group; fewer than 10 groups is "unmeasured");
|
|
7
|
+
// * the holdout split is scored only through a ledger that counts every use, and non-default prompt variants are refused
|
|
8
|
+
// on it unless the run is declared final;
|
|
9
|
+
// * a label-leakage audit checks that neither the corpus nor any prompt a system saw contains the label vocabulary.
|
|
10
|
+
import * as fs from 'node:fs';
|
|
11
|
+
import * as path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { sha256, canonicalJson, hashOf } from '../canonical.js';
|
|
14
|
+
import { writeStoreJson, readStoreFile } from '../store.js';
|
|
15
|
+
import { groupedBootstrap, wilsonInterval, f1Of, precisionOf, recallOf } from '../../posture/evaluation/interval.js';
|
|
16
|
+
import { runCase, SYSTEMS } from './runner.js';
|
|
17
|
+
import { readerIdentity } from './scripted-reader.js';
|
|
18
|
+
|
|
19
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
// The bundled synthetic suite lives in the source tree's fixtures, found from the package root. Scan code must not
|
|
21
|
+
// read evaluation labels, so this is reachable only from the explicit `context eval` command, and `--suite-dir` /
|
|
22
|
+
// AGENTIC_SECURITY_DCI_EVAL_SUITE can point at any other suite in the same layout.
|
|
23
|
+
function packageRoot(from) {
|
|
24
|
+
let dir = from;
|
|
25
|
+
for (let i = 0; i < 8; i++) {
|
|
26
|
+
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
|
27
|
+
dir = path.dirname(dir);
|
|
28
|
+
}
|
|
29
|
+
return from;
|
|
30
|
+
}
|
|
31
|
+
export const DEFAULT_SUITE_DIR = process.env.AGENTIC_SECURITY_DCI_EVAL_SUITE
|
|
32
|
+
? path.resolve(process.env.AGENTIC_SECURITY_DCI_EVAL_SUITE)
|
|
33
|
+
: path.join(packageRoot(HERE), ['test', 'fixtures', 'context-intelligence', 'eval'].join(path.sep));
|
|
34
|
+
export const VARIANTS = Object.freeze(['dci', 'dci-all-packs']);
|
|
35
|
+
export const FROZEN_VARIANT = 'dci';
|
|
36
|
+
const FLAG = (args, k) => (args && args[k] !== undefined ? args[k] : undefined);
|
|
37
|
+
const truthy = (v) => v === true || v === 'true' || v === '1';
|
|
38
|
+
|
|
39
|
+
const LEAK_TERMS = /\b(?:vulnerable|vulnerability|idor|bola|bfla|exploit|insecure|label|holdout|ground.?truth|expected result)\b/i;
|
|
40
|
+
|
|
41
|
+
function loadSuite(dir) {
|
|
42
|
+
const read = (f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
|
|
43
|
+
const labels = read('labels.json');
|
|
44
|
+
const split = read('split.json');
|
|
45
|
+
return { labels, split, dir };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function walk(dir, out = []) {
|
|
49
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
50
|
+
const p = path.join(dir, e.name);
|
|
51
|
+
if (e.isDirectory()) walk(p, out); else if (e.isFile()) out.push(p);
|
|
52
|
+
}
|
|
53
|
+
return out.sort();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function suiteHash(dir) {
|
|
57
|
+
const files = walk(dir).map(f => ({ f: path.relative(dir, f), h: sha256(fs.readFileSync(f)) }));
|
|
58
|
+
return hashOf(files);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The corpus and every prompt a system saw must not contain the label vocabulary. Returns hits (empty = clean). */
|
|
62
|
+
export function leakageAudit(dir, promptsBySystem) {
|
|
63
|
+
const hits = [];
|
|
64
|
+
for (const f of walk(path.join(dir, 'corpus'))) {
|
|
65
|
+
const text = fs.readFileSync(f, 'utf8');
|
|
66
|
+
const m = LEAK_TERMS.exec(text);
|
|
67
|
+
if (m) hits.push({ where: 'corpus', file: path.relative(dir, f), term: m[0] });
|
|
68
|
+
}
|
|
69
|
+
for (const [system, prompts] of Object.entries(promptsBySystem)) {
|
|
70
|
+
for (const p of prompts) {
|
|
71
|
+
const body = String(p);
|
|
72
|
+
const m = /\bc(?:0[1-9]|1[01])\b/.exec(body) || /\bdci-eval\b/.exec(body);
|
|
73
|
+
if (m) { hits.push({ where: `prompt:${system}`, term: m[0] }); break; }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return hits;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function tally(units) {
|
|
80
|
+
const c = { tp: 0, fp: 0, fn: 0, tn: 0 };
|
|
81
|
+
for (const u of units) { c.tp += u.tp; c.fp += u.fp; c.fn += u.fn; c.tn += u.tn; }
|
|
82
|
+
return c;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function metricsFor(units, protocolHash, name) {
|
|
86
|
+
const c = tally(units);
|
|
87
|
+
const groupOf = Object.fromEntries(units.map(u => [u.id, u.caseId]));
|
|
88
|
+
const f1 = groupedBootstrap({ units, groupOf, statistic: ({ total }) => f1Of(total), name: `${name}:f1`, protocolHash });
|
|
89
|
+
const pn = c.tp + c.fp;
|
|
90
|
+
return {
|
|
91
|
+
counts: c, units: units.length,
|
|
92
|
+
precision: precisionOf(c), recall: recallOf(c), f1: f1Of(c),
|
|
93
|
+
precisionInterval: pn > 0 ? wilsonInterval(c.tp, pn) : { status: 'unmeasured', reason: 'nothing was reported' },
|
|
94
|
+
f1Interval: f1,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Nearest-rank percentile of a numeric list (null when empty). */
|
|
99
|
+
export function percentile(values, q) {
|
|
100
|
+
if (!values.length) return null;
|
|
101
|
+
const v = [...values].sort((a, b) => a - b);
|
|
102
|
+
return v[Math.min(v.length - 1, Math.max(0, Math.ceil(q * v.length) - 1))];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Context-size, call and cost accounting for one system. Token figures are chars/4 estimates; no price is assumed. */
|
|
106
|
+
export function economicsOf({ promptSizes, calls, wallMs, metrics }) {
|
|
107
|
+
const tokens = promptSizes.map(n => Math.ceil(n / 4));
|
|
108
|
+
const tp = metrics.counts.tp;
|
|
109
|
+
return {
|
|
110
|
+
tokenizer: 'chars/4-estimate',
|
|
111
|
+
promptsPerCase: promptSizes.length,
|
|
112
|
+
contextTokens: { median: percentile(tokens, 0.5), p95: percentile(tokens, 0.95), p99: percentile(tokens, 0.99), total: tokens.reduce((a, b) => a + b, 0) },
|
|
113
|
+
modelCalls: calls,
|
|
114
|
+
callsPerTruePositive: tp > 0 ? calls / tp : null,
|
|
115
|
+
wallMs,
|
|
116
|
+
cost: { usd: null, basis: 'no price supplied; calls and estimated tokens are reported instead' },
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function ledgerPath() { return 'reports/eval-ledger.json'; }
|
|
121
|
+
function readLedger(root) {
|
|
122
|
+
const r = readStoreFile(root, ledgerPath());
|
|
123
|
+
if (!r.ok) return { schemaVersion: 'eval-ledger/1', holdoutScoredRuns: 0, devScoredRuns: 0, runs: [] };
|
|
124
|
+
try { return JSON.parse(r.text); } catch { return { schemaVersion: 'eval-ledger/1', holdoutScoredRuns: 0, devScoredRuns: 0, runs: [], note: 'previous ledger unreadable; counts restart' }; }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const pct = (x) => (x === null || x === undefined ? 'n/a' : (x * 100).toFixed(0) + '%');
|
|
128
|
+
const interval = (i) => (!i || i.status !== 'measured' ? `unmeasured (${i && i.reason ? i.reason.split(',')[0] : 'no data'})` : `${pct(i.low)} to ${pct(i.high)}`);
|
|
129
|
+
|
|
130
|
+
export function renderMarkdown(card) {
|
|
131
|
+
const L = [];
|
|
132
|
+
L.push('# Context Intelligence evaluation scorecard', '');
|
|
133
|
+
L.push('> SYNTHETIC SUITE, SCRIPTED READER. These cases and the reader were written by the same authors, with no independent adjudication and no real-world defect history. The numbers describe the context-building and verification machinery on synthetic cases. They are not evidence about any model, and they make no accuracy claim about real repositories.', '');
|
|
134
|
+
L.push(`- split: ${card.split} variant: ${card.variant} cases: ${card.caseCount}`);
|
|
135
|
+
L.push(`- suite hash: ${card.hashes.suite}`);
|
|
136
|
+
L.push(`- protocol hash: ${card.hashes.protocol}`);
|
|
137
|
+
L.push(`- reader: ${card.reader.kind} v${card.reader.version} ${card.reader.digest} (synthetic)`);
|
|
138
|
+
L.push(`- label leakage audit: ${card.leakage.length === 0 ? 'clean' : card.leakage.length + ' hit(s)'}`);
|
|
139
|
+
L.push(`- holdout scored runs so far (this machine's ledger): ${card.ledger.holdoutScoredRuns}`, '');
|
|
140
|
+
L.push('| system | status | TP | FP | FN | TN | precision | recall | F1 | F1 interval | model calls | prompt chars |', '|---|---|---|---|---|---|---|---|---|---|---|---|');
|
|
141
|
+
for (const s of card.systems) {
|
|
142
|
+
if (s.status !== 'measured') { L.push(`| ${s.system} | ${s.status}: ${s.reason} | | | | | | | | | | |`); continue; }
|
|
143
|
+
const m = s.metrics;
|
|
144
|
+
L.push(`| ${s.system} | measured | ${m.counts.tp} | ${m.counts.fp} | ${m.counts.fn} | ${m.counts.tn} | ${pct(m.precision)} | ${pct(m.recall)} | ${pct(m.f1)} | ${interval(m.f1Interval)} | ${s.calls} | ${s.promptChars} |`);
|
|
145
|
+
}
|
|
146
|
+
L.push('', '## Economics (estimated; no price supplied)', '', '| system | median ctx tokens | P95 | P99 | model calls | calls per true positive | wall ms |', '|---|---|---|---|---|---|---|');
|
|
147
|
+
for (const s of card.systems) if (s.economics) { const e = s.economics; L.push(`| ${s.system} | ${e.contextTokens.median ?? 'n/a'} | ${e.contextTokens.p95 ?? 'n/a'} | ${e.contextTokens.p99 ?? 'n/a'} | ${e.modelCalls} | ${e.callsPerTruePositive === null ? 'n/a' : e.callsPerTruePositive.toFixed(1)} | ${e.wallMs} |`); }
|
|
148
|
+
L.push('', `Replay: \`${card.replay}\``, '');
|
|
149
|
+
if (card.leakage.length) { L.push('## Leakage hits', ...card.leakage.map(h => `- ${h.where} ${h.file || ''} ${h.term}`), ''); }
|
|
150
|
+
return L.join('\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function renderCsv(card) {
|
|
154
|
+
const rows = [['system', 'case', 'variant', 'vulnerable', 'flagged', 'outcome', 'calls', 'promptChars']];
|
|
155
|
+
for (const s of card.systems) for (const u of (s.units || [])) rows.push([s.system, u.caseId, u.variant, u.vulnerable, u.flagged, u.outcome, u.calls, u.promptChars]);
|
|
156
|
+
return rows.map(r => r.join(',')).join('\n') + '\n';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function evaluateSuite({ suiteDir = DEFAULT_SUITE_DIR, split = 'dev', systems = ['deterministic', 'hunt', 'dci'], variant = FROZEN_VARIANT, root = null, final = false, onProgress = () => {} } = {}) {
|
|
160
|
+
const suite = loadSuite(suiteDir);
|
|
161
|
+
const ids = split === 'all' ? [...suite.split.dev, ...suite.split.holdout] : suite.split[split];
|
|
162
|
+
if (!ids) return { ok: false, reason: `unknown split '${split}' (dev, holdout, all)` };
|
|
163
|
+
if (!VARIANTS.includes(variant)) return { ok: false, reason: `unknown variant '${variant}' (${VARIANTS.join(', ')})` };
|
|
164
|
+
const touchesHoldout = split !== 'dev';
|
|
165
|
+
if (touchesHoldout && variant !== FROZEN_VARIANT && !final) return { ok: false, reason: `variant '${variant}' is not the frozen one; develop variants on the dev split. The holdout may only score the frozen variant unless the run is declared --final.` };
|
|
166
|
+
|
|
167
|
+
const protocol = { suite: suiteHash(suiteDir), split, variant, systems, reader: readerIdentity(), tolerance: 5 };
|
|
168
|
+
const protocolHash = hashOf(protocol);
|
|
169
|
+
const promptsBySystem = {};
|
|
170
|
+
const out = [];
|
|
171
|
+
for (const system of systems) {
|
|
172
|
+
if (!SYSTEMS.includes(system)) { out.push({ system, status: 'error', reason: 'unknown system' }); continue; }
|
|
173
|
+
const units = []; let calls = 0; let promptChars = 0; let blocked = null; const errors = []; const promptSizes = []; const t0 = Date.now();
|
|
174
|
+
for (const id of ids) {
|
|
175
|
+
const def = { id, ...suite.labels.cases[id] };
|
|
176
|
+
for (const key of Object.keys(def.variants).sort()) {
|
|
177
|
+
const v = def.variants[key];
|
|
178
|
+
onProgress({ system, case: id, variant: key });
|
|
179
|
+
const r = await runCase({ system, caseDef: def, variant: { key }, suiteDir, variantName: variant });
|
|
180
|
+
if (r.status === 'blocked') { blocked = r.reason; continue; }
|
|
181
|
+
if (r.status === 'error') { errors.push(`${id}/${key}: ${r.reason}`); continue; }
|
|
182
|
+
const flagged = !!r.flagged; const vuln = !!v.vulnerable;
|
|
183
|
+
const u = { id: `${id}/${key}`, caseId: id, variant: key, language: def.language, vulnerable: vuln, flagged, tp: vuln && flagged ? 1 : 0, fn: vuln && !flagged ? 1 : 0, fp: !vuln && flagged ? 1 : 0, tn: !vuln && !flagged ? 1 : 0, calls: r.calls, promptChars: r.promptChars, outcome: vuln ? (flagged ? 'tp' : 'fn') : (flagged ? 'fp' : 'tn'), detail: r.detail };
|
|
184
|
+
units.push(u); calls += r.calls; promptChars += r.promptChars;
|
|
185
|
+
(promptsBySystem[system] = promptsBySystem[system] || []).push(...r.prompts);
|
|
186
|
+
promptSizes.push(...r.prompts.map(x => String(x).length));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (blocked && units.length === 0) { out.push({ system, status: 'blocked', reason: blocked }); continue; }
|
|
190
|
+
if (errors.length) { out.push({ system, status: 'error', reason: `${errors.length} case run(s) failed: ${errors.slice(0, 3).join('; ')}` }); continue; }
|
|
191
|
+
const metrics = metricsFor(units, protocolHash, system);
|
|
192
|
+
out.push({ system, status: 'measured', metrics, units, calls, promptChars, economics: economicsOf({ promptSizes, calls, wallMs: Date.now() - t0, metrics }) });
|
|
193
|
+
}
|
|
194
|
+
const leakage = leakageAudit(suiteDir, promptsBySystem);
|
|
195
|
+
|
|
196
|
+
let ledger = { holdoutScoredRuns: 0, devScoredRuns: 0 };
|
|
197
|
+
if (root) {
|
|
198
|
+
ledger = readLedger(root);
|
|
199
|
+
if (touchesHoldout) ledger.holdoutScoredRuns += 1; else ledger.devScoredRuns += 1;
|
|
200
|
+
ledger.runs = [...(ledger.runs || []).slice(-49), { protocolHash, split, variant, systems }];
|
|
201
|
+
writeStoreJson(root, ledgerPath(), ledger);
|
|
202
|
+
}
|
|
203
|
+
const replay = `agentic-security context eval --suite fixtures --split ${split} --variant ${variant} --baseline ${systems.join(',')}`;
|
|
204
|
+
const card = { schemaVersion: 'dci-eval-scorecard/1', synthetic: true, claim: 'machinery on synthetic cases only; no accuracy claim about real repositories', split, variant, caseCount: ids.length, hashes: { suite: protocol.suite, protocol: protocolHash }, reader: readerIdentity(), leakage, ledger: { holdoutScoredRuns: ledger.holdoutScoredRuns, devScoredRuns: ledger.devScoredRuns }, systems: out, replay };
|
|
205
|
+
card.hashes.scorecard = hashOf({ ...card, ledger: null, systems: card.systems.map(x => (x.economics ? { ...x, economics: { ...x.economics, wallMs: null } } : x)) });
|
|
206
|
+
return { ok: true, card };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function cmdEval(root, args, format) {
|
|
210
|
+
const suiteArg = FLAG(args, 'suite');
|
|
211
|
+
if (suiteArg !== undefined && suiteArg !== 'fixtures' && suiteArg !== true) { console.log("context eval: only '--suite fixtures' is available (the bundled synthetic suite)"); return 1; }
|
|
212
|
+
if (!fs.existsSync(path.join(DEFAULT_SUITE_DIR, 'labels.json'))) { console.log('context eval: the synthetic suite is not present in this install (it ships with the source tree)'); return 1; }
|
|
213
|
+
const baseline = FLAG(args, 'baseline') && FLAG(args, 'baseline') !== true ? String(FLAG(args, 'baseline')).split(',').map(s => s.trim()).filter(Boolean) : ['deterministic', 'hunt', 'dci'];
|
|
214
|
+
const variant = FLAG(args, 'variant') && FLAG(args, 'variant') !== true ? String(FLAG(args, 'variant')) : FROZEN_VARIANT;
|
|
215
|
+
const split = FLAG(args, 'split') && FLAG(args, 'split') !== true ? String(FLAG(args, 'split')) : 'dev';
|
|
216
|
+
const r = await evaluateSuite({ split, variant, systems: baseline, root, final: truthy(FLAG(args, 'final')) });
|
|
217
|
+
if (!r.ok) { console.log(`context eval: ${r.reason}`); return 1; }
|
|
218
|
+
const card = r.card;
|
|
219
|
+
const base = `reports/eval-${card.hashes.scorecard.slice(0, 16)}`;
|
|
220
|
+
const w = [writeStoreJson(root, `${base}.json`, card)];
|
|
221
|
+
const md = renderMarkdown(card);
|
|
222
|
+
const fileWrites = [['.md', md], ['.csv', renderCsv(card)]];
|
|
223
|
+
const { writeStoreFile } = await import('../store.js');
|
|
224
|
+
for (const [ext, text] of fileWrites) w.push(writeStoreFile(root, base + ext, text));
|
|
225
|
+
if (format === 'json') console.log(JSON.stringify({ ...card, files: w.map(x => x && x.ok !== false) }, null, 2));
|
|
226
|
+
else console.log(md + `\nWritten: .agentic-security/context-intelligence/${base}.{json,md,csv}`);
|
|
227
|
+
return card.leakage.length || card.systems.some(s => s.status === 'error') ? 1 : 0;
|
|
228
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Runs the frozen synthetic suite through the compared systems and tallies per-case outcomes.
|
|
3
|
+
//
|
|
4
|
+
// Systems (each answers one question per case variant: "is a defect reported at the labeled sink?"):
|
|
5
|
+
// deterministic the default scanner (`runScan`) with state writes disabled; no model, no spend
|
|
6
|
+
// hunt the existing discovery pipeline (partition, lens hunters, confirm, panel, judge)
|
|
7
|
+
// dci DCI bundles + governed review + verified citations -> candidates (model hypotheses, pre-confirmation)
|
|
8
|
+
// dci-compiler dci with imported compiler metadata; BLOCKED unless the case ships a `hie-export.json`
|
|
9
|
+
// Both model-backed systems get the SAME injected reader, so the only thing that differs is the text put in front of it.
|
|
10
|
+
//
|
|
11
|
+
// The reader here is the scripted one, so every number is a statement about the machinery on synthetic cases.
|
|
12
|
+
import * as fs from 'node:fs';
|
|
13
|
+
import * as os from 'node:os';
|
|
14
|
+
import * as path from 'node:path';
|
|
15
|
+
import { runScan } from '../../runScan.js';
|
|
16
|
+
import { withStateWritesDisabled } from '../../posture/state-dir.js';
|
|
17
|
+
import { buildProjectIR } from '../../ir/index.js';
|
|
18
|
+
import { runDiscovery } from '../../discovery/index.js';
|
|
19
|
+
import { makeTaintProbe } from '../../discovery/index.js';
|
|
20
|
+
import { buildContext } from '../build.js';
|
|
21
|
+
import { loadConfig } from '../config.js';
|
|
22
|
+
import { planJobs, runQueue } from '../queue.js';
|
|
23
|
+
import { packById } from '../prompts/packs.js';
|
|
24
|
+
import { describeModel } from '../review.js';
|
|
25
|
+
import { reviewsToCandidates, adjudicate } from '../candidates.js';
|
|
26
|
+
import { scriptedReader } from './scripted-reader.js';
|
|
27
|
+
import { readSnapshot } from '../files.js';
|
|
28
|
+
|
|
29
|
+
export const SYSTEMS = Object.freeze(['deterministic', 'hunt', 'dci', 'dci-compiler']);
|
|
30
|
+
export const LINE_TOLERANCE = 5;
|
|
31
|
+
|
|
32
|
+
/** Line (1-based) of the first line containing `contains`, or null. */
|
|
33
|
+
export function locate(text, contains) {
|
|
34
|
+
const lines = String(text).split('\n');
|
|
35
|
+
for (let i = 0; i < lines.length; i++) if (lines[i].includes(contains)) return i + 1;
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function copyTree(src, dest) {
|
|
40
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
41
|
+
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
|
|
42
|
+
const s = path.join(src, e.name); const d = path.join(dest, e.name);
|
|
43
|
+
if (e.isDirectory()) copyTree(s, d); else if (e.isFile()) fs.copyFileSync(s, d);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function recorder(inner) {
|
|
48
|
+
const prompts = [];
|
|
49
|
+
const fn = async (prompt) => { prompts.push(prompt); return inner(prompt); };
|
|
50
|
+
fn.tokenizerName = inner.tokenizerName;
|
|
51
|
+
return { invoke: fn, prompts };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function hit(found, sink) {
|
|
55
|
+
return found.some(f => f.file === sink.file && Number.isInteger(f.line) && Math.abs(f.line - sink.line) <= LINE_TOLERANCE);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function runDeterministic(dir, sink) {
|
|
59
|
+
const { scan } = await withStateWritesDisabled(() => runScan(dir, { deep: true, deepInCi: true }));
|
|
60
|
+
const findings = [...(scan.findings || []), ...(scan.logicVulns || [])].filter(f => f && f.severity !== 'info');
|
|
61
|
+
return { flagged: hit(findings, sink), calls: 0, promptChars: 0, detail: { findings: findings.length }, prompts: [] };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function runHuntSystem(dir, sink, reader) {
|
|
65
|
+
const snap = await readSnapshot(dir, { maxFiles: 2000 });
|
|
66
|
+
const fileContents = snap.fileContents;
|
|
67
|
+
const ir = buildProjectIR(fileContents);
|
|
68
|
+
const rec = recorder(reader);
|
|
69
|
+
const res = await withStateWritesDisabled(() => runDiscovery({ perFileIR: ir.perFile, callGraph: ir.callGraph, fileContents }, { llmInvoke: rec.invoke, lenses: ['authz', 'business-logic'], maxAreas: 8 }));
|
|
70
|
+
const reported = [...res.fresh, ...res.duplicates];
|
|
71
|
+
const found = reported.map(f => ({ file: f.file, line: f.line }));
|
|
72
|
+
return { flagged: hit(found, sink), calls: rec.prompts.length, promptChars: rec.prompts.reduce((n, p) => n + p.length, 0), detail: { reported: reported.length, areas: res.focusAreas.length }, prompts: rec.prompts };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function runDciSystem(dir, sink, reader, { packIds, withCompiler }) {
|
|
76
|
+
const { config } = loadConfig(dir);
|
|
77
|
+
if (withCompiler) config.semanticMetadata = { mode: 'import', haskellHieDir: '.' };
|
|
78
|
+
const built = await buildContext(dir, { config });
|
|
79
|
+
if (!built.ok) return { flagged: false, calls: 0, promptChars: 0, detail: { buildFailed: built.reason }, prompts: [] };
|
|
80
|
+
const packs = packIds.map(packById).filter(Boolean);
|
|
81
|
+
const model = describeModel();
|
|
82
|
+
const { jobs } = planJobs({ bundles: built.bundles, packs, model });
|
|
83
|
+
const byEp = new Map(built.bundles.map(b => [b.entrypoint.id, b]));
|
|
84
|
+
const packMap = new Map(packs.map(p => [p.id, p]));
|
|
85
|
+
const loadJob = (job) => {
|
|
86
|
+
const bundles = job.entrypointIds.map(id => byEp.get(id));
|
|
87
|
+
if (bundles.some(b => !b || !b.manifest)) return { error: 'no bundle' };
|
|
88
|
+
return { pack: packMap.get(job.packId), entrypoints: bundles.map(b => b.entrypoint), manifests: bundles.map(b => b.manifest), packageTexts: bundles.map((b, i) => (b.pack.packages.find(p => p.index === job.packageIndexes[i]) || b.pack.packages[0]).text) };
|
|
89
|
+
};
|
|
90
|
+
const rec = recorder(reader);
|
|
91
|
+
const result = await runQueue(jobs, { root: built.root, fileContents: built.fileContents, model, invoker: { ok: true, invoke: rec.invoke }, loadJob, retryFailed: false, limits: { concurrency: 1, maxCalls: 500, maxWallSeconds: 300, maxTokensPerJob: 40000, maxRetries: 0, jobTimeoutMs: 30000, maxCostUsd: null, costPerCallUsd: null } });
|
|
92
|
+
const records = [];
|
|
93
|
+
for (const [, res] of result.results) {
|
|
94
|
+
if (res.state === 'succeeded' && res.review) { const lj = loadJob(res.job); if (!lj.error) records.push({ review: res.review, entrypoints: lj.entrypoints.map(e => ({ id: e.id, label: e.label })), pack: lj.pack }); }
|
|
95
|
+
}
|
|
96
|
+
const candidates = reviewsToCandidates(records);
|
|
97
|
+
const adj = await adjudicate(candidates, { taintProbe: makeTaintProbe(built.ir.perFile, built.ir.callGraph), scanRoot: built.root });
|
|
98
|
+
const found = candidates.map(c => ({ file: c.file, line: c.line }));
|
|
99
|
+
const tiers = {};
|
|
100
|
+
for (const f of adj.findings) { const t = (f.discovery && f.discovery.confirmation && f.discovery.confirmation.tier) || 'unknown'; tiers[t] = (tiers[t] || 0) + 1; }
|
|
101
|
+
return {
|
|
102
|
+
flagged: hit(found, sink), calls: rec.prompts.length, promptChars: rec.prompts.reduce((n, p) => n + p.length, 0),
|
|
103
|
+
detail: { jobs: jobs.length, candidates: candidates.length, coverage: { eligible: result.coverage.eligible, succeeded: result.coverage.succeeded, missing: result.coverage.missingCoverage }, confirmationTiers: tiers, bundles: { complete: built.summary.complete, partial: built.summary.partialBundles, unavailable: built.summary.unavailable } },
|
|
104
|
+
prompts: rec.prompts,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Run one system over one case variant in a throwaway copy. Never touches the source fixture.
|
|
110
|
+
* Returns { flagged, calls, promptChars, detail, prompts, status } where status is 'ok' or 'blocked'/'error'.
|
|
111
|
+
*/
|
|
112
|
+
export async function runCase({ system, caseDef, variant, suiteDir, variantName = 'dci' }) {
|
|
113
|
+
const v = caseDef.variants[variant.key];
|
|
114
|
+
const srcDir = path.join(suiteDir, 'corpus', caseDef.id, variant.key);
|
|
115
|
+
if (system === 'dci-compiler' && !fs.existsSync(path.join(srcDir, 'hie-export.json'))) {
|
|
116
|
+
return { status: 'blocked', reason: 'no compiler metadata export is shipped for this case (the exporter needs a build toolchain and is not run by DCI)', flagged: null, calls: 0, promptChars: 0, detail: {}, prompts: [] };
|
|
117
|
+
}
|
|
118
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dci-eval-'));
|
|
119
|
+
try {
|
|
120
|
+
copyTree(srcDir, tmp);
|
|
121
|
+
const sinkText = fs.readFileSync(path.join(tmp, v.sink.file), 'utf8');
|
|
122
|
+
const line = locate(sinkText, v.sink.contains);
|
|
123
|
+
if (line === null) return { status: 'error', reason: `sink text not found in ${v.sink.file}`, flagged: null, calls: 0, promptChars: 0, detail: {}, prompts: [] };
|
|
124
|
+
const sink = { file: v.sink.file, line };
|
|
125
|
+
const reader = scriptedReader();
|
|
126
|
+
let r;
|
|
127
|
+
if (system === 'deterministic') r = await runDeterministic(tmp, sink);
|
|
128
|
+
else if (system === 'hunt') r = await runHuntSystem(tmp, sink, reader);
|
|
129
|
+
else if (system === 'dci' || system === 'dci-compiler') {
|
|
130
|
+
const packIds = variantName === 'dci-all-packs' ? ['authz-idor', 'authz-function', 'transaction-integrity'] : [caseDef.pack];
|
|
131
|
+
r = await runDciSystem(tmp, sink, reader, { packIds, withCompiler: system === 'dci-compiler' });
|
|
132
|
+
} else return { status: 'error', reason: `unknown system ${system}`, flagged: null, calls: 0, promptChars: 0, detail: {}, prompts: [] };
|
|
133
|
+
return { status: 'ok', sink, ...r };
|
|
134
|
+
} catch (e) {
|
|
135
|
+
return { status: 'error', reason: String(e && e.message || e).slice(0, 200), flagged: null, calls: 0, promptChars: 0, detail: {}, prompts: [] };
|
|
136
|
+
} finally {
|
|
137
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
//
|
|
2
|
+
// A deterministic, rule-based "reader" that stands in for a model in the SYNTHETIC evaluation suite.
|
|
3
|
+
//
|
|
4
|
+
// WHAT IT IS. A small text analyser that answers the same two prompt formats a model would see (the DCI review prompt
|
|
5
|
+
// and the `hunt` prompt) using only what is literally written in the prompt. Because it is deterministic, a suite run
|
|
6
|
+
// is reproducible bit for bit and needs no network, key or spend, which is what lets the evaluation HARNESS be tested.
|
|
7
|
+
//
|
|
8
|
+
// WHAT IT IS NOT. It is not a language model and its results are not evidence about any model's ability. It was
|
|
9
|
+
// written by the same people who wrote the fixtures, so it is good at exactly the shapes those fixtures contain. Any
|
|
10
|
+
// score produced with it measures the context-construction and verification machinery on synthetic cases, never
|
|
11
|
+
// vulnerability-finding skill. The scorecard says so on its face.
|
|
12
|
+
//
|
|
13
|
+
// It reads ONLY the prompt. That is the point of the comparison: two systems differ in what text they put in front of
|
|
14
|
+
// the same reader, so a difference in outcome is a difference in context.
|
|
15
|
+
import * as crypto from 'node:crypto';
|
|
16
|
+
|
|
17
|
+
const ROUTE_CALL = /\b(?:app|router|server|api)\.(get|post|put|patch|delete|all)\s*\(\s*(['"`])([^'"`]+)\2/;
|
|
18
|
+
const FLASK_ROUTE = /@\w+\.route\(\s*(['"])([^'"]+)\1/;
|
|
19
|
+
const SCOTTY_ROUTE = /^\s*(get|post|put|patch|delete)\s+"([^"]+)"/;
|
|
20
|
+
const UNTRUSTED = /\b(?:req|request)\.(?:params|query|body|args|form)\b|\bparam\s+"|<int:\w+>|<\w+>|\bnote_id\b|\bitem_id\b/;
|
|
21
|
+
const DATA = /\b(?:db|tx|conn|pool|client|repo)\.(?:query|execute|run)\s*\(|\bquery\s+conn\b|\bexecute\s+conn\b|\bdb\.execute\(/;
|
|
22
|
+
const WRITE = /\b(?:UPDATE|DELETE|INSERT)\b/i;
|
|
23
|
+
const QUALIFIED_PRINCIPAL = /\b(?:req\.user|request\.user|current_user|session\.user|principal|ctx\.user|userId\s+u)\b/;
|
|
24
|
+
const OWNER_COL = /\b(?:owner|owner_id|org_id|tenant_id|user_id|account_id)\b|\bowner\s*=/i;
|
|
25
|
+
const ROLE_GUARD = /\b(?:requireAdmin|isAdmin|role\s*!==?\s*['"]admin['"]|hasRole|requireRole)\b/;
|
|
26
|
+
|
|
27
|
+
function numbered(text) {
|
|
28
|
+
// " 12| code" lines, as the DCI packer renders them
|
|
29
|
+
const out = [];
|
|
30
|
+
for (const raw of text.split('\n')) {
|
|
31
|
+
const m = /^\s*(\d+)\| ?(.*)$/.exec(raw);
|
|
32
|
+
if (m) out.push({ n: +m[1], text: m[2] });
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Blocks from a DCI review prompt: [{file, lines:[{n,text}]}] plus metadata the packer wrote into the header. */
|
|
38
|
+
export function blocksFromReviewPrompt(prompt) {
|
|
39
|
+
const blocks = [];
|
|
40
|
+
const re = /^=== SOURCE (\S+):(\d+)-(\d+) \|[^\n]*===\n/gm;
|
|
41
|
+
const marks = [];
|
|
42
|
+
let m;
|
|
43
|
+
while ((m = re.exec(prompt))) marks.push({ file: m[1], at: m.index, bodyAt: re.lastIndex });
|
|
44
|
+
marks.forEach((mk, i) => {
|
|
45
|
+
const end = i + 1 < marks.length ? marks[i + 1].at : prompt.length;
|
|
46
|
+
blocks.push({ file: mk.file, lines: numbered(prompt.slice(mk.bodyAt, end)) });
|
|
47
|
+
});
|
|
48
|
+
return { blocks, unresolved: /^UNRESOLVED:/m.test(prompt), notIncluded: /^NOT INCLUDED:/m.test(prompt), limit: /^LIMIT:/m.test(prompt) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Blocks from a `hunt` prompt: whole files, which the reader numbers itself. */
|
|
52
|
+
export function blocksFromHuntPrompt(prompt) {
|
|
53
|
+
const blocks = [];
|
|
54
|
+
const re = /--- SOURCE FILE \(untrusted data\): (\S+?)(?: \(truncated\))? ---\n([\s\S]*?)\n--- END SOURCE FILE: /g;
|
|
55
|
+
let m;
|
|
56
|
+
while ((m = re.exec(prompt))) blocks.push({ file: m[1], lines: m[2].split('\n').map((text, i) => ({ n: i + 1, text })) });
|
|
57
|
+
return blocks;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function allLines(blocks) { return blocks.flatMap(b => b.lines.map(l => ({ ...l, file: b.file }))); }
|
|
61
|
+
|
|
62
|
+
/** Lines from a registration to the end of its handler within one block. */
|
|
63
|
+
function handlerRange(block, startIdx) {
|
|
64
|
+
const L = block.lines;
|
|
65
|
+
const first = L[startIdx].text;
|
|
66
|
+
if (/^\s*@/.test(first)) { // decorator style: until the next top-level line after the def body
|
|
67
|
+
let i = startIdx + 1;
|
|
68
|
+
while (i < L.length && /^\s*@/.test(L[i].text)) i++;
|
|
69
|
+
const base = /^\s*/.exec(L[i] ? L[i].text : '')[0].length;
|
|
70
|
+
let j = i + 1;
|
|
71
|
+
while (j < L.length && (L[j].text.trim() === '' || /^\s*/.exec(L[j].text)[0].length > base)) j++;
|
|
72
|
+
return L.slice(startIdx, j);
|
|
73
|
+
}
|
|
74
|
+
if (SCOTTY_ROUTE.test(first)) {
|
|
75
|
+
const base = /^\s*/.exec(first)[0].length;
|
|
76
|
+
let j = startIdx + 1;
|
|
77
|
+
while (j < L.length && (L[j].text.trim() === '' || /^\s*/.exec(L[j].text)[0].length > base)) j++;
|
|
78
|
+
return L.slice(startIdx, j);
|
|
79
|
+
}
|
|
80
|
+
let depth = 0; let opened = false; let j = startIdx;
|
|
81
|
+
for (; j < L.length; j++) {
|
|
82
|
+
for (const c of L[j].text) { if (c === '{') { depth++; opened = true; } else if (c === '}') depth--; }
|
|
83
|
+
if (opened && depth <= 0) break;
|
|
84
|
+
}
|
|
85
|
+
return L.slice(startIdx, Math.min(L.length, j + 1));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function findRoutes(blocks) {
|
|
89
|
+
const routes = [];
|
|
90
|
+
for (const b of blocks) {
|
|
91
|
+
b.lines.forEach((l, i) => {
|
|
92
|
+
let m = ROUTE_CALL.exec(l.text);
|
|
93
|
+
if (m) { routes.push({ block: b, idx: i, method: m[1].toUpperCase(), path: m[3], file: b.file, line: l.n }); return; }
|
|
94
|
+
m = FLASK_ROUTE.exec(l.text);
|
|
95
|
+
if (m) { routes.push({ block: b, idx: i, method: 'GET', path: m[2], file: b.file, line: l.n }); return; }
|
|
96
|
+
m = SCOTTY_ROUTE.exec(l.text);
|
|
97
|
+
if (m) routes.push({ block: b, idx: i, method: m[1].toUpperCase(), path: m[2], file: b.file, line: l.n });
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return routes;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Find the body of a function named `name` anywhere in the blocks (one level of helper). */
|
|
104
|
+
function functionBody(blocks, name) {
|
|
105
|
+
for (const b of blocks) {
|
|
106
|
+
for (let i = 0; i < b.lines.length; i++) {
|
|
107
|
+
if (new RegExp(`(?:function\\s+${name}\\b|\\b${name}\\s*=\\s*(?:async\\s*)?\\(|^\\s*${name}\\s*::|\\bdef\\s+${name}\\b)`).test(b.lines[i].text)) {
|
|
108
|
+
const range = handlerRange(b, i);
|
|
109
|
+
return range.map(l => ({ ...l, file: b.file }));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function statement(lines, i) {
|
|
117
|
+
let text = lines[i].text; let depth = 0;
|
|
118
|
+
for (const c of text) { if (c === '(') depth++; else if (c === ')') depth--; }
|
|
119
|
+
let j = i;
|
|
120
|
+
while (depth > 0 && j + 1 < lines.length && j - i < 6) { j++; text += ' ' + lines[j].text; for (const c of lines[j].text) { if (c === '(') depth++; else if (c === ')') depth--; } }
|
|
121
|
+
return text;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function stripUntrusted(t) {
|
|
125
|
+
return t.replace(/\b(?:req|request)\.(?:params|query|body|args|form)\b(?:\.\w+)?/g, ' ').replace(/(["'`])(?:\\.|(?!\1).)*\1/g, '""');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Analyse ONE route: returns the observations a reader can make from the text it was given.
|
|
130
|
+
*/
|
|
131
|
+
export function analyseRoute(route, blocks, { dynamicUnresolved = false } = {}) {
|
|
132
|
+
const handler = handlerRange(route.block, route.idx).map(l => ({ ...l, file: route.file }));
|
|
133
|
+
const all = allLines(blocks);
|
|
134
|
+
const obs = { route, handler, untrusted: false, data: [], writes: [], scoping: [], capability: null, roleGuards: [], roleGuardDefined: false, lateScoping: [], dynamic: false, transaction: false, helperScoping: [] };
|
|
135
|
+
const regLine = handler[0] ? handler[0].text : '';
|
|
136
|
+
|
|
137
|
+
obs.untrusted = handler.some(l => UNTRUSTED.test(l.text)) || UNTRUSTED.test(regLine);
|
|
138
|
+
handler.forEach((l, i) => {
|
|
139
|
+
if (DATA.test(l.text)) {
|
|
140
|
+
const st = statement(handler, i);
|
|
141
|
+
obs.data.push({ ...l, stmt: st });
|
|
142
|
+
if (WRITE.test(st)) obs.writes.push({ ...l, stmt: st });
|
|
143
|
+
if (OWNER_COL.test(st) && QUALIFIED_PRINCIPAL.test(stripUntrustedKeepUser(st))) obs.scoping.push({ ...l, kind: 'query-filter' });
|
|
144
|
+
}
|
|
145
|
+
if (/\btoken\s*=\s*\$1/.test(l.text) && /\b(?:req|request)\.params\.token\b/.test(statement(handler, i))) obs.capability = { ...l };
|
|
146
|
+
if (/!==|!=|==/.test(l.text) && OWNER_COL.test(l.text) && QUALIFIED_PRINCIPAL.test(stripUntrustedKeepUser(l.text))) obs.lateScoping.push({ ...l });
|
|
147
|
+
if (/\bactions\[|\[[^\]]*(?:params|query|body)[^\]]*\]/.test(l.text) || /\breturn\s+run\(/.test(l.text)) obs.dynamic = true;
|
|
148
|
+
if (/\btransaction\b/.test(l.text)) obs.transaction = true;
|
|
149
|
+
if (/\bisAdmin\b|\brole\s*!==?\s*['"]admin['"]/.test(l.text)) obs.roleGuards.push({ ...l, where: 'handler' });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// middleware named on the registration line
|
|
153
|
+
const mwNames = [...regLine.matchAll(/\b(require[A-Z]\w*)\b/g)].map(m => m[1]);
|
|
154
|
+
for (const name of mwNames) {
|
|
155
|
+
if (ROLE_GUARD.test(name)) {
|
|
156
|
+
const body = functionBody(blocks, name);
|
|
157
|
+
if (body) { obs.roleGuardDefined = true; obs.roleGuards.push({ ...body[0], where: 'middleware', name }); }
|
|
158
|
+
else obs.roleGuards.push({ file: route.file, n: route.line, text: regLine, where: 'middleware-undefined', name });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// router-level / app-level `use` lines in the provided blocks
|
|
162
|
+
for (const l of all) {
|
|
163
|
+
const m = /\b(?:router|app)\.use\(\s*(require[A-Z]\w*)\s*\)/.exec(l.text);
|
|
164
|
+
if (m && ROLE_GUARD.test(m[1]) && l.n < route.line + 0 && l.file === route.file) {
|
|
165
|
+
const body = functionBody(blocks, m[1]);
|
|
166
|
+
obs.roleGuards.push({ ...l, where: 'use', name: m[1], defined: !!body });
|
|
167
|
+
if (body) obs.roleGuardDefined = true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// one level of helper: a call whose definition is in the blocks and which scopes by the principal
|
|
171
|
+
for (const l of handler) {
|
|
172
|
+
for (const m of l.text.matchAll(/\b(find\w+|get\w+|load\w+)\s*\(/g)) {
|
|
173
|
+
const body = functionBody(blocks, m[1]);
|
|
174
|
+
if (!body) continue;
|
|
175
|
+
body.forEach((bl, i) => {
|
|
176
|
+
if (DATA.test(bl.text)) {
|
|
177
|
+
const st = statement(body, i);
|
|
178
|
+
obs.helperData = { ...bl, stmt: st };
|
|
179
|
+
if (OWNER_COL.test(st) && /\buser\.id\b|\bcurrent_user\b|\buser\.orgId\b/.test(st)) obs.helperScoping.push({ ...bl });
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
void dynamicUnresolved;
|
|
185
|
+
return obs;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function stripUntrustedKeepUser(t) { return stripUntrusted(t).replace(/""/g, '""'); }
|
|
189
|
+
|
|
190
|
+
function cite(l, role, claim) { return { role, file: l.file, startLine: l.n, endLine: l.n, claim }; }
|
|
191
|
+
|
|
192
|
+
/** The decision for a pack, from the observations. Returns the review JSON object. */
|
|
193
|
+
export function decide(packId, obs, ctx) {
|
|
194
|
+
const base = { observedMitigations: [], attackerControl: [], uncertainties: [], likelihood: 'low' };
|
|
195
|
+
if (packId === 'authz-idor') {
|
|
196
|
+
if (obs.capability) {
|
|
197
|
+
return { ...base, evidence: [cite(obs.capability, 'data-access', 'looked up by an unguessable token')], observedMitigations: [{ kind: 'other', file: obs.capability.file, startLine: obs.capability.n, endLine: obs.capability.n, claim: 'capability token lookup' }], conclusion: ctx.complete ? 'no_issue_observed' : 'insufficient_evidence', invariant: 'object selected by identifier must be scoped', narrative: 'access is by capability token', uncertainties: ctx.complete ? [] : ['context incomplete'] };
|
|
198
|
+
}
|
|
199
|
+
const data = obs.data[0] || (obs.helperData ? { ...obs.helperData } : null);
|
|
200
|
+
if (!obs.untrusted || !data) return { ...base, evidence: [], conclusion: 'insufficient_evidence', invariant: 'object selected by identifier must be scoped', uncertainties: ['no identifier-to-data path visible'] };
|
|
201
|
+
const scoped = obs.scoping.length || obs.helperScoping.length;
|
|
202
|
+
if (obs.writes.length && obs.lateScoping.length) {
|
|
203
|
+
const w = obs.writes[0]; const g = obs.lateScoping[0];
|
|
204
|
+
if (g.n > w.n) return { ...base, evidence: [cite(w, 'state-change', 'the write happens first'), cite(g, 'guard', 'the ownership check comes after it')], conclusion: 'potential_issue', invariant: 'object selected by identifier must be scoped before it is changed', narrative: 'the change is made before the owner is checked', attackerControl: ['req.params.id'], likelihood: 'high' };
|
|
205
|
+
}
|
|
206
|
+
if (obs.lateScoping.length && !obs.writes.length && obs.lateScoping[0].n >= data.n) {
|
|
207
|
+
return { ...base, evidence: [cite(data, 'data-access', 'fetch'), cite(obs.lateScoping[0], 'guard', 'owner check')], observedMitigations: [{ kind: 'ownership', file: obs.lateScoping[0].file, startLine: obs.lateScoping[0].n, endLine: obs.lateScoping[0].n, claim: 'owner compared with the principal and rejected before use' }], conclusion: ctx.complete ? 'no_issue_observed' : 'insufficient_evidence', invariant: 'object selected by identifier must be scoped', narrative: 'fetched, then checked before use' };
|
|
208
|
+
}
|
|
209
|
+
if (scoped) {
|
|
210
|
+
const s = obs.scoping[0] || obs.helperScoping[0];
|
|
211
|
+
return { ...base, evidence: [cite(data, 'data-access', 'query'), cite(s, 'guard', 'filter by principal')], observedMitigations: [{ kind: 'ownership', file: s.file, startLine: s.n, endLine: s.n, claim: 'query is filtered by the authenticated principal' }], conclusion: ctx.complete ? 'no_issue_observed' : 'insufficient_evidence', invariant: 'object selected by identifier must be scoped', narrative: 'id is combined with the principal in the query', uncertainties: ctx.complete ? [] : ['context incomplete'] };
|
|
212
|
+
}
|
|
213
|
+
if (ctx.unresolved) return { ...base, evidence: [], conclusion: 'insufficient_evidence', invariant: 'object selected by identifier must be scoped', uncertainties: ['an unresolved call could contain the scoping'] };
|
|
214
|
+
return { ...base, evidence: [cite(data, 'data-access', 'object fetched by a caller-controlled identifier')], conclusion: 'potential_issue', invariant: 'object selected by identifier must be scoped to the principal', narrative: 'the identifier flows into the query and nothing ties it to the caller', attackerControl: ['identifier'], likelihood: 'high' };
|
|
215
|
+
}
|
|
216
|
+
if (packId === 'authz-function') {
|
|
217
|
+
if (obs.dynamic && ctx.unresolved) return { ...base, evidence: [], conclusion: 'insufficient_evidence', invariant: 'privileged action needs a role check', uncertainties: ['the action is chosen at runtime'] };
|
|
218
|
+
if (obs.dynamic) return { ...base, evidence: [], conclusion: 'insufficient_evidence', invariant: 'privileged action needs a role check', uncertainties: ['the action is chosen at runtime'] };
|
|
219
|
+
const act = obs.writes[0] || obs.data[0];
|
|
220
|
+
if (!act) return { ...base, evidence: [], conclusion: 'insufficient_evidence', invariant: 'privileged action needs a role check', uncertainties: ['no privileged action visible'] };
|
|
221
|
+
const defined = obs.roleGuards.find(g => g.where === 'handler' || g.where === 'middleware' || (g.where === 'use' && g.defined));
|
|
222
|
+
if (defined) return { ...base, evidence: [cite(act, 'state-change', 'privileged change')], observedMitigations: [{ kind: 'role-check', file: defined.file, startLine: defined.n, endLine: defined.n, claim: 'role check precedes the action' }], conclusion: ctx.complete ? 'no_issue_observed' : 'insufficient_evidence', invariant: 'privileged action needs a role check', narrative: 'role check then action' };
|
|
223
|
+
if (obs.roleGuards.length) return { ...base, evidence: [], conclusion: 'insufficient_evidence', invariant: 'privileged action needs a role check', uncertainties: ['a role guard is named but its body is not shown'] };
|
|
224
|
+
if (ctx.unresolved) return { ...base, evidence: [], conclusion: 'insufficient_evidence', invariant: 'privileged action needs a role check', uncertainties: ['an unresolved call could contain the check'] };
|
|
225
|
+
return { ...base, evidence: [cite(act, 'state-change', 'privileged change with no role check')], conclusion: 'potential_issue', invariant: 'privileged action needs a role check', narrative: 'only authentication stands in front of the action', likelihood: 'high' };
|
|
226
|
+
}
|
|
227
|
+
if (packId === 'transaction-integrity') {
|
|
228
|
+
if (obs.writes.length >= 2 && !obs.transaction) return { ...base, evidence: [cite(obs.writes[0], 'state-change', 'first write'), cite(obs.writes[1], 'state-change', 'second write')], conclusion: 'potential_issue', invariant: 'dependent writes must be atomic', narrative: 'a failure between the two writes leaves them inconsistent' };
|
|
229
|
+
if (obs.writes.length >= 2 && obs.transaction) return { ...base, evidence: [cite(obs.writes[0], 'state-change', 'write')], observedMitigations: [{ kind: 'other', file: obs.writes[0].file, startLine: obs.writes[0].n, endLine: obs.writes[0].n, claim: 'writes run inside a transaction' }], conclusion: ctx.complete ? 'no_issue_observed' : 'insufficient_evidence', invariant: 'dependent writes must be atomic' };
|
|
230
|
+
return { ...base, evidence: [], conclusion: 'insufficient_evidence', invariant: 'dependent writes must be atomic' };
|
|
231
|
+
}
|
|
232
|
+
return { ...base, evidence: [], conclusion: 'insufficient_evidence', invariant: 'unspecified' };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The reader as an `llmInvoke`: prompt in, JSON text out. Handles both prompt formats. */
|
|
236
|
+
export function scriptedReader() {
|
|
237
|
+
const invoke = async (prompt) => {
|
|
238
|
+
if (/^QUESTION PACK: /m.test(prompt)) {
|
|
239
|
+
const packId = /^QUESTION PACK: ([\w-]+)\//m.exec(prompt)[1];
|
|
240
|
+
const label = /^ENTRY POINT 1: (.*?) \(id /m.exec(prompt)[1];
|
|
241
|
+
const { blocks, unresolved, notIncluded, limit } = blocksFromReviewPrompt(prompt);
|
|
242
|
+
const routes = findRoutes(blocks);
|
|
243
|
+
const [method, ...rest] = label.split(' ');
|
|
244
|
+
const wantPath = rest.join(' ');
|
|
245
|
+
const route = routes.find(r => r.path === wantPath && (r.method === method || r.method === 'ALL')) || routes.find(r => r.path === wantPath) || routes[0];
|
|
246
|
+
if (!route) return JSON.stringify({ evidence: [], observedMitigations: [], conclusion: 'insufficient_evidence', invariant: 'unspecified', uncertainties: ['the registration is not in the provided context'] });
|
|
247
|
+
const obs = analyseRoute(route, blocks);
|
|
248
|
+
const complete = !unresolved && !notIncluded && !limit;
|
|
249
|
+
return JSON.stringify(decide(packId, obs, { complete, unresolved }));
|
|
250
|
+
}
|
|
251
|
+
// hunt prompt: report the candidates for the lens, with a location
|
|
252
|
+
const lens = /Your lens is ([\w ]+)\./.exec(prompt);
|
|
253
|
+
const lensTitle = lens ? lens[1].trim() : '';
|
|
254
|
+
const packId = lensTitle === 'Authorization' ? 'authz-idor' : lensTitle === 'Business logic' ? 'transaction-integrity' : null;
|
|
255
|
+
const blocks = blocksFromHuntPrompt(prompt);
|
|
256
|
+
const candidates = [];
|
|
257
|
+
if (packId) {
|
|
258
|
+
for (const route of findRoutes(blocks)) {
|
|
259
|
+
const obs = analyseRoute(route, blocks);
|
|
260
|
+
let d = decide(packId, obs, { complete: true, unresolved: false });
|
|
261
|
+
if (packId === 'authz-idor' && /(?:role|admin|users\/:id\/role|purge|jobs)/.test(route.path + obs.handler.map(l => l.text).join(' '))) {
|
|
262
|
+
const f = decide('authz-function', obs, { complete: true, unresolved: false });
|
|
263
|
+
if (f.conclusion === 'potential_issue') d = f;
|
|
264
|
+
}
|
|
265
|
+
if (d.conclusion === 'potential_issue' && d.evidence.length) {
|
|
266
|
+
const e = d.evidence[0];
|
|
267
|
+
candidates.push({ title: `${route.method} ${route.path}: ${d.invariant}`, file: e.file, line: e.startLine, rationale: d.narrative || '', entryPoint: `${route.method} ${route.path}`, sink: e.claim });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return JSON.stringify({ candidates });
|
|
272
|
+
};
|
|
273
|
+
invoke.tokenizerName = 'chars/4-estimate';
|
|
274
|
+
return invoke;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function readerIdentity() {
|
|
278
|
+
return { kind: 'scripted-reader', version: 1, digest: crypto.createHash('sha256').update(scriptedReader.toString() + decide.toString() + analyseRoute.toString()).digest('hex').slice(0, 16), synthetic: true };
|
|
279
|
+
}
|