@clear-capabilities/agentic-security-scanner 0.171.0 → 0.172.1
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 +29 -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 +885 -0
- package/dist/9382.index.js +4386 -0
- package/dist/9579.index.js +1197 -0
- package/dist/agentic-security.mjs +4 -4
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +4 -4
- package/src/context-intelligence/CLAUDE.md +25 -0
- package/src/context-intelligence/adapters/existing-ir.js +14 -0
- package/src/context-intelligence/adapters/haskell-hie.js +165 -0
- package/src/context-intelligence/build.js +68 -0
- package/src/context-intelligence/candidates.js +115 -0
- package/src/context-intelligence/canonical.js +120 -0
- package/src/context-intelligence/capabilities.js +33 -0
- package/src/context-intelligence/cli.js +426 -0
- package/src/context-intelligence/config.js +91 -0
- package/src/context-intelligence/controls.js +244 -0
- package/src/context-intelligence/diff.js +97 -0
- package/src/context-intelligence/entrypoints.js +336 -0
- package/src/context-intelligence/evaluation/index.js +235 -0
- package/src/context-intelligence/evaluation/runner.js +139 -0
- package/src/context-intelligence/evaluation/scripted-reader.js +289 -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 +188 -0
- package/src/context-intelligence/repo-info.js +16 -0
- package/src/context-intelligence/review.js +370 -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,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,289 @@
|
|
|
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
|
+
/** A reader that cites a line says what is on it: the claim quotes the cited text, as a careful model would. */
|
|
193
|
+
function quoteCited(obj, blocks) {
|
|
194
|
+
const text = (c) => {
|
|
195
|
+
for (const b of blocks) if (b.file === c.file) { const l = b.lines.find(x => x.n === c.startLine); if (l) return l.text.trim().slice(0, 100); }
|
|
196
|
+
return '';
|
|
197
|
+
};
|
|
198
|
+
const fix = (list) => (Array.isArray(list) ? list.map(c => ({ ...c, claim: `${c.claim}: ${text(c)}` })) : list);
|
|
199
|
+
return { ...obj, evidence: fix(obj.evidence), observedMitigations: fix(obj.observedMitigations) };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The decision for a pack, from the observations. Returns the review JSON object. */
|
|
203
|
+
export function decide(packId, obs, ctx) {
|
|
204
|
+
const base = { observedMitigations: [], attackerControl: [], uncertainties: [], likelihood: 'low' };
|
|
205
|
+
if (packId === 'authz-idor') {
|
|
206
|
+
if (obs.capability) {
|
|
207
|
+
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'] };
|
|
208
|
+
}
|
|
209
|
+
const data = obs.data[0] || (obs.helperData ? { ...obs.helperData } : null);
|
|
210
|
+
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'] };
|
|
211
|
+
const scoped = obs.scoping.length || obs.helperScoping.length;
|
|
212
|
+
if (obs.writes.length && obs.lateScoping.length) {
|
|
213
|
+
const w = obs.writes[0]; const g = obs.lateScoping[0];
|
|
214
|
+
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' };
|
|
215
|
+
}
|
|
216
|
+
if (obs.lateScoping.length && !obs.writes.length && obs.lateScoping[0].n >= data.n) {
|
|
217
|
+
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' };
|
|
218
|
+
}
|
|
219
|
+
if (scoped) {
|
|
220
|
+
const s = obs.scoping[0] || obs.helperScoping[0];
|
|
221
|
+
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'] };
|
|
222
|
+
}
|
|
223
|
+
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'] };
|
|
224
|
+
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' };
|
|
225
|
+
}
|
|
226
|
+
if (packId === 'authz-function') {
|
|
227
|
+
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'] };
|
|
228
|
+
if (obs.dynamic) return { ...base, evidence: [], conclusion: 'insufficient_evidence', invariant: 'privileged action needs a role check', uncertainties: ['the action is chosen at runtime'] };
|
|
229
|
+
const act = obs.writes[0] || obs.data[0];
|
|
230
|
+
if (!act) return { ...base, evidence: [], conclusion: 'insufficient_evidence', invariant: 'privileged action needs a role check', uncertainties: ['no privileged action visible'] };
|
|
231
|
+
const defined = obs.roleGuards.find(g => g.where === 'handler' || g.where === 'middleware' || (g.where === 'use' && g.defined));
|
|
232
|
+
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' };
|
|
233
|
+
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'] };
|
|
234
|
+
if (ctx.unresolved) return { ...base, evidence: [], conclusion: 'insufficient_evidence', invariant: 'privileged action needs a role check', uncertainties: ['an unresolved call could contain the check'] };
|
|
235
|
+
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' };
|
|
236
|
+
}
|
|
237
|
+
if (packId === 'transaction-integrity') {
|
|
238
|
+
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' };
|
|
239
|
+
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' };
|
|
240
|
+
return { ...base, evidence: [], conclusion: 'insufficient_evidence', invariant: 'dependent writes must be atomic' };
|
|
241
|
+
}
|
|
242
|
+
return { ...base, evidence: [], conclusion: 'insufficient_evidence', invariant: 'unspecified' };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The reader as an `llmInvoke`: prompt in, JSON text out. Handles both prompt formats. */
|
|
246
|
+
export function scriptedReader() {
|
|
247
|
+
const invoke = async (prompt) => {
|
|
248
|
+
if (/^QUESTION PACK: /m.test(prompt)) {
|
|
249
|
+
const packId = /^QUESTION PACK: ([\w-]+)\//m.exec(prompt)[1];
|
|
250
|
+
const label = (/^ENTRY POINT LABEL \(data\): (.*)$/m.exec(prompt) || [])[1] || '';
|
|
251
|
+
const { blocks, unresolved, notIncluded, limit } = blocksFromReviewPrompt(prompt);
|
|
252
|
+
const routes = findRoutes(blocks);
|
|
253
|
+
const [method, ...rest] = label.split(' ');
|
|
254
|
+
const wantPath = rest.join(' ');
|
|
255
|
+
const route = routes.find(r => r.path === wantPath && (r.method === method || r.method === 'ALL')) || routes.find(r => r.path === wantPath) || routes[0];
|
|
256
|
+
if (!route) return JSON.stringify({ evidence: [], observedMitigations: [], conclusion: 'insufficient_evidence', invariant: 'unspecified', uncertainties: ['the registration is not in the provided context'] });
|
|
257
|
+
const obs = analyseRoute(route, blocks);
|
|
258
|
+
const complete = !unresolved && !notIncluded && !limit;
|
|
259
|
+
return JSON.stringify(quoteCited(decide(packId, obs, { complete, unresolved }), blocks));
|
|
260
|
+
}
|
|
261
|
+
// hunt prompt: report the candidates for the lens, with a location
|
|
262
|
+
const lens = /Your lens is ([\w ]+)\./.exec(prompt);
|
|
263
|
+
const lensTitle = lens ? lens[1].trim() : '';
|
|
264
|
+
const packId = lensTitle === 'Authorization' ? 'authz-idor' : lensTitle === 'Business logic' ? 'transaction-integrity' : null;
|
|
265
|
+
const blocks = blocksFromHuntPrompt(prompt);
|
|
266
|
+
const candidates = [];
|
|
267
|
+
if (packId) {
|
|
268
|
+
for (const route of findRoutes(blocks)) {
|
|
269
|
+
const obs = analyseRoute(route, blocks);
|
|
270
|
+
let d = decide(packId, obs, { complete: true, unresolved: false });
|
|
271
|
+
if (packId === 'authz-idor' && /(?:role|admin|users\/:id\/role|purge|jobs)/.test(route.path + obs.handler.map(l => l.text).join(' '))) {
|
|
272
|
+
const f = decide('authz-function', obs, { complete: true, unresolved: false });
|
|
273
|
+
if (f.conclusion === 'potential_issue') d = f;
|
|
274
|
+
}
|
|
275
|
+
if (d.conclusion === 'potential_issue' && d.evidence.length) {
|
|
276
|
+
const e = d.evidence[0];
|
|
277
|
+
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 });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return JSON.stringify({ candidates });
|
|
282
|
+
};
|
|
283
|
+
invoke.tokenizerName = 'chars/4-estimate';
|
|
284
|
+
return invoke;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function readerIdentity() {
|
|
288
|
+
return { kind: 'scripted-reader', version: 1, digest: crypto.createHash('sha256').update(scriptedReader.toString() + decide.toString() + analyseRoute.toString()).digest('hex').slice(0, 16), synthetic: true };
|
|
289
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Which files DCI analyses, and how they are read. Reading goes through `readRepoFile`, so a symlink, a file that
|
|
3
|
+
// resolves outside the repository, or an oversized file is skipped with a counted reason, never followed.
|
|
4
|
+
import * as fs from 'node:fs';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { listFiles } from '../util/glob.js';
|
|
8
|
+
import { STATE_DIR_NAME } from '../posture/state-dir.js';
|
|
9
|
+
import { readRepoFile, snapshotHashOf, normalizeText, safeRelPath } from './canonical.js';
|
|
10
|
+
|
|
11
|
+
export const SOURCE_EXTS = /\.(?:js|jsx|mjs|cjs|ts|tsx|py|java|cs|kt|go|php|rb|rs|c|cc|cpp|h|hpp|l?hs|nix)$/i;
|
|
12
|
+
export const IGNORE_GLOBS = ['node_modules/**', '.git/**', 'dist/**', 'build/**', 'vendor/**', '**/.agentic-security/**', 'dist-newstyle/**', '.stack-work/**', '.direnv/**', 'coverage/**', '**/*.min.js'];
|
|
13
|
+
|
|
14
|
+
/** Read the analysable source set. Returns { ok, fileContents, skipped, snapshotHash } or { ok:false, reason }. */
|
|
15
|
+
export async function readSnapshot(root, { maxFiles = 3000 } = {}) {
|
|
16
|
+
let rootReal;
|
|
17
|
+
try { rootReal = fs.realpathSync(root); } catch { return { ok: false, reason: 'root-not-found' }; }
|
|
18
|
+
const rels = (await listFiles(rootReal, { ignore: IGNORE_GLOBS })).filter(f => SOURCE_EXTS.test(f)).sort();
|
|
19
|
+
if (rels.length > maxFiles) return { ok: false, reason: 'too-many-files', count: rels.length, limit: maxFiles };
|
|
20
|
+
const fileContents = {};
|
|
21
|
+
const skipped = [];
|
|
22
|
+
for (const rel of rels) {
|
|
23
|
+
const r = readRepoFile(rootReal, rel);
|
|
24
|
+
if (r.ok) fileContents[rel] = r.text;
|
|
25
|
+
else skipped.push({ file: rel, reason: r.reason });
|
|
26
|
+
}
|
|
27
|
+
for (const l of findSymlinks(rootReal)) skipped.push({ file: l, reason: 'symlink' });
|
|
28
|
+
skipped.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
|
|
29
|
+
return { ok: true, rootReal, fileContents, skipped, snapshotHash: snapshotHashOf(fileContents) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'vendor', STATE_DIR_NAME, 'dist-newstyle', '.stack-work', '.direnv', 'coverage']);
|
|
33
|
+
|
|
34
|
+
/** Symlinked source files and directories (never followed), so they are counted as skipped instead of vanishing. */
|
|
35
|
+
function findSymlinks(rootReal, limit = 5000) {
|
|
36
|
+
const found = [];
|
|
37
|
+
let seen = 0;
|
|
38
|
+
const walk = (abs, rel) => {
|
|
39
|
+
let entries;
|
|
40
|
+
try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch { return; }
|
|
41
|
+
for (const e of entries) {
|
|
42
|
+
if (++seen > limit) return;
|
|
43
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
44
|
+
if (e.isSymbolicLink()) { if (e.name.indexOf('.') === -1 || SOURCE_EXTS.test(e.name)) found.push(r); continue; }
|
|
45
|
+
if (e.isDirectory() && !SKIP_DIRS.has(e.name)) walk(path.join(abs, e.name), r);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
walk(rootReal, '');
|
|
49
|
+
return found;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read the analysable source set as it was at a git ref, without touching the working tree. `ref` is passed to git as a
|
|
54
|
+
* single argv element after `--end-of-options`-style validation, never through a shell. Returns the same shape as
|
|
55
|
+
* `readSnapshot`, or { ok:false, reason }.
|
|
56
|
+
*/
|
|
57
|
+
export function readGitSnapshot(root, ref, { maxFiles = 3000 } = {}) {
|
|
58
|
+
if (typeof ref !== 'string' || !/^[A-Za-z0-9._\/~^@{}:-]{1,200}$/.test(ref) || ref.startsWith('-')) return { ok: false, reason: 'bad-ref' };
|
|
59
|
+
let rootReal;
|
|
60
|
+
try { rootReal = fs.realpathSync(root); } catch { return { ok: false, reason: 'root-not-found' }; }
|
|
61
|
+
const git = (args, max = 64 * 1024 * 1024) => execFileSync('git', ['-C', rootReal, ...args], { encoding: 'utf8', timeout: 30000, maxBuffer: max, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
62
|
+
let commit;
|
|
63
|
+
try { commit = git(['rev-parse', '--verify', `${ref}^{commit}`]).trim(); } catch { return { ok: false, reason: 'unknown-ref' }; }
|
|
64
|
+
let names;
|
|
65
|
+
try { names = git(['ls-tree', '-r', '--name-only', '-z', commit]).split('\0').filter(Boolean); } catch { return { ok: false, reason: 'ls-tree-failed' }; }
|
|
66
|
+
const ignore = IGNORE_GLOBS.map(g => new RegExp('^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*\//g, '(?:.*/)?').replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*') + '$'));
|
|
67
|
+
const rels = names.filter(f => SOURCE_EXTS.test(f) && safeRelPath(f) === f && !ignore.some(re => re.test(f))).sort();
|
|
68
|
+
if (rels.length > maxFiles) return { ok: false, reason: 'too-many-files', count: rels.length, limit: maxFiles };
|
|
69
|
+
const fileContents = {};
|
|
70
|
+
const skipped = [];
|
|
71
|
+
for (const rel of rels) {
|
|
72
|
+
try {
|
|
73
|
+
const mode = git(['ls-tree', commit, '--', rel]).split(' ')[0];
|
|
74
|
+
if (mode === '120000' || mode === '160000') { skipped.push({ file: rel, reason: 'symlink-or-submodule' }); continue; }
|
|
75
|
+
const text = git(['show', `${commit}:${rel}`], 2 * 1024 * 1024);
|
|
76
|
+
fileContents[rel] = normalizeText(text);
|
|
77
|
+
} catch { skipped.push({ file: rel, reason: 'unreadable-at-ref' }); }
|
|
78
|
+
}
|
|
79
|
+
return { ok: true, rootReal, fileContents, skipped, snapshotHash: snapshotHashOf(fileContents), commit };
|
|
80
|
+
}
|