Skip to content

Commit 79d936b

Browse files
map: report why an endpoint is unmodellable; make the coverage and sink schema honest (#126)
* map: report why an endpoint is unmodellable; make the coverage and sink schema honest Four gaps found by building a real consumer of this map (an attack-surface visualizer) and by the external review's "make unmodelled code visible" recommendation. 1. LIMITATIONS. An endpoint whose sink argument uses a dynamic computed key or a spread cannot be rule-generated, and previously said only "flow evidence is heuristic" — so the actual cause was invisible and an operator would reasonably assume nothing was there. Endpoints now carry `limitations: [{ kind, detail, line }]` naming the offending expression (`body[field]`, `{ ...body }`), and the specific cause also appears in the affected flows' `ruleGeneratableReasons` instead of the generic wording. A cleanly analysable endpoint gets no limitations, so the field means something. 2. filesPreFiltered. `filesParsed: 6, filesDiscovered: 66` reads as "91% unanalysed" when it really means 60 files had no entry-point signal at all (most of a project is client code). The three buckets are now explicit and sum to the total, so no consumer has to infer it by subtraction — a visualizer had to invent that segment itself to avoid alarming a reader. 3. Stable sink ids. `Flow.sink` is an embedded COPY, so a consumer had to dedupe on a composite of eight fields and would render a second phantom sink if a copy ever drifted. Every sink now carries a deterministic `id`, and a flow's copy shares it. 4. SinkKind is a closed union instead of `string` — the doc comment advertised `template` and `redirect`, which no recognizer emits, so a consumer could not switch exhaustively. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * map: require sinks to be justified; fix the coverage line and `new Function` Three findings from external review. P1 (blocker for auto-generated rules) — a dangerous NAME was treated as a dangerous API. Any bare `fetch(…)`/`readFile(…)`/`exec(…)` that was not a top-level local counted as the real thing, so BOTH of these produced FALSE candidates: import { fetch, readFile } from "./util"; // app code, not HTTP/fs withClient((fetch) => fetch(req.body.url)) // a parameter shadowing the global The first became an SSRF candidate and the second a second one — directly violating the zero-false-candidate goal the corpus metric exists to protect. A bare call must now be justified: it resolves to a module that plausibly provides that API (fs → node:fs[/promises], exec → node:child_process, http → a known http package), or it is a genuine unresolved global — and only `fetch`, `eval` and `Function` ever are. Shadowing by an enclosing parameter or catch binding disqualifies it. A relative import resolves to no package, so app code that shares a name with an API is no longer mistaken for it. Impostors are not even inventoried as dangerous sinks now, so nothing downstream can resurrect them. P2 — the CLI recreated the ambiguity the schema fix removed. It printed "6/66 file(s) parsed" without saying the other 60 were deliberately pre-filtered, which reads as "91% unanalysed". It now prints all three buckets, and only the third is a failure: "66 file(s) found — 6 analysed, 60 skipped (no server entry point)". P2 — `new Function()` could never reach a precise flow. It was inventoried as an eval sink, but only CallExpression was indexed, so the call could not be located and every flow into it stayed heuristic (its argument-role entry was unreachable). NewExpression is now indexed, and the role model reflects the API: only the LAST argument is code — earlier ones declare parameter names. Verified: the three false candidates are gone (0), while genuine global-fetch / node:fs / child_process / new Function candidates all still compile — including the new code-injection candidate that was previously impossible. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 69215ed commit 79d936b

5 files changed

Lines changed: 374 additions & 26 deletions

File tree

src/cli.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,8 +222,12 @@ async function runMap(args: ParsedArgs): Promise<number> {
222222
`${precise} proven input→sink flow(s) [${map.framework}].`,
223223
);
224224
console.error(
225-
`patchstack: ${c.filesParsed}/${c.filesDiscovered} file(s) parsed` +
226-
(c.filesSkipped ? `, ${c.filesSkipped} skipped` : '') +
225+
// All three buckets, explicitly: "6/66 parsed" reads as "91% unanalysed" when the other 60 files
226+
// simply contain no server entry point (most of a project is client code). Only `skipped` is a
227+
// failure to analyse.
228+
`patchstack: ${c.filesDiscovered} file(s) found — ${c.filesParsed} analysed, ` +
229+
`${c.filesPreFiltered} skipped (no server entry point)` +
230+
(c.filesSkipped ? `, ${c.filesSkipped} could not be analysed` : '') +
227231
`. DETECTED surface only — static analysis is best-effort; unproven pairs are marked "heuristic".`,
228232
);
229233
const json = JSON.stringify(map, null, 2);

src/map/extract.ts

Lines changed: 148 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
22
import { builtinModules } from 'node:module';
33
import { createHash } from 'node:crypto';
44
import { join, relative, isAbsolute, dirname, resolve as resolvePath } from 'node:path';
5-
import type { SiteInputMap, Endpoint, InputField, InputSource, Sink, Flow, ArgumentRole, CandidateFamily, TsModule } from './types.js';
5+
import type { SiteInputMap, Endpoint, InputField, InputSource, Sink, Flow, Limitation, ArgumentRole, CandidateFamily, TsModule } from './types.js';
66

77
// Framework-AGNOSTIC input-flow extractor. It doesn't gate on a specific stack — it walks any JS/TS
88
// source and applies recognizer tables for (1) entry points, (2) inputs, (3) sinks, so it generalizes
@@ -218,11 +218,12 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
218218
const stats: WalkStats = { discovered: 0 };
219219
const files = collectSources(cwd, boundary, { followOutside: options.followSymlinks }, [], new Set(), stats);
220220
let parsed = 0;
221+
let preFiltered = 0;
221222

222223
for (const file of files) {
223224
try {
224225
const text = readFileSync(file, 'utf8');
225-
if (!hasEntrySignal(text)) continue;
226+
if (!hasEntrySignal(text)) { preFiltered++; continue; }
226227
parsed++;
227228
// Coordinates are only valid for the exact file content they were derived from.
228229
const fingerprint = createHash('sha256').update(text).digest('hex').slice(0, 16);
@@ -282,6 +283,7 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
282283
adapter: 'agnostic-v1',
283284
filesDiscovered: stats.discovered,
284285
filesParsed: parsed,
286+
filesPreFiltered: preFiltered,
285287
filesSkipped: failed.length,
286288
roots: ['.'],
287289
notes,
@@ -514,7 +516,7 @@ function extractFromFile(sf: any, ts: TsModule, localSinks: Map<string, Sink[]>,
514516
...spanOf(decl),
515517
inputs,
516518
sinks,
517-
flows: linkFlows(handlerBody, handlerFn?.parameters, inputs, sinks, ts),
519+
...linkedFlows(handlerBody, handlerFn?.parameters, inputs, sinks, ts),
518520
};
519521
// Honesty marker: a validator EXISTS but couldn't be read — inputs are unknown, not "none".
520522
if (validatorCall && inputs.length === 0) ep.inputsResolved = false;
@@ -675,7 +677,7 @@ function handlerEntry(
675677
end: extra.end,
676678
inputs,
677679
sinks,
678-
flows: linkFlows(body, params, inputs, sinks, ts),
680+
...linkedFlows(body, params, inputs, sinks, ts),
679681
};
680682
}
681683

@@ -1105,18 +1107,36 @@ function directSinks(node: any, ts: TsModule, bindings: Bindings): Sink[] {
11051107
}
11061108
}
11071109
}
1108-
// bare calls: fetch( / exec( / readFile( / eval( — unless the name is a plain local function.
1110+
// Bare calls: `fetch(…)` / `exec(…)` / `readFile(…)` / `eval(…)`. A dangerous NAME is not a
1111+
// dangerous API: `import { fetch } from './util'` and a callback parameter named `fetch` both look
1112+
// identical here, and treating either as an HTTP request produced a FALSE SSRF candidate. So the
1113+
// call must be justified — either it resolves to a module that plausibly provides that API, or it
1114+
// is a genuine unresolved global (only `fetch`/`eval`/`Function` ever are).
11091115
if (ts.isIdentifier(callee) && !bindings.locals.has(callee.text)) {
11101116
const name = callee.text;
1111-
const pkg = npmPackageOf(bindings.resolve(name));
1112-
// `fetch` is a global — never attribute it to an unrelated imported http client.
1113-
if (HTTP_CALLS.test(name)) push({ kind: 'http', provider: name, package: pkg ?? (name === 'fetch' ? undefined : infer('http')), op: 'request', ...spanOf(n) });
1114-
if (FS_CALLS.test(name)) push({ kind: 'fs', package: pkg, op: name, ...spanOf(n) });
1115-
if (EXEC_CALLS.test(name)) push({ kind: 'exec', package: pkg, op: name, ...spanOf(n) });
1116-
if (name === 'eval') push({ kind: 'eval', op: 'eval', ...spanOf(n) });
1117+
const spec = bindings.resolve(name);
1118+
const pkg = npmPackageOf(spec);
1119+
const shadowed = isShadowedByEnclosingBinding(n, name, ts);
1120+
// A relative import resolves to no package: it's app code, not the API it shares a name with.
1121+
const fromModule = spec !== undefined;
1122+
const trueGlobal = !fromModule && !shadowed;
1123+
1124+
if (HTTP_CALLS.test(name)) {
1125+
if (pkg && isHttpPackage(pkg)) push({ kind: 'http', provider: name, package: pkg, op: 'request', ...spanOf(n) });
1126+
else if (name === 'fetch' && trueGlobal) push({ kind: 'http', provider: 'fetch', op: 'request', ...spanOf(n) });
1127+
}
1128+
// `readFile`/`exec` are never globals: without a matching module binding this is app code.
1129+
if (FS_CALLS.test(name) && pkg && /^node:fs(\/promises)?$/.test(pkg)) {
1130+
push({ kind: 'fs', package: pkg, op: name, ...spanOf(n) });
1131+
}
1132+
if (EXEC_CALLS.test(name) && pkg === 'node:child_process') {
1133+
push({ kind: 'exec', package: pkg, op: name, ...spanOf(n) });
1134+
}
1135+
if (name === 'eval' && trueGlobal) push({ kind: 'eval', op: 'eval', ...spanOf(n) });
11171136
}
11181137
}
1119-
if (ts.isNewExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === 'Function') {
1138+
if (ts.isNewExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === 'Function'
1139+
&& !bindings.locals.has('Function') && !isShadowedByEnclosingBinding(n, 'Function', ts)) {
11201140
push({ kind: 'eval', op: 'new Function', ...spanOf(n) });
11211141
}
11221142
ts.forEachChild(n, visit);
@@ -1145,14 +1165,20 @@ function isUninvokedFunctionDeclaration(n: any, ts: TsModule): boolean {
11451165
// Deliberately conservative: a match yields `precise`; no match yields `heuristic` (the input and sink
11461166
// merely co-occur). It never claims a flow it didn't see, which is the point — a consumer pinning a
11471167
// rule to a parameter should trust `precise` and treat `heuristic` as "may reach".
1168+
// Spread onto an endpoint: `flows`, plus `limitations` only when there are any (keeps the common case clean).
1169+
function linkedFlows(body: any, params: any, inputs: InputField[], sinks: Sink[], ts: TsModule): { flows: Flow[]; limitations?: Limitation[] } {
1170+
const { flows, limitations } = linkFlows(body, params, inputs, sinks, ts);
1171+
return limitations.length > 0 ? { flows, limitations } : { flows };
1172+
}
1173+
11481174
function linkFlows(
11491175
bodyNode: any,
11501176
params: any,
11511177
inputs: InputField[],
11521178
sinks: Sink[],
11531179
ts: TsModule,
1154-
): Flow[] {
1155-
if (!bodyNode || sinks.length === 0 || inputs.length === 0) return [];
1180+
): { flows: Flow[]; limitations: Limitation[] } {
1181+
if (!bodyNode || sinks.length === 0 || inputs.length === 0) return { flows: [], limitations: [] };
11561182

11571183
// Tainted roots and the PATH each one stands for. `req` → '' (its own members are the path);
11581184
// `const { billing } = await req.json()` → billing stands for 'billing', so a read of
@@ -1224,14 +1250,17 @@ function linkFlows(
12241250
// is ambiguous — the end distinguishes them.
12251251
const callBySpan = new Map<string, any>();
12261252
const callVisit = (n: any) => {
1227-
if (ts.isCallExpression(n)) {
1253+
// NewExpression too, or `new Function(...)` — inventoried as an eval sink — could never be located,
1254+
// leaving its flows permanently heuristic and its argument-role entry unreachable.
1255+
if (ts.isCallExpression(n) || ts.isNewExpression(n)) {
12281256
try { callBySpan.set(`${n.getStart()}:${n.getEnd()}`, n); } catch { /* synthetic */ }
12291257
}
12301258
ts.forEachChild(n, callVisit);
12311259
};
12321260
callVisit(bodyNode);
12331261

12341262
const flows: Flow[] = [];
1263+
const allLimits: Limitation[] = [];
12351264
for (const sink of sinks) {
12361265
// A sink from an imported module has no call site here — never claim precise for it.
12371266
const node = sink.file === undefined && sink.start !== undefined && sink.end !== undefined
@@ -1240,15 +1269,17 @@ function linkFlows(
12401269
// path → the argument ROLES it was read into. Per-argument attribution is what makes a candidate
12411270
// possible: the same value in `url` vs `body`, or `path` vs `content`, implies different mitigations.
12421271
const reads = new Map<string, Set<ArgumentRole>>();
1272+
const sinkLimits: Limitation[] = [];
12431273
if (node) {
12441274
// ONLY this sink call's own arguments, plus other calls in the SAME fluent chain
12451275
// (`.update({…}).eq('id', data.id)` is one operation). Never the enclosing statement: a sibling
12461276
// expression such as `Promise.all([audit(data.title), db.insert({…})])` must not lend evidence.
12471277
for (const call of fluentChainCalls(node, ts)) {
12481278
const method = calleeName(call, ts);
12491279
const args = call.arguments ?? [];
1280+
for (const a of args) for (const l of sinkArgumentLimitations(a, ts, rootPath)) sinkLimits.push(l);
12501281
for (let i = 0; i < args.length; i++) {
1251-
const role = argumentRoleOf(sink.kind, method, i);
1282+
const role = argumentRoleOf(sink.kind, method, i, args.length);
12521283
for (const path of taintedReadPaths(args[i], ts, rootPath)) {
12531284
const set = reads.get(path) ?? new Set<ArgumentRole>();
12541285
set.add(role);
@@ -1279,6 +1310,13 @@ function linkFlows(
12791310
if (sink.file !== undefined) reasons.push('sink is in an imported module: no local call-site evidence');
12801311
if (sink.start === undefined) reasons.push('sink call could not be located in the source');
12811312
if (precise && argumentRole === 'unknown') reasons.push(`sink argument role is not modelled for ${sink.kind}.${sink.op ?? '?'}`);
1313+
// A dynamic key or a spread in this sink's arguments means no coordinate can name the field that
1314+
// actually reaches it — report the specific cause rather than a generic "heuristic".
1315+
for (const l of sinkLimits) {
1316+
reasons.push(l.kind === 'dynamic-key'
1317+
? `dynamic computed key reaches this sink (${l.detail}): the field cannot be named by a parameter`
1318+
: `spread reaches this sink (${l.detail}): the specific field is not identifiable`);
1319+
}
12821320
if (precise && argumentRole && argumentRole !== 'unknown' && !family) {
12831321
// e.g. a request value in a parameterized db `values` object: real reachability, but not a
12841322
// pattern a generic blocking rule can express.
@@ -1295,8 +1333,19 @@ function linkFlows(
12951333
ruleGeneratableReasons: reasons,
12961334
});
12971335
}
1336+
for (const l of sinkLimits) allLimits.push(l);
12981337
}
1299-
return flows;
1338+
return { flows, limitations: dedupeLimitations(allLimits) };
1339+
}
1340+
1341+
function dedupeLimitations(list: Limitation[]): Limitation[] {
1342+
const seen = new Set<string>();
1343+
return list.filter((l) => {
1344+
const k = `${l.kind}:${l.detail}:${l.line}`;
1345+
if (seen.has(k)) return false;
1346+
seen.add(k);
1347+
return true;
1348+
});
13001349
}
13011350

13021351
/** Join two path segments, tolerating an empty base. */
@@ -1351,17 +1400,45 @@ const CANDIDATE_FAMILIES: Record<string, Partial<Record<ArgumentRole, CandidateF
13511400
eval: { code: 'code-injection' },
13521401
};
13531402

1403+
/**
1404+
* Is `name` bound by an enclosing function parameter (or catch clause) at this call site? If so the call
1405+
* is NOT the global of that name — a callback parameter called `fetch` is the single most likely way to
1406+
* fake an SSRF candidate. Scoped to parameters/catch bindings: cheap, and it covers the shadowing shapes
1407+
* that occur in practice. Erring here loses a candidate rather than inventing one.
1408+
*/
1409+
function isShadowedByEnclosingBinding(node: any, name: string, ts: TsModule): boolean {
1410+
for (let cur = node?.parent; cur; cur = cur.parent) {
1411+
if (ts.isCatchClause(cur) && cur.variableDeclaration && ts.isIdentifier(cur.variableDeclaration.name)
1412+
&& cur.variableDeclaration.name.text === name) return true;
1413+
const params = (cur as any).parameters;
1414+
if (!params) continue;
1415+
for (const p of params) {
1416+
if (!p?.name) continue;
1417+
if (ts.isIdentifier(p.name) && p.name.text === name) return true;
1418+
if (ts.isObjectBindingPattern(p.name) || ts.isArrayBindingPattern(p.name)) {
1419+
for (const el of p.name.elements) {
1420+
if (ts.isBindingElement(el) && ts.isIdentifier(el.name) && el.name.text === name) return true;
1421+
}
1422+
}
1423+
}
1424+
}
1425+
return false;
1426+
}
1427+
13541428
/** Method name a call invokes (`db.from(t).insert(x)` → "insert", `exec(x)` → "exec"). */
13551429
function calleeName(call: any, ts: TsModule): string | undefined {
13561430
const c = call?.expression;
13571431
if (!c) return undefined;
13581432
if (ts.isPropertyAccessExpression(c)) return c.name.text;
1359-
if (ts.isIdentifier(c)) return c.text;
1433+
if (ts.isIdentifier(c)) return c.text; // also covers `new Function(...)`
13601434
return undefined;
13611435
}
13621436

13631437
/** Role of argument `index` for this call, given the sink kind it was recognized as. */
1364-
function argumentRoleOf(sinkKind: string, method: string | undefined, index: number): ArgumentRole {
1438+
function argumentRoleOf(sinkKind: string, method: string | undefined, index: number, total = 0): ArgumentRole {
1439+
// `new Function(a, b, "return a+b")` — every argument but the LAST declares a parameter name; only the
1440+
// last one is executable code. An index-based table cannot express that.
1441+
if (sinkKind === 'eval' && method === 'Function') return index === total - 1 ? 'code' : 'args';
13651442
const table = method ? ARGUMENT_ROLES[sinkKind]?.[method] : undefined;
13661443
return table?.[index] ?? 'unknown';
13671444
}
@@ -1396,7 +1473,7 @@ function fluentChainCalls(call: any, ts: TsModule): any[] {
13961473
const out: any[] = [];
13971474
const collect = (n: any) => {
13981475
if (!n) return;
1399-
if (ts.isCallExpression(n)) out.push(n);
1476+
if (ts.isCallExpression(n) || ts.isNewExpression(n)) out.push(n);
14001477
if (ts.isCallExpression(n) || ts.isPropertyAccessExpression(n) || ts.isAwaitExpression(n) || ts.isParenthesizedExpression(n) || ts.isNonNullExpression(n)) {
14011478
collect(n.expression);
14021479
}
@@ -1429,6 +1506,46 @@ function taintedReadPaths(node: any, ts: TsModule, rootPath: Map<string, string>
14291506
return out;
14301507
}
14311508

1509+
/**
1510+
* Shapes that defeat parameter pinning, found in a sink call's arguments. Reporting these is the point:
1511+
* "we could not model this" is far more useful to an operator than an endpoint that silently shows no
1512+
* flow, and it is the queue for improving the extractor.
1513+
* - `insert({ v: body[field] })` → the field is chosen at runtime; no coordinate can name it.
1514+
* - `insert({ ...body })` → the whole payload reaches the sink; which field is unidentifiable.
1515+
*/
1516+
function sinkArgumentLimitations(node: any, ts: TsModule, rootPath: Map<string, string>): Limitation[] {
1517+
const out: Limitation[] = [];
1518+
const seen = new Set<string>();
1519+
const add = (kind: Limitation['kind'], detail: string, n: any) => {
1520+
const key = `${kind}:${detail}`;
1521+
if (seen.has(key)) return;
1522+
seen.add(key);
1523+
out.push({ kind, detail, line: lineOf(n) });
1524+
};
1525+
const text = (n: any) => {
1526+
try { return String(n.getText()).replace(/\s+/g, ' ').slice(0, 120); } catch { return '<expression>'; }
1527+
};
1528+
const visit = (n: any) => {
1529+
if (!n) return;
1530+
// A computed member read off tainted data with a non-literal index.
1531+
if (ts.isElementAccessExpression(n)) {
1532+
const root = rootIdentifier(n.expression, ts);
1533+
const arg = n.argumentExpression;
1534+
if (root && rootPath.has(root) && arg && !ts.isStringLiteralLike(arg) && !ts.isNumericLiteral(arg)) {
1535+
add('dynamic-key', text(n), n);
1536+
}
1537+
}
1538+
// A spread of tainted data into the sink's argument.
1539+
if ((ts.isSpreadAssignment?.(n) || ts.isSpreadElement(n)) && n.expression) {
1540+
const root = rootIdentifier(n.expression, ts);
1541+
if (root && rootPath.has(root)) add('spread-into-sink', text(n.parent ?? n), n);
1542+
}
1543+
ts.forEachChild(n, visit);
1544+
};
1545+
visit(node);
1546+
return out;
1547+
}
1548+
14321549
/** Canonical path of a member/element access rooted in a tainted binding, or undefined if not tainted. */
14331550
function pathFromTainted(node: any, ts: TsModule, rootPath: Map<string, string>): string | undefined {
14341551
const segs: string[] = [];
@@ -1489,12 +1606,21 @@ function localCalls(node: any, ts: TsModule): string[] {
14891606
return names;
14901607
}
14911608

1609+
// Deterministic identity for a sink, so `Flow.sink` (an embedded copy) can be correlated back to the
1610+
// inventory entry without deep-equality.
1611+
function sinkId(s: Sink): string {
1612+
return createHash('sha256')
1613+
.update([s.kind, s.provider, s.package, s.table, s.op, s.file, s.start, s.end].join('|'))
1614+
.digest('hex')
1615+
.slice(0, 12);
1616+
}
1617+
14921618
function dedupeSinks(sinks: Sink[]): Sink[] {
14931619
const seen = new Set<string>();
14941620
const out: Sink[] = [];
14951621
for (const s of sinks) {
1496-
const key = `${s.kind}:${s.provider}:${s.package}:${s.table}:${s.op}:${s.line}`;
1497-
if (!seen.has(key)) { seen.add(key); out.push(s); }
1622+
const key = `${s.kind}:${s.provider}:${s.package}:${s.table}:${s.op}:${s.line}:${s.start}`;
1623+
if (!seen.has(key)) { seen.add(key); out.push({ ...s, id: sinkId(s) }); }
14981624
}
14991625
return out;
15001626
}

src/map/types.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,12 @@ export interface InputField {
4141
runtimeParameterReason?: string;
4242
}
4343

44+
/** Sink families the extractor recognizes today. Kept as a closed union so a consumer can exhaustively
45+
* switch on it; add a member here when a recognizer is added. */
46+
export type SinkKind = 'db' | 'fs' | 'http' | 'exec' | 'eval';
47+
4448
export interface Sink {
45-
/** db | fs | http | exec | template | redirect | … */
46-
kind: string;
49+
kind: SinkKind;
4750
/** e.g. "supabase", "pg", "fetch". */
4851
provider?: string;
4952
/**
@@ -71,6 +74,20 @@ export interface Sink {
7174
*/
7275
start?: number;
7376
end?: number;
77+
/**
78+
* Stable identity of this sink within the map. `Flow.sink` is an embedded COPY for convenience, so
79+
* correlate the two on this id rather than by deep-equality — a copy that ever drifts from the
80+
* inventory entry would otherwise look like a second, distinct sink.
81+
*/
82+
id?: string;
83+
}
84+
85+
/** Something the analyser could not model at this endpoint — i.e. why it cannot be rule-generated. */
86+
export interface Limitation {
87+
kind: 'dynamic-key' | 'spread-into-sink' | 'non-static-sink-argument' | 'unresolved-helper';
88+
/** The offending expression as written, e.g. `body[field]`. */
89+
detail: string;
90+
line?: number;
7491
}
7592

7693
export interface Endpoint {
@@ -113,6 +130,12 @@ export interface Endpoint {
113130
* `inputs` are UNKNOWN rather than empty. Absent when the extracted inputs can be trusted as-is.
114131
*/
115132
inputsResolved?: boolean;
133+
/**
134+
* Why this endpoint (or a sink within it) cannot be turned into a rule — a dynamic computed key, a
135+
* spread that hides which field reaches the sink, etc. This is the improvement queue: it is more
136+
* useful than silently emitting an incomplete picture.
137+
*/
138+
limitations?: Limitation[];
116139
}
117140

118141
/**
@@ -172,6 +195,12 @@ export interface Coverage {
172195
filesDiscovered: number;
173196
/** Files actually parsed (passed the entry-point pre-filter). */
174197
filesParsed: number;
198+
/**
199+
* Files skipped BEFORE parsing because they contained no entry-point signal at all. These are not
200+
* failures — most of a project is client code. Reported explicitly so a consumer never has to infer
201+
* it by subtracting, which reads as "91% unanalysed".
202+
*/
203+
filesPreFiltered: number;
175204
/** Files skipped because they could not be read/parsed (fail-open). */
176205
filesSkipped: number;
177206
/** Source roots analyzed, repo-relative. */

0 commit comments

Comments
 (0)