@clear-capabilities/agentic-security-scanner 0.183.1 → 0.185.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 +29 -0
- package/bin/agentic-security.js +88 -0
- package/dist/{2a8456e0800aeee871b6.json → 0b301d45b231f144a1b4.json} +27 -9
- package/dist/1128.index.js +908 -0
- package/dist/1336.index.js +261 -112
- package/dist/1665.index.js +828 -0
- package/dist/2923.index.js +17 -9
- package/dist/9382.index.js +23 -2
- package/dist/9579.index.js +11 -4
- package/dist/agentic-security.mjs +15 -15
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/frontend/src/shell.js +12 -2
- package/package.json +27 -9
- package/src/context-intelligence/controls.js +15 -0
- package/src/context-intelligence/reachability.js +8 -2
- package/src/dataflow/CLAUDE.md +43 -1
- package/src/dataflow/backward.js +42 -7
- package/src/dataflow/cross-service-taint.js +83 -1
- package/src/dataflow/demand-query.js +929 -0
- package/src/dataflow/dep-fingerprint.js +718 -0
- package/src/dataflow/engine.js +48 -6
- package/src/dataflow/ifds-precise.js +22 -0
- package/src/dataflow/incremental-replay.js +118 -0
- package/src/dataflow/incremental-session.js +257 -0
- package/src/dataflow/incremental.js +237 -35
- package/src/dataflow/index.js +25 -94
- package/src/dataflow/summaries.js +72 -6
- package/src/dataflow/tabulation.js +80 -24
- package/src/discovery/hunter.js +6 -2
- package/src/discovery/index.js +5 -2
- package/src/engine.js +63 -3
- package/src/graph/CLAUDE.md +31 -0
- package/src/graph/adapters/artifact.js +81 -0
- package/src/graph/adapters/crossrepo.js +75 -0
- package/src/graph/adapters/dataflow.js +76 -0
- package/src/graph/adapters/provenance.js +92 -0
- package/src/graph/adapters/semantic.js +89 -0
- package/src/graph/crosswalk-builders.js +152 -0
- package/src/graph/crosswalk.js +146 -0
- package/src/graph/index.js +15 -0
- package/src/graph/observation.js +57 -0
- package/src/graph/refs.js +105 -0
- package/src/graph/registry.js +129 -0
- package/src/graph/relations.js +81 -0
- package/src/graph/scc.js +241 -0
- package/src/graph/view.js +254 -0
- package/src/language/haskell-disclosure.js +13 -1
- package/src/lineage/CLAUDE.md +71 -8
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
- package/src/lineage/boundary-contract.js +273 -0
- package/src/lineage/completion.js +205 -0
- package/src/lineage/continuation.js +140 -0
- package/src/lineage/control-credit.js +313 -0
- package/src/lineage/correlation.js +199 -0
- package/src/lineage/coverage.js +8 -1
- package/src/lineage/cross-repo-link.js +22 -0
- package/src/lineage/cut-planner.js +548 -0
- package/src/lineage/driver.js +219 -66
- package/src/lineage/engine.js +166 -21
- package/src/lineage/export-json.js +5 -0
- package/src/lineage/graph-builder.js +84 -7
- package/src/lineage/haskell-view.js +59 -0
- package/src/lineage/index.js +3 -0
- package/src/lineage/nix-view.js +35 -0
- package/src/lineage/observation-adapters.js +17 -9
- package/src/lineage/owned-build.js +77 -0
- package/src/lineage/owned-worker-entry.js +22 -0
- package/src/lineage/owned-worker.js +95 -0
- package/src/lineage/path-evidence.js +123 -0
- package/src/lineage/path-predicates.js +529 -0
- package/src/lineage/path-query.js +189 -20
- package/src/lineage/path-store.js +191 -17
- package/src/lineage/scc-solver.js +186 -0
- package/src/lineage/smt-orchestrator.js +138 -0
- package/src/lineage/source-seeding.js +36 -2
- package/src/lineage/summaries.js +66 -4
- package/src/lineage/value-versions.js +130 -0
- package/src/lineage/witness-selection.js +95 -0
- package/src/pipeline/scan-health.js +19 -0
- package/src/posture/CLAUDE.md +13 -0
- package/src/posture/evidence-bundle.js +1 -1
- package/src/posture/witness-bundle.js +573 -0
- package/src/posture/witness-runtime.js +276 -0
- package/src/report/index.js +2 -0
|
@@ -0,0 +1,929 @@
|
|
|
1
|
+
// Demand-driven reverse taint (DAG-02): a sink query answered by a finite
|
|
2
|
+
// backward demand frontier plus the EXISTING forward engine run on the
|
|
3
|
+
// selected region only.
|
|
4
|
+
//
|
|
5
|
+
// Roles (R02.5, no existing implementation is removed):
|
|
6
|
+
// tabulation.js coarse caller-only function selector (reachabilitySliceFromSinks)
|
|
7
|
+
// backward.js intraprocedural explanation trace of a finding
|
|
8
|
+
// ifds-precise.js depth-capped predecessor chain
|
|
9
|
+
// this module sink-keyed selector that follows definitions, parameter and
|
|
10
|
+
// return mappings, receiver fields, aliases, producer helpers
|
|
11
|
+
// and callbacks, then lets runTaintEngine corroborate.
|
|
12
|
+
//
|
|
13
|
+
// Soundness rules this module is built around:
|
|
14
|
+
// 1. The frontier only SELECTS functions. A backward/forward intersection is a
|
|
15
|
+
// candidate filter and is reported as one; the only witness is a finding the
|
|
16
|
+
// forward engine produced over the selected region.
|
|
17
|
+
// 2. A construct the selector cannot follow (instance fields, an exhausted
|
|
18
|
+
// depth or work budget, an unresolved higher-order shape) never silently
|
|
19
|
+
// drops a function. Default policy expands to the full function set
|
|
20
|
+
// (`onLimit` / `onUnsupported` = 'full'); the 'partial' policy instead
|
|
21
|
+
// reports partial scope with negativeClaimAllowed false.
|
|
22
|
+
// 3. A limited reverse walk never authorizes a negative verdict. The verdict
|
|
23
|
+
// vocabulary has no "safe": `flow-found`, `no-flow-in-modeled-scope`
|
|
24
|
+
// (only when the completion envelope allows a negative claim) and
|
|
25
|
+
// `unknown`.
|
|
26
|
+
//
|
|
27
|
+
// Opt-in: AGENTIC_SECURITY_DEMAND=1 (see demandEnabled) plus the API below. The
|
|
28
|
+
// default scan never calls this module.
|
|
29
|
+
|
|
30
|
+
import * as crypto from 'node:crypto';
|
|
31
|
+
import { accessPathOf } from './access-paths.js';
|
|
32
|
+
import { matchSource, matchSinkOrSanitizer, matchMemberWriteSink, _catalogSize } from './catalog.js';
|
|
33
|
+
import { lookupBuiltinSummary } from './builtin-summaries.js';
|
|
34
|
+
import { runTaintEngine } from './engine.js';
|
|
35
|
+
import { buildCallerQidIndex, reachabilitySliceWithCompletion } from './tabulation.js';
|
|
36
|
+
import { makeCompletion } from '../lineage/completion.js';
|
|
37
|
+
|
|
38
|
+
const DEMAND_SCHEMA_VERSION = '1.0.0';
|
|
39
|
+
const DEMAND_CONTEXTS = Object.freeze(['whole-program']);
|
|
40
|
+
const DEFAULT_MAX_CALLER_DEPTH = 64;
|
|
41
|
+
const DEFAULT_MAX_WORK = 50000;
|
|
42
|
+
const MAX_PATH_SEGMENTS = 6;
|
|
43
|
+
|
|
44
|
+
/** Demand mode is opt-in: this env flag AND an explicit API/query list. */
|
|
45
|
+
export function demandEnabled(env = process.env) {
|
|
46
|
+
return env.AGENTIC_SECURITY_DEMAND === '1';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// R02.1 query contract
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Normalize a sink query. Keys: exact sink site (file + line, optionally
|
|
55
|
+
* nodeId / callee / sinkId to narrow), argument index, field/access path within
|
|
56
|
+
* the sink argument, analysis context, rule family (CWE id or family name), and
|
|
57
|
+
* the graph/configuration digests the answer is valid for.
|
|
58
|
+
*/
|
|
59
|
+
export function normalizeSinkQuery(q = {}) {
|
|
60
|
+
const num = (v) => (Number.isInteger(v) && v >= 0 ? v : null);
|
|
61
|
+
const str = (v) => (typeof v === 'string' && v.length ? v : null);
|
|
62
|
+
return {
|
|
63
|
+
file: str(q.file),
|
|
64
|
+
line: num(q.line),
|
|
65
|
+
nodeId: str(q.nodeId),
|
|
66
|
+
fnQid: str(q.fnQid),
|
|
67
|
+
callee: str(q.callee),
|
|
68
|
+
sinkId: str(q.sinkId),
|
|
69
|
+
argIndex: num(q.argIndex),
|
|
70
|
+
accessPath: str(q.accessPath),
|
|
71
|
+
context: str(q.context) || 'whole-program',
|
|
72
|
+
ruleFamily: str(q.ruleFamily),
|
|
73
|
+
graphDigest: str(q.graphDigest),
|
|
74
|
+
configDigest: str(q.configDigest),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const _digestCache = new WeakMap();
|
|
79
|
+
|
|
80
|
+
/** Stable digest of the call graph content the query was answered over. */
|
|
81
|
+
export function graphDigest(callGraph) {
|
|
82
|
+
if (!callGraph || !callGraph.functions) return null;
|
|
83
|
+
if (_digestCache.has(callGraph)) return _digestCache.get(callGraph);
|
|
84
|
+
const h = crypto.createHash('sha256');
|
|
85
|
+
for (const qid of [...callGraph.functions.keys()].sort()) {
|
|
86
|
+
const fn = callGraph.functions.get(qid);
|
|
87
|
+
h.update(qid); h.update('\0');
|
|
88
|
+
h.update(JSON.stringify(fn.params || [])); h.update('\0');
|
|
89
|
+
h.update(JSON.stringify(fn.cfg ? fn.cfg.nodes : null)); h.update('\n');
|
|
90
|
+
}
|
|
91
|
+
const d = h.digest('hex').slice(0, 32);
|
|
92
|
+
_digestCache.set(callGraph, d);
|
|
93
|
+
return d;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Digest of everything besides the graph that can change an answer. */
|
|
97
|
+
export function configDigest(opts = {}) {
|
|
98
|
+
const h = crypto.createHash('sha256');
|
|
99
|
+
h.update(JSON.stringify({
|
|
100
|
+
catalog: _catalogSize(),
|
|
101
|
+
kcfa: process.env.AGENTIC_SECURITY_KCFA_MAX_CONTEXTS || null,
|
|
102
|
+
callstring: process.env.AGENTIC_SECURITY_KCFA_CALLSTRING || null,
|
|
103
|
+
fnLimit: opts.fnLimit || null,
|
|
104
|
+
maxCallerDepth: opts.maxCallerDepth || DEFAULT_MAX_CALLER_DEPTH,
|
|
105
|
+
maxWork: opts.maxWork || DEFAULT_MAX_WORK,
|
|
106
|
+
onLimit: opts.onLimit || 'full',
|
|
107
|
+
onUnsupported: opts.onUnsupported || 'full',
|
|
108
|
+
}));
|
|
109
|
+
return h.digest('hex').slice(0, 16);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Stable key of a normalized query bound to its digests. */
|
|
113
|
+
export function sinkQueryKey(query, digests = {}) {
|
|
114
|
+
const q = normalizeSinkQuery(query);
|
|
115
|
+
q.graphDigest = digests.graphDigest ?? q.graphDigest;
|
|
116
|
+
q.configDigest = digests.configDigest ?? q.configDigest;
|
|
117
|
+
return crypto.createHash('sha256').update(JSON.stringify(q)).digest('hex').slice(0, 24);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Expression helpers
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
const SELF_ROOTS = new Set(['this', '_this_', 'self', 'cls']);
|
|
125
|
+
const REQ_PARAM = /^(?:req|request|ctx)$/;
|
|
126
|
+
// Roots that are language builtins or ambient globals, so a call on them is
|
|
127
|
+
// modeled by the engine's own summaries rather than being an unresolved callee.
|
|
128
|
+
const BUILTIN_ROOTS = new Set(['console', 'JSON', 'Math', 'Object', 'Array', 'String', 'Number', 'Boolean', 'Promise', 'Buffer', 'process', 'Date', 'RegExp', 'Set', 'Map', 'Symbol', 'Error', 'parseInt', 'parseFloat', 'encodeURIComponent', 'decodeURIComponent', 'require']);
|
|
129
|
+
|
|
130
|
+
const HIGHER_ORDER_METHODS = new Set(['map', 'forEach', 'filter', 'flatMap', 'find', 'findIndex', 'some', 'every', 'reduce', 'reduceRight', 'sort', 'then', 'catch', 'finally', 'all', 'allSettled', 'race', 'any']);
|
|
131
|
+
|
|
132
|
+
function isExpr(v) { return v && typeof v === 'object' && typeof v.kind === 'string'; }
|
|
133
|
+
|
|
134
|
+
function visitVal(v, f) {
|
|
135
|
+
if (!v || typeof v !== 'object') return;
|
|
136
|
+
if (isExpr(v)) { f(v); return; }
|
|
137
|
+
if (Array.isArray(v)) { for (const x of v) visitVal(x, f); return; }
|
|
138
|
+
for (const x of Object.values(v)) visitVal(x, f);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function forEachChild(e, f) {
|
|
142
|
+
for (const k of Object.keys(e)) {
|
|
143
|
+
if (k === 'kind') continue;
|
|
144
|
+
visitVal(e[k], f);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const rootOf = (p) => { const i = p.indexOf('.'); return i < 0 ? p : p.slice(0, i); };
|
|
149
|
+
|
|
150
|
+
/** Segment-wise overlap; a `*` segment (computed key) matches any segment. */
|
|
151
|
+
const _segs = new Map();
|
|
152
|
+
function segsOf(s) { let v = _segs.get(s); if (!v) { v = s.split('.'); if (_segs.size > 200000) _segs.clear(); _segs.set(s, v); } return v; }
|
|
153
|
+
function segOverlap(a, b) {
|
|
154
|
+
if (a === b) return true;
|
|
155
|
+
const x = segsOf(a); const y = segsOf(b);
|
|
156
|
+
const n = Math.min(x.length, y.length);
|
|
157
|
+
for (let i = 0; i < n; i++) if (x[i] !== y[i] && x[i] !== '*' && y[i] !== '*') return false;
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function exprOfPath(path) {
|
|
162
|
+
const segs = path.split('.');
|
|
163
|
+
let e = { kind: 'ident', name: segs[0] };
|
|
164
|
+
for (let i = 1; i < segs.length; i++) e = { kind: 'member', object: e, prop: segs[i] };
|
|
165
|
+
return e;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function calleeName(callee) {
|
|
169
|
+
if (typeof callee === 'string') return callee;
|
|
170
|
+
if (!callee) return null;
|
|
171
|
+
if (callee.kind === 'ident') return callee.name;
|
|
172
|
+
if (callee.kind === 'member') return accessPathOf(callee);
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Function facts and the extended incoming-call index (R02.2)
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
const _infoCache = new WeakMap();
|
|
181
|
+
|
|
182
|
+
function fnInfo(fn) {
|
|
183
|
+
if (_infoCache.has(fn)) return _infoCache.get(fn);
|
|
184
|
+
const info = { params: Array.isArray(fn.params) ? fn.params.filter((p) => typeof p === 'string') : [], assigns: [], calls: [], returns: [], fnValues: new Set(), assignedRoots: new Set(), hasOpaqueAssign: false };
|
|
185
|
+
const walk = (e, nid, line) => {
|
|
186
|
+
if (!e || typeof e !== 'object') return;
|
|
187
|
+
if (e.kind === 'function-value') { if (e.qid) info.fnValues.add(e.qid); return; }
|
|
188
|
+
if (e.kind === 'call') info.calls.push({ expr: e, nid, line: e.line || line });
|
|
189
|
+
if (isExpr(e)) forEachChild(e, (c) => walk(c, nid, line));
|
|
190
|
+
};
|
|
191
|
+
const nodes = (fn.cfg && fn.cfg.nodes) || {};
|
|
192
|
+
for (const nid of Object.keys(nodes)) {
|
|
193
|
+
const n = nodes[nid];
|
|
194
|
+
if (!n) continue;
|
|
195
|
+
if (n.kind === 'assign') {
|
|
196
|
+
if (typeof n.target === 'string') { info.assigns.push({ nid, target: n.target, source: n.source, line: n.line }); info.assignedRoots.add(rootOf(n.target)); }
|
|
197
|
+
else { info.assigns.push({ nid, target: null, source: n.source, line: n.line, opaque: true }); info.hasOpaqueAssign = true; }
|
|
198
|
+
walk(n.source, nid, n.line);
|
|
199
|
+
} else if (n.kind === 'call') {
|
|
200
|
+
info.calls.push({ expr: n, nid, line: n.line, top: true });
|
|
201
|
+
for (const a of (n.args || [])) walk(a, nid, n.line);
|
|
202
|
+
if (n.callee && typeof n.callee === 'object') walk(n.callee, nid, n.line);
|
|
203
|
+
} else if (n.kind === 'return') {
|
|
204
|
+
if (n.value) { info.returns.push(n.value); walk(n.value, nid, n.line); }
|
|
205
|
+
} else {
|
|
206
|
+
for (const k of Object.keys(n)) {
|
|
207
|
+
if (['id', 'kind', 'succ', 'pred', 'line'].includes(k)) continue;
|
|
208
|
+
visitVal(n[k], (c) => walk(c, nid, n.line));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
_infoCache.set(fn, info);
|
|
213
|
+
return info;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const _indexCache = new WeakMap();
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Incoming-call index over the WHOLE call graph: callee qid -> sites, each
|
|
220
|
+
* {caller, nid, call, kind: 'call'|'callback'|'callback-ref'}. Starts from the
|
|
221
|
+
* canonical buildCallerQidIndex (statement-level calls) and adds embedded call
|
|
222
|
+
* sites that `fn.calls` omits, plus callback registrations. Also indexes
|
|
223
|
+
* functions by bare name for method-name candidate expansion.
|
|
224
|
+
*/
|
|
225
|
+
export function buildIncomingIndex(callGraph) {
|
|
226
|
+
if (_indexCache.has(callGraph)) return _indexCache.get(callGraph);
|
|
227
|
+
const incoming = new Map();
|
|
228
|
+
const byTail = new Map();
|
|
229
|
+
const modules = new Map(); const byFile = new Map();
|
|
230
|
+
const add = (qid, site) => { if (!incoming.has(qid)) incoming.set(qid, []); incoming.get(qid).push(site); };
|
|
231
|
+
const resolveName = (name, file) => (name && callGraph.resolveKnownCallee ? callGraph.resolveKnownCallee(name, file) : null);
|
|
232
|
+
for (const fn of callGraph.functions.values()) {
|
|
233
|
+
const tail = String(fn.name || '').split('.').pop();
|
|
234
|
+
if (tail) { if (!byTail.has(tail)) byTail.set(tail, []); byTail.get(tail).push(fn.qid); }
|
|
235
|
+
if (!byFile.has(fn.file)) byFile.set(fn.file, []); byFile.get(fn.file).push(fn);
|
|
236
|
+
if (fn.name === '<module>' && String(fn.qid).includes('::top::')) { if (!modules.has(fn.file)) modules.set(fn.file, []); modules.get(fn.file).push(fn.qid); }
|
|
237
|
+
}
|
|
238
|
+
const base = buildCallerQidIndex(callGraph); // canonical statement-level edges, retained for parity checks
|
|
239
|
+
for (const fn of callGraph.functions.values()) {
|
|
240
|
+
const info = fnInfo(fn);
|
|
241
|
+
for (const occ of info.calls) {
|
|
242
|
+
const name = calleeName(occ.expr.callee);
|
|
243
|
+
const g = resolveName(name, fn.file);
|
|
244
|
+
if (g) add(g, { caller: fn.qid, nid: occ.nid, call: occ.expr, kind: 'call' });
|
|
245
|
+
for (const a of (occ.expr.args || [])) {
|
|
246
|
+
if (a && a.kind === 'function-value' && a.qid) add(a.qid, { caller: fn.qid, nid: occ.nid, call: occ.expr, kind: 'callback' });
|
|
247
|
+
else if (a && a.kind === 'ident') {
|
|
248
|
+
const r = resolveName(a.name, fn.file);
|
|
249
|
+
if (r && r !== fn.qid) add(r, { caller: fn.qid, nid: occ.nid, call: occ.expr, kind: 'callback-ref' });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const idx = { incoming, byTail, modules, byFile, base };
|
|
255
|
+
_indexCache.set(callGraph, idx);
|
|
256
|
+
return idx;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// Sink-site discovery (nested sinks in assignment and return expressions)
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
function matchesFamily(entry, family) {
|
|
264
|
+
if (!family) return true;
|
|
265
|
+
const v = entry.vuln || {};
|
|
266
|
+
return v.cwe === family || entry.id === family || (v.name && v.name === family) || (entry.family && entry.family === family);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Enumerate sink sites in the graph that satisfy the query's site keys. */
|
|
270
|
+
export function findSinkSites(callGraph, query) {
|
|
271
|
+
const q = normalizeSinkQuery(query);
|
|
272
|
+
const sites = [];
|
|
273
|
+
for (const fn of callGraph.functions.values()) {
|
|
274
|
+
if (q.file && fn.file !== q.file) continue;
|
|
275
|
+
if (q.fnQid && fn.qid !== q.fnQid) continue;
|
|
276
|
+
const info = fnInfo(fn);
|
|
277
|
+
for (const occ of info.calls) {
|
|
278
|
+
if (q.line != null && occ.line !== q.line) continue;
|
|
279
|
+
if (q.nodeId && occ.nid !== q.nodeId) continue;
|
|
280
|
+
const hits = matchSinkOrSanitizer(occ.expr.callee, fn.file) || [];
|
|
281
|
+
for (const e of hits) {
|
|
282
|
+
if (e.kind !== 'sink' || !matchesFamily(e, q.ruleFamily)) continue;
|
|
283
|
+
if (q.sinkId && e.id !== q.sinkId) continue;
|
|
284
|
+
if (q.callee && calleeName(occ.expr.callee) !== q.callee) continue;
|
|
285
|
+
sites.push({ file: fn.file, fnQid: fn.qid, nodeId: occ.nid, line: occ.line, entry: e, sinkId: e.id, argExprs: occ.expr.args || [], callee: calleeName(occ.expr.callee), kind: 'call' });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
for (const a of info.assigns) {
|
|
289
|
+
if (!a.target || (q.line != null && a.line !== q.line)) continue;
|
|
290
|
+
if (q.nodeId && a.nid !== q.nodeId) continue;
|
|
291
|
+
const hits = matchMemberWriteSink(a.target, fn.file) || [];
|
|
292
|
+
for (const e of hits) {
|
|
293
|
+
if (!matchesFamily(e, q.ruleFamily) || (q.sinkId && e.id !== q.sinkId)) continue;
|
|
294
|
+
if (q.callee && a.target !== q.callee) continue;
|
|
295
|
+
sites.push({ file: fn.file, fnQid: fn.qid, nodeId: a.nid, line: a.line, entry: e, sinkId: e.id, argExprs: [a.source], callee: a.target, kind: 'member-write' });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// The IR can carry one call both as a statement node and as the value of a
|
|
300
|
+
// return node; both name the same source site.
|
|
301
|
+
const seenSite = new Set();
|
|
302
|
+
for (let i = sites.length - 1; i >= 0; i--) {
|
|
303
|
+
const s = sites[i];
|
|
304
|
+
const k = `${s.fnQid}|${s.line}|${s.sinkId}|${s.callee}`;
|
|
305
|
+
if (seenSite.has(k)) sites.splice(i, 1); else seenSite.add(k);
|
|
306
|
+
}
|
|
307
|
+
sites.sort((x, y) => (x.fnQid < y.fnQid ? -1 : x.fnQid > y.fnQid ? 1 : x.nodeId < y.nodeId ? -1 : x.nodeId > y.nodeId ? 1 : x.sinkId < y.sinkId ? -1 : 1));
|
|
308
|
+
return sites;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// R02.2 / R02.4: the backward demand frontier
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
class Frontier {
|
|
316
|
+
constructor(callGraph, opts) {
|
|
317
|
+
this.cg = callGraph;
|
|
318
|
+
this.idx = buildIncomingIndex(callGraph);
|
|
319
|
+
this.maxDepth = opts.maxCallerDepth ?? DEFAULT_MAX_CALLER_DEPTH;
|
|
320
|
+
this.maxWork = opts.maxWork ?? DEFAULT_MAX_WORK;
|
|
321
|
+
this.hasCha = !!(opts.engineOpts && opts.engineOpts._cha);
|
|
322
|
+
this.region = new Map(); // qid -> Set(reason)
|
|
323
|
+
this.work = [];
|
|
324
|
+
this.seen = new Set();
|
|
325
|
+
this.cbDone = new Set();
|
|
326
|
+
this.reqDone = new Set();
|
|
327
|
+
this.steps = 0;
|
|
328
|
+
this.maxDepthReached = 0;
|
|
329
|
+
this.limits = []; // {kind, count}
|
|
330
|
+
this.unsupported = []; // {kind, qid, detail}
|
|
331
|
+
this.unresolved = new Map(); // name@qid -> record
|
|
332
|
+
this.terminals = new Set(); // 'source' | 'param' | 'call' | 'free' | 'callback'
|
|
333
|
+
this.followed = { callers: 0, producers: 0, callbacks: 0, mutators: 0 };
|
|
334
|
+
this.depthCut = 0;
|
|
335
|
+
this.workLeft = 0;
|
|
336
|
+
this.sinkCalls = new Set();
|
|
337
|
+
this.touchCache = new WeakMap();
|
|
338
|
+
this.expanded = new Set();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
addRegion(qid, reason) {
|
|
342
|
+
if (!this.cg.functions.has(qid)) return;
|
|
343
|
+
if (!this.region.has(qid)) this.region.set(qid, new Set());
|
|
344
|
+
this.region.get(qid).add(reason);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
push(qid, path, ep, depth, reason) {
|
|
348
|
+
// recursive field growth (`x = x.next` in a loop) would otherwise generate
|
|
349
|
+
// an unbounded family of paths; fold to a capped prefix, which selects
|
|
350
|
+
// more, never less
|
|
351
|
+
if (path.length > 24 && path.charCodeAt(0) !== 36) {
|
|
352
|
+
const segs = segsOf(path);
|
|
353
|
+
if (segs.length > MAX_PATH_SEGMENTS) path = segs.slice(0, MAX_PATH_SEGMENTS).join('.');
|
|
354
|
+
}
|
|
355
|
+
const k = `${qid}|${path}|${ep ? 1 : 0}`;
|
|
356
|
+
if (this.seen.has(k)) return;
|
|
357
|
+
this.seen.add(k);
|
|
358
|
+
this.work.push({ qid, path, ep, depth, reason });
|
|
359
|
+
if (depth > this.maxDepthReached) this.maxDepthReached = depth;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
run() {
|
|
363
|
+
while (this.work.length) {
|
|
364
|
+
if (++this.steps > this.maxWork) {
|
|
365
|
+
this.limits.push({ kind: 'work-limit', count: this.work.length });
|
|
366
|
+
this.workLeft = this.work.length;
|
|
367
|
+
this.work = [];
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
const item = this.work.pop();
|
|
371
|
+
this.process(item);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
markUnsupported(kind, qid, detail) {
|
|
376
|
+
if (!this.unsupported.some((u) => u.kind === kind && u.qid === qid)) this.unsupported.push({ kind, qid, detail });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Walk an expression in `fn` and register every path/call it depends on. */
|
|
380
|
+
reads(fn, expr, item) {
|
|
381
|
+
const rd = (e) => {
|
|
382
|
+
if (!e || typeof e !== 'object') return;
|
|
383
|
+
if (e.kind === 'literal') return;
|
|
384
|
+
if (e.kind === 'function-value') { this.callbackValue(e.qid, item.depth); return; }
|
|
385
|
+
const p = accessPathOf(e);
|
|
386
|
+
if (p) { this.push(fn.qid, p, item.ep, item.depth, item.reason); this.checkSourceExpr(fn, e, p); return; }
|
|
387
|
+
if (e.kind === 'call') { this.callExpr(fn, e, item); return; }
|
|
388
|
+
if (matchSource(e, fn.file)) this.terminals.add('source');
|
|
389
|
+
forEachChild(e, rd);
|
|
390
|
+
};
|
|
391
|
+
rd(expr);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
callbackValue(qid, depth) {
|
|
395
|
+
if (!qid || !this.cg.functions.has(qid)) return;
|
|
396
|
+
this.followed.callbacks += 1;
|
|
397
|
+
this.addRegion(qid, 'callback');
|
|
398
|
+
this.push(qid, '$ret', false, depth, 'callback');
|
|
399
|
+
this.push(qid, '$params', false, depth, 'callback');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
callExpr(fn, e, item) {
|
|
403
|
+
if (matchSource(e, fn.file)) this.terminals.add('source');
|
|
404
|
+
const callee = e.callee;
|
|
405
|
+
// receiver: taint on the receiver flows through the call (`tainted.trim()`)
|
|
406
|
+
if (callee && typeof callee === 'object' && callee.kind === 'member') {
|
|
407
|
+
const rp = accessPathOf(callee.object);
|
|
408
|
+
if (rp) this.push(fn.qid, rp, item.ep, item.depth, item.reason);
|
|
409
|
+
else this.reads(fn, callee.object, item);
|
|
410
|
+
} else if (typeof callee === 'string' && callee.includes('.')) {
|
|
411
|
+
this.push(fn.qid, callee.slice(0, callee.lastIndexOf('.')), item.ep, item.depth, item.reason);
|
|
412
|
+
}
|
|
413
|
+
for (const a of (e.args || [])) this.reads(fn, a, item);
|
|
414
|
+
const name = calleeName(callee);
|
|
415
|
+
const g = name && this.cg.resolveKnownCallee ? this.cg.resolveKnownCallee(name, fn.file) : null;
|
|
416
|
+
if (g) {
|
|
417
|
+
this.followed.producers += 1;
|
|
418
|
+
this.addRegion(g, 'producer');
|
|
419
|
+
this.push(g, '$ret', false, item.depth, 'producer');
|
|
420
|
+
this.terminals.add('call');
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
// member call on an unresolved receiver: the engine may resolve it through
|
|
424
|
+
// class-hierarchy analysis, so every project function with that bare name
|
|
425
|
+
// is a candidate producer (conservative expansion, never a drop).
|
|
426
|
+
const tail = name ? name.split('.').pop() : null;
|
|
427
|
+
// Only the engine's class-hierarchy resolution (opts._cha) can reach such a
|
|
428
|
+
// callee, so without it there is nothing to over-select; with it, only
|
|
429
|
+
// class methods are candidates.
|
|
430
|
+
const cands = tail && this.hasCha && callee && typeof callee === 'object' && callee.kind === 'member'
|
|
431
|
+
? (this.idx.byTail.get(tail) || []).filter((q) => /::[A-Za-z_$][\w$]*::[\w$]+@/.test(q) && !q.includes('::<module>::'))
|
|
432
|
+
: null;
|
|
433
|
+
if (cands && cands.length) {
|
|
434
|
+
for (const c of cands) { this.followed.producers += 1; this.addRegion(c, 'method-candidate'); this.push(c, '$ret', false, item.depth, 'method-candidate'); this.push(c, '$params', false, item.depth, 'method-candidate'); }
|
|
435
|
+
this.terminals.add('call');
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const info = fnInfo(fn);
|
|
439
|
+
const prop = callee && typeof callee === 'object' && callee.kind === 'member' && typeof callee.prop === 'string' ? callee.prop : (name ? name.split('.').pop() : null);
|
|
440
|
+
// A call through a parameter is a callback invocation (the registration
|
|
441
|
+
// site is followed by expandCallers); an array or promise combinator is
|
|
442
|
+
// modeled by the engine's higher-order handling.
|
|
443
|
+
const viaParam = callee && typeof callee === 'object' && callee.kind === 'ident' && info.params.includes(callee.name);
|
|
444
|
+
const modeled = viaParam || (prop && HIGHER_ORDER_METHODS.has(prop)) || (callee && matchSinkOrSanitizer(callee, fn.file)) || (name && lookupBuiltinSummary(name)) || (name && BUILTIN_ROOTS.has(rootOf(name)));
|
|
445
|
+
if (!modeled) {
|
|
446
|
+
const key = `${name || '<dynamic>'}@${fn.qid}`;
|
|
447
|
+
if (!this.unresolved.has(key)) this.unresolved.set(key, { callee: name || '<dynamic>', fnQid: fn.qid, line: e.line || null });
|
|
448
|
+
}
|
|
449
|
+
this.terminals.add('call');
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
checkSource(fn, path) {
|
|
453
|
+
const segs = path.split('.');
|
|
454
|
+
for (let i = 1; i <= segs.length; i++) {
|
|
455
|
+
if (matchSource(exprOfPath(segs.slice(0, i).join('.')), fn.file)) { this.terminals.add('source'); return; }
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
checkSourceExpr(fn, e, p) {
|
|
460
|
+
if (matchSource(e, fn.file)) this.terminals.add('source');
|
|
461
|
+
else this.checkSource(fn, p);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
process(item) {
|
|
465
|
+
const fn = this.cg.functions.get(item.qid);
|
|
466
|
+
if (!fn || !fn.cfg || !fn.cfg.nodes) { this.markUnsupported('missing-function-body', item.qid, item.path); return; }
|
|
467
|
+
this.addRegion(item.qid, item.reason || 'sink');
|
|
468
|
+
const info = fnInfo(fn);
|
|
469
|
+
const p = item.path;
|
|
470
|
+
|
|
471
|
+
if (!this.cbDone.has(fn.qid)) {
|
|
472
|
+
this.cbDone.add(fn.qid);
|
|
473
|
+
// callbacks defined inside a selected function run under its taint
|
|
474
|
+
for (const q of info.fnValues) this.callbackValue(q, item.depth);
|
|
475
|
+
this.requestFieldWriters(fn, info, item);
|
|
476
|
+
}
|
|
477
|
+
if (p === '$ret') { for (const r of info.returns) this.reads(fn, r, item); return; }
|
|
478
|
+
if (p === '$params') { for (const prm of info.params) this.push(fn.qid, prm, false, item.depth, item.reason); this.push(fn.qid, '$ret', false, item.depth, item.reason); return; }
|
|
479
|
+
|
|
480
|
+
const root = rootOf(p);
|
|
481
|
+
if (SELF_ROOTS.has(root)) { this.markUnsupported('instance-field', fn.qid, p); return; }
|
|
482
|
+
this.checkSource(fn, p);
|
|
483
|
+
|
|
484
|
+
for (const a of info.assigns) {
|
|
485
|
+
if (a.opaque) { this.reads(fn, a.source, item); continue; }
|
|
486
|
+
if (!segOverlap(a.target, p)) continue;
|
|
487
|
+
// target covers the needed path (equal or a prefix): the value of the
|
|
488
|
+
// needed path comes from the matching part of the source. Otherwise the
|
|
489
|
+
// write is to a sub-field of the needed path (a partial write): the whole
|
|
490
|
+
// source matters.
|
|
491
|
+
const tl = a.target.split('.').length; const pl = p.split('.').length;
|
|
492
|
+
if (tl <= pl) this.assignSource(fn, a, p.split('.').slice(tl).join('.'), item);
|
|
493
|
+
else this.reads(fn, a.source, item);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
for (const occ of info.calls) {
|
|
497
|
+
// the queried sink call itself runs after its argument is formed, so it
|
|
498
|
+
// cannot change what the argument holds
|
|
499
|
+
if (this.sinkCalls.has(`${fn.qid}|${occ.line}|${calleeName(occ.expr.callee)}`)) continue;
|
|
500
|
+
if (!this.callTouches(occ.expr, p)) continue;
|
|
501
|
+
this.followed.mutators += 1;
|
|
502
|
+
this.reads(fn, occ.expr, item);
|
|
503
|
+
const name = calleeName(occ.expr.callee);
|
|
504
|
+
const g = name && this.cg.resolveKnownCallee ? this.cg.resolveKnownCallee(name, fn.file) : null;
|
|
505
|
+
if (g && g !== fn.qid) { this.addRegion(g, 'mutator'); this.push(g, '$params', false, item.depth, 'mutator'); }
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const pi = info.params.indexOf(root);
|
|
509
|
+
if (pi >= 0) {
|
|
510
|
+
this.terminals.add('param');
|
|
511
|
+
if (item.ep) this.expandCallers(fn, pi, item);
|
|
512
|
+
} else if (!info.assignedRoots.has(root) && !info.hasOpaqueAssign && !info.params.includes(root)) {
|
|
513
|
+
this.freeVariable(fn, root, p, item);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
assignSource(fn, a, suffix, item) {
|
|
518
|
+
const src = a.source;
|
|
519
|
+
if (!src) return;
|
|
520
|
+
if (src.kind === 'object' && Array.isArray(src.props)) {
|
|
521
|
+
if (!suffix) { for (const pr of src.props) this.reads(fn, pr.value, item); return; }
|
|
522
|
+
const segs = suffix.split('.');
|
|
523
|
+
const key = segs[0];
|
|
524
|
+
let found = false;
|
|
525
|
+
for (const pr of src.props) {
|
|
526
|
+
if (pr.key === key || pr.key === '*' || key === '*') {
|
|
527
|
+
found = true;
|
|
528
|
+
const rest = segs.slice(1).join('.');
|
|
529
|
+
const vp = accessPathOf(pr.value);
|
|
530
|
+
if (rest && vp) this.push(fn.qid, `${vp}.${rest}`, item.ep, item.depth, item.reason);
|
|
531
|
+
else this.reads(fn, pr.value, item);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
// a spread entry (`...other`) can supply any key
|
|
535
|
+
if (!found || src.props.some((pr) => pr.key === '...' || pr.spread)) this.reads(fn, src, item);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
const sp = accessPathOf(src);
|
|
539
|
+
if (sp) { this.push(fn.qid, suffix ? `${sp}.${suffix}` : sp, item.ep, item.depth, item.reason); this.checkSource(fn, sp); return; }
|
|
540
|
+
this.reads(fn, src, item);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
callTouches(call, p) {
|
|
544
|
+
let paths = this.touchCache.get(call);
|
|
545
|
+
if (paths) return paths.some((q) => segOverlap(q, p));
|
|
546
|
+
paths = [];
|
|
547
|
+
this.touchCache.set(call, paths);
|
|
548
|
+
const collect = (e) => {
|
|
549
|
+
if (!e || typeof e !== 'object') return;
|
|
550
|
+
if (e.kind === 'function-value') return;
|
|
551
|
+
const ap = accessPathOf(e);
|
|
552
|
+
if (ap) { paths.push(ap); return; }
|
|
553
|
+
if (isExpr(e)) forEachChild(e, collect);
|
|
554
|
+
};
|
|
555
|
+
const callee = call.callee;
|
|
556
|
+
if (callee && typeof callee === 'object' && callee.kind === 'member') collect(callee.object);
|
|
557
|
+
else if (typeof callee === 'string' && callee.includes('.')) paths.push(callee.slice(0, callee.lastIndexOf('.')));
|
|
558
|
+
for (const a of (call.args || [])) collect(a);
|
|
559
|
+
return paths.some((q) => segOverlap(q, p));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
expandCallers(fn, paramIdx, item) {
|
|
563
|
+
// the caller-side argument is read whole, so one expansion per parameter
|
|
564
|
+
// covers every access path rooted at it
|
|
565
|
+
const ek = `${fn.qid}|${paramIdx}`;
|
|
566
|
+
if (this.expanded.has(ek)) return;
|
|
567
|
+
this.expanded.add(ek);
|
|
568
|
+
const sites = this.idx.incoming.get(fn.qid) || [];
|
|
569
|
+
if (!sites.length) return;
|
|
570
|
+
if (item.depth + 1 > this.maxDepth) { this.depthCut += sites.length; this.limits.push({ kind: 'depth-limit', count: sites.length }); return; }
|
|
571
|
+
for (const s of sites) {
|
|
572
|
+
const caller = this.cg.functions.get(s.caller);
|
|
573
|
+
if (!caller) continue;
|
|
574
|
+
this.followed.callers += 1;
|
|
575
|
+
this.addRegion(s.caller, s.kind === 'call' ? 'caller' : 'callback-registrar');
|
|
576
|
+
const ni = { ...item, qid: s.caller, depth: item.depth + 1, ep: true, reason: 'caller' };
|
|
577
|
+
if (s.kind === 'call') {
|
|
578
|
+
const arg = (s.call.args || [])[paramIdx];
|
|
579
|
+
if (arg) this.reads(caller, arg, ni);
|
|
580
|
+
const callee = s.call.callee;
|
|
581
|
+
if (callee && typeof callee === 'object' && callee.kind === 'member') this.reads(caller, callee.object, ni);
|
|
582
|
+
} else {
|
|
583
|
+
for (const a of (s.call.args || [])) if (!(a && a.kind === 'function-value' && a.qid === fn.qid)) this.reads(caller, a, ni);
|
|
584
|
+
const callee = s.call.callee;
|
|
585
|
+
if (callee && typeof callee === 'object' && callee.kind === 'member') this.reads(caller, callee.object, ni);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
requestFieldWriters(fn, info, item) {
|
|
591
|
+
if (!item.ep && item.reason !== 'sink') return;
|
|
592
|
+
const p0 = info.params[0];
|
|
593
|
+
if (!p0 || !REQ_PARAM.test(p0) || this.reqDone.has(fn.file)) return;
|
|
594
|
+
this.reqDone.add(fn.file);
|
|
595
|
+
for (const other of (this.idx.byFile.get(fn.file) || [])) {
|
|
596
|
+
if (other.file !== fn.file) continue;
|
|
597
|
+
const oi = fnInfo(other);
|
|
598
|
+
const q0 = oi.params[0];
|
|
599
|
+
if (!q0 || !REQ_PARAM.test(q0)) continue;
|
|
600
|
+
for (const a of oi.assigns) {
|
|
601
|
+
if (a.target && a.target.startsWith(`${q0}.`)) {
|
|
602
|
+
this.addRegion(other.qid, 'request-field-writer');
|
|
603
|
+
this.push(other.qid, a.target, true, item.depth, 'request-field-writer');
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
freeVariable(fn, root, p, item) {
|
|
610
|
+
const definers = [];
|
|
611
|
+
for (const other of (this.idx.byFile.get(fn.file) || [])) {
|
|
612
|
+
if (other.file !== fn.file || other.qid === fn.qid) continue;
|
|
613
|
+
const oi = fnInfo(other);
|
|
614
|
+
if (oi.assignedRoots.has(root) || oi.params.includes(root)) definers.push(other);
|
|
615
|
+
}
|
|
616
|
+
// import bindings (`const x = require(...)`) carry no data of their own
|
|
617
|
+
const importOnly = definers.length > 0 && definers.every((d) => fnInfo(d).assigns.filter((a) => a.target && rootOf(a.target) === root).every((a) => a.source && a.source.kind === 'call' && calleeName(a.source.callee) === 'require'));
|
|
618
|
+
if (!definers.length) return; // ambient global; the engine models it by name only
|
|
619
|
+
this.terminals.add('free');
|
|
620
|
+
if (importOnly) return;
|
|
621
|
+
for (const d of definers) {
|
|
622
|
+
if (d.qid === fn.qid) continue;
|
|
623
|
+
this.addRegion(d.qid, 'free-variable-definer');
|
|
624
|
+
this.push(d.qid, p, item.ep, item.depth, 'free-variable-definer');
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ---------------------------------------------------------------------------
|
|
630
|
+
// Seeds from a sink site
|
|
631
|
+
// ---------------------------------------------------------------------------
|
|
632
|
+
|
|
633
|
+
function seedExprs(site, q) {
|
|
634
|
+
let args;
|
|
635
|
+
if (q.argIndex != null) args = site.argExprs[q.argIndex] ? [site.argExprs[q.argIndex]] : [];
|
|
636
|
+
else if (site.entry.argIndex === 'all') args = site.argExprs;
|
|
637
|
+
else if (typeof site.entry.argIndex === 'number') args = site.argExprs[site.entry.argIndex] ? [site.argExprs[site.entry.argIndex]] : [];
|
|
638
|
+
else args = site.argExprs; // 'rhs' and member-write
|
|
639
|
+
return args;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function seedFrontier(frontier, fn, site, q) {
|
|
643
|
+
const item = { qid: site.fnQid, path: '', ep: true, depth: 0, reason: 'sink' };
|
|
644
|
+
frontier.addRegion(site.fnQid, 'sink');
|
|
645
|
+
if (site.kind === 'call') frontier.sinkCalls.add(`${site.fnQid}|${site.line}|${site.callee}`);
|
|
646
|
+
for (const a of seedExprs(site, q)) {
|
|
647
|
+
if (!q.accessPath) { frontier.reads(fn, a, item); continue; }
|
|
648
|
+
const ap = accessPathOf(a);
|
|
649
|
+
if (ap) { frontier.push(site.fnQid, `${ap}.${q.accessPath}`, true, 0, 'sink'); continue; }
|
|
650
|
+
if (a && a.kind === 'object' && Array.isArray(a.props)) {
|
|
651
|
+
const segs = q.accessPath.split('.');
|
|
652
|
+
for (const pr of a.props) {
|
|
653
|
+
if (pr.key !== segs[0] && pr.key !== '*') continue;
|
|
654
|
+
const vp = accessPathOf(pr.value);
|
|
655
|
+
if (segs.length > 1 && vp) frontier.push(site.fnQid, `${vp}.${segs.slice(1).join('.')}`, true, 0, 'sink');
|
|
656
|
+
else frontier.reads(fn, pr.value, item);
|
|
657
|
+
}
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
// not a path or literal: no field refinement is possible, read the whole expression
|
|
661
|
+
frontier.reads(fn, a, item);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
666
|
+
// Normalization, running, comparing
|
|
667
|
+
// ---------------------------------------------------------------------------
|
|
668
|
+
|
|
669
|
+
export function normalizeFinding(f) {
|
|
670
|
+
const sources = new Set();
|
|
671
|
+
if (f.source && f.source.label) sources.add(`${f.source.label}@${f.source.line ?? ''}`);
|
|
672
|
+
for (const c of (f.chain || [])) if (c && c.label) sources.add(`${c.label}@${c.line ?? ''}`);
|
|
673
|
+
return {
|
|
674
|
+
file: f.file,
|
|
675
|
+
line: f.line,
|
|
676
|
+
sinkId: f.sink ? f.sink.label : null,
|
|
677
|
+
argIndex: f.argIndex ?? null,
|
|
678
|
+
cwe: f.cwe ?? null,
|
|
679
|
+
sources: [...sources].sort(),
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const findingKey = (n) => JSON.stringify([n.file, n.line, n.sinkId, n.argIndex, n.cwe, n.sources]);
|
|
684
|
+
|
|
685
|
+
function pickFindings(raw, sites, q) {
|
|
686
|
+
const out = [];
|
|
687
|
+
const want = new Set(sites.map((s) => `${s.file}|${s.line}|${s.sinkId}`));
|
|
688
|
+
for (const f of raw) {
|
|
689
|
+
if (!f.sink || !want.has(`${f.file}|${f.line}|${f.sink.label}`)) continue;
|
|
690
|
+
if (q.argIndex != null && f.argIndex !== q.argIndex) continue;
|
|
691
|
+
out.push(normalizeFinding(f));
|
|
692
|
+
}
|
|
693
|
+
const byKey = new Map();
|
|
694
|
+
for (const n of out) byKey.set(findingKey(n), n);
|
|
695
|
+
return [...byKey.values()].sort((a, b) => (findingKey(a) < findingKey(b) ? -1 : 1));
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function sourceBearing(fn) {
|
|
699
|
+
const info = fnInfo(fn);
|
|
700
|
+
let found = Array.isArray(fn.paramAnnotations) && fn.paramAnnotations.length > 0;
|
|
701
|
+
const rd = (e) => { if (found || !e || typeof e !== 'object') return; if (e.kind === 'function-value') return; if (matchSource(e, fn.file)) { found = true; return; } if (isExpr(e)) forEachChild(e, rd); };
|
|
702
|
+
for (const a of info.assigns) rd(a.source);
|
|
703
|
+
for (const c of info.calls) rd(c.expr);
|
|
704
|
+
for (const r of info.returns) rd(r);
|
|
705
|
+
return found;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const _lru = new Map();
|
|
709
|
+
const LRU_MAX = 64;
|
|
710
|
+
|
|
711
|
+
function resultCacheGet(key) { if (!_lru.has(key)) return null; const v = _lru.get(key); _lru.delete(key); _lru.set(key, v); return v; }
|
|
712
|
+
function resultCachePut(key, v) { _lru.set(key, v); while (_lru.size > LRU_MAX) _lru.delete(_lru.keys().next().value); }
|
|
713
|
+
export function clearDemandCache() { _lru.clear(); }
|
|
714
|
+
|
|
715
|
+
function engineOptsOf(opts) {
|
|
716
|
+
const o = { ...(opts.engineOpts || {}) };
|
|
717
|
+
if (opts.fnLimit) o.fnLimit = opts.fnLimit;
|
|
718
|
+
if (typeof opts.deadlineMs === 'number') o.deadlineMs = opts.deadlineMs;
|
|
719
|
+
return o;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function engineIncomplete(raw) {
|
|
723
|
+
const list = raw && raw.analysisIncomplete;
|
|
724
|
+
return Array.isArray(list) ? list.map((x) => x.reason).filter(Boolean) : [];
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function fieldAttributable(frontier) {
|
|
728
|
+
return frontier.terminals.size > 0;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function baseResult(q, key, digests) {
|
|
732
|
+
return { schemaVersion: DEMAND_SCHEMA_VERSION, queryKey: key, query: q, digests };
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Run one sink query in demand mode. Returns the result record described in
|
|
737
|
+
* the module header. Never throws for an unanswerable query: it returns an
|
|
738
|
+
* `unavailable` completion instead.
|
|
739
|
+
*/
|
|
740
|
+
export function runDemandSinkQuery(perFileIR, callGraph, query, opts = {}) {
|
|
741
|
+
const q = normalizeSinkQuery(query);
|
|
742
|
+
const digests = { graphDigest: opts.graphDigest ?? graphDigest(callGraph), configDigest: opts.configDigest ?? configDigest(opts) };
|
|
743
|
+
const key = sinkQueryKey(q, digests);
|
|
744
|
+
const useCache = opts.cache !== false && !opts.engine;
|
|
745
|
+
if (useCache) { const hit = resultCacheGet(key); if (hit) return { ...hit, cached: true }; }
|
|
746
|
+
const res = baseResult(q, key, digests);
|
|
747
|
+
res.mode = 'demand';
|
|
748
|
+
const total = callGraph && callGraph.functions ? callGraph.functions.size : 0;
|
|
749
|
+
|
|
750
|
+
if (!callGraph || !callGraph.functions) return unavailable(res, 'no-call-graph', total);
|
|
751
|
+
if (!DEMAND_CONTEXTS.includes(q.context)) return unavailable(res, 'unsupported-context', total);
|
|
752
|
+
const sites = findSinkSites(callGraph, q);
|
|
753
|
+
if (!sites.length) return unavailable(res, 'sink-site-not-found', total);
|
|
754
|
+
|
|
755
|
+
const frontier = new Frontier(callGraph, opts);
|
|
756
|
+
for (const s of sites) seedFrontier(frontier, callGraph.functions.get(s.fnQid), s, q);
|
|
757
|
+
frontier.run();
|
|
758
|
+
|
|
759
|
+
const onLimit = opts.onLimit || 'full';
|
|
760
|
+
const onUnsupported = opts.onUnsupported || 'full';
|
|
761
|
+
const reasons = [];
|
|
762
|
+
let fallback = null;
|
|
763
|
+
if (frontier.limits.length) {
|
|
764
|
+
if (onLimit === 'full') fallback = { reason: frontier.limits.map((l) => l.kind).sort()[0], expandedToFull: true };
|
|
765
|
+
else for (const l of frontier.limits) reasons.push(l.kind);
|
|
766
|
+
}
|
|
767
|
+
if (frontier.unsupported.length) {
|
|
768
|
+
const kinds = frontier.unsupported.map((u) => u.kind).sort();
|
|
769
|
+
if (onUnsupported === 'full') fallback = fallback || { reason: `unsupported:${kinds[0]}`, expandedToFull: true };
|
|
770
|
+
else for (const k of new Set(kinds)) reasons.push(`unsupported-construct:${k}`);
|
|
771
|
+
}
|
|
772
|
+
if (frontier.unresolved.size) reasons.push('unresolved-callee');
|
|
773
|
+
|
|
774
|
+
let regionIds = [...frontier.region.keys()].sort();
|
|
775
|
+
if (fallback) regionIds = [...callGraph.functions.keys()].sort();
|
|
776
|
+
const regionGraph = { ...callGraph, functions: new Map(regionIds.map((id) => [id, callGraph.functions.get(id)])) };
|
|
777
|
+
|
|
778
|
+
const run = opts.engine || runTaintEngine;
|
|
779
|
+
const raw = run(perFileIR, regionGraph, engineOptsOf(opts));
|
|
780
|
+
for (const r of engineIncomplete(raw)) reasons.push(`engine:${r}`);
|
|
781
|
+
|
|
782
|
+
let findings = pickFindings(raw, sites, q);
|
|
783
|
+
let siblingSuppressed = 0;
|
|
784
|
+
if (q.accessPath && !fieldAttributable(frontier)) { siblingSuppressed = findings.length; findings = []; }
|
|
785
|
+
|
|
786
|
+
const sbIn = regionIds.filter((id) => sourceBearing(callGraph.functions.get(id)));
|
|
787
|
+
res.sites = sites.map((s) => ({ file: s.file, line: s.line, fnQid: s.fnQid, nodeId: s.nodeId, sinkId: s.sinkId, kind: s.kind }));
|
|
788
|
+
res.findings = findings;
|
|
789
|
+
res.siblingFieldSuppressed = siblingSuppressed;
|
|
790
|
+
res.witness = findings.length ? 'forward-engine-corroborated' : 'none';
|
|
791
|
+
res.candidateFilter = { role: 'candidate-filter-only', sourceBearingFunctionsInRegion: sbIn.length, candidate: sbIn.length > 0 || frontier.terminals.has('param') || frontier.terminals.has('free') };
|
|
792
|
+
res.selectorCoverage = {
|
|
793
|
+
totalFunctions: total,
|
|
794
|
+
selectedFunctions: frontier.region.size,
|
|
795
|
+
selectedFraction: total ? frontier.region.size / total : 0,
|
|
796
|
+
byReason: countReasons(frontier.region),
|
|
797
|
+
followed: { ...frontier.followed },
|
|
798
|
+
maxDepthReached: frontier.maxDepthReached,
|
|
799
|
+
workSteps: frontier.steps,
|
|
800
|
+
limits: { maxCallerDepth: frontier.maxDepth, maxWork: frontier.maxWork },
|
|
801
|
+
limitHits: frontier.limits.map((l) => ({ ...l })),
|
|
802
|
+
unsupported: frontier.unsupported.map((u) => ({ ...u })),
|
|
803
|
+
unresolvedCallees: [...frontier.unresolved.values()],
|
|
804
|
+
terminals: [...frontier.terminals].sort(),
|
|
805
|
+
};
|
|
806
|
+
res.analyzedFunctions = regionIds.length;
|
|
807
|
+
res.fallback = fallback;
|
|
808
|
+
res.completion = completionOf(reasons, frontier, fallback);
|
|
809
|
+
res.negativeClaimAllowed = res.completion.negativeClaimAllowed;
|
|
810
|
+
res.verdict = findings.length ? 'flow-found' : (res.negativeClaimAllowed ? 'no-flow-in-modeled-scope' : 'unknown');
|
|
811
|
+
if (useCache) resultCachePut(key, res);
|
|
812
|
+
return res;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function countReasons(region) {
|
|
816
|
+
const out = {};
|
|
817
|
+
for (const set of region.values()) for (const r of set) out[r] = (out[r] || 0) + 1;
|
|
818
|
+
return out;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function completionOf(reasons, frontier, fallback) {
|
|
822
|
+
const unexplored = frontier.depthCut + frontier.workLeft;
|
|
823
|
+
const partial = reasons.length > 0;
|
|
824
|
+
return makeCompletion({
|
|
825
|
+
status: partial ? 'partial' : 'complete',
|
|
826
|
+
modelCoverage: partial ? 'partial' : 'complete',
|
|
827
|
+
reasons,
|
|
828
|
+
unexploredFrontierCount: partial ? Math.max(unexplored, 0) : 0,
|
|
829
|
+
limits: { maxCallerDepth: frontier.maxDepth, maxWork: frontier.maxWork, expandedToFull: !!fallback },
|
|
830
|
+
unit: 'demand-query',
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function unavailable(res, reason, total) {
|
|
835
|
+
res.sites = [];
|
|
836
|
+
res.findings = [];
|
|
837
|
+
res.siblingFieldSuppressed = 0;
|
|
838
|
+
res.witness = 'none';
|
|
839
|
+
res.candidateFilter = { role: 'candidate-filter-only', sourceBearingFunctionsInRegion: 0, candidate: false };
|
|
840
|
+
res.selectorCoverage = { totalFunctions: total, selectedFunctions: 0, selectedFraction: 0, byReason: {}, followed: {}, maxDepthReached: 0, workSteps: 0, limits: {}, limitHits: [], unsupported: [], unresolvedCallees: [], terminals: [] };
|
|
841
|
+
res.analyzedFunctions = 0;
|
|
842
|
+
res.fallback = null;
|
|
843
|
+
res.completion = makeCompletion({ status: 'unavailable', reasons: [reason], unit: 'demand-query' });
|
|
844
|
+
res.negativeClaimAllowed = false;
|
|
845
|
+
res.verdict = 'unknown';
|
|
846
|
+
return res;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** Reference answer: the same query over the FULL function set. */
|
|
850
|
+
export function runFullSinkQuery(perFileIR, callGraph, query, opts = {}) {
|
|
851
|
+
const q = normalizeSinkQuery(query);
|
|
852
|
+
const digests = { graphDigest: opts.graphDigest ?? graphDigest(callGraph), configDigest: opts.configDigest ?? configDigest(opts) };
|
|
853
|
+
const res = baseResult(q, sinkQueryKey(q, digests), digests);
|
|
854
|
+
res.mode = 'full';
|
|
855
|
+
const total = callGraph.functions.size;
|
|
856
|
+
const sites = findSinkSites(callGraph, q);
|
|
857
|
+
if (!sites.length) return unavailable(res, 'sink-site-not-found', total);
|
|
858
|
+
const run = opts.engine || runTaintEngine;
|
|
859
|
+
const raw = run(perFileIR, callGraph, engineOptsOf(opts));
|
|
860
|
+
let findings = pickFindings(raw, sites, q);
|
|
861
|
+
let suppressed = 0;
|
|
862
|
+
if (q.accessPath) {
|
|
863
|
+
const frontier = new Frontier(callGraph, opts);
|
|
864
|
+
for (const s of sites) seedFrontier(frontier, callGraph.functions.get(s.fnQid), s, q);
|
|
865
|
+
frontier.run();
|
|
866
|
+
if (!fieldAttributable(frontier)) { suppressed = findings.length; findings = []; }
|
|
867
|
+
}
|
|
868
|
+
res.sites = sites.map((s) => ({ file: s.file, line: s.line, fnQid: s.fnQid, nodeId: s.nodeId, sinkId: s.sinkId, kind: s.kind }));
|
|
869
|
+
res.findings = findings;
|
|
870
|
+
res.siblingFieldSuppressed = suppressed;
|
|
871
|
+
res.analyzedFunctions = total;
|
|
872
|
+
res.engineIncomplete = engineIncomplete(raw);
|
|
873
|
+
return res;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** Compare a demand result with the full reference for the same query. */
|
|
877
|
+
export function compareToFull(demand, full) {
|
|
878
|
+
const a = demand.findings.map(findingKey);
|
|
879
|
+
const b = full.findings.map(findingKey);
|
|
880
|
+
const missing = b.filter((k) => !a.includes(k));
|
|
881
|
+
const extra = a.filter((k) => !b.includes(k));
|
|
882
|
+
return { equal: missing.length === 0 && extra.length === 0, missing, extra };
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** Run a batch of queries (the shape the optional dataflow/index.js hook uses). */
|
|
886
|
+
export function runDemandQueries(perFileIR, callGraph, queries, opts = {}) {
|
|
887
|
+
return (queries || []).map((q) => runDemandSinkQuery(perFileIR, callGraph, q, opts));
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** Enumerate every sink site in the graph as a query (used by parity checks and the benchmark). */
|
|
891
|
+
export function enumerateSinkQueries(callGraph) {
|
|
892
|
+
return findSinkSites(callGraph, {}).map((s) => ({ file: s.file, line: s.line, nodeId: s.nodeId, fnQid: s.fnQid, sinkId: s.sinkId }));
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// ---------------------------------------------------------------------------
|
|
896
|
+
// R02.5: adapters over the existing reverse implementations
|
|
897
|
+
// ---------------------------------------------------------------------------
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Compare the demand region of a query result with the coarse tabulation slice
|
|
901
|
+
* for the same sink functions, using the shared completion metadata. Nothing is
|
|
902
|
+
* replaced: the report states which role each selector keeps.
|
|
903
|
+
*/
|
|
904
|
+
export function reconcileSelectors(callGraph, demandResult) {
|
|
905
|
+
const sinkFns = [...new Set((demandResult.sites || []).map((s) => s.fnQid))];
|
|
906
|
+
const tab = reachabilitySliceWithCompletion(sinkFns.map((fnQid) => ({ fnQid })), callGraph);
|
|
907
|
+
const demandIds = new Set();
|
|
908
|
+
const frontier = new Frontier(callGraph, {});
|
|
909
|
+
for (const s of findSinkSites(callGraph, demandResult.query)) seedFrontier(frontier, callGraph.functions.get(s.fnQid), s, demandResult.query);
|
|
910
|
+
frontier.run();
|
|
911
|
+
for (const id of frontier.region.keys()) demandIds.add(id);
|
|
912
|
+
const onlyDemand = [...demandIds].filter((x) => !tab.reachable.has(x)).sort();
|
|
913
|
+
const onlyTabulation = [...tab.reachable].filter((x) => !demandIds.has(x)).sort();
|
|
914
|
+
return {
|
|
915
|
+
roles: {
|
|
916
|
+
tabulation: 'coarse caller-only selector; kept, cannot see producer helpers or callback registrars',
|
|
917
|
+
backward: 'explanation trace of an emitted finding; kept, not a selector',
|
|
918
|
+
ifdsPrecise: 'depth-capped predecessor chain; kept, now reports a completion record',
|
|
919
|
+
demand: 'sink-keyed selector with forward corroboration',
|
|
920
|
+
},
|
|
921
|
+
tabulationCompletion: makeCompletion({ status: tab.completion.status, reasons: tab.completion.reasons, unexploredFrontierCount: tab.completion.unexploredFrontierCount, limits: tab.completion.limits, unit: 'tabulation-slice' }),
|
|
922
|
+
demandCompletion: demandResult.completion,
|
|
923
|
+
onlyDemand,
|
|
924
|
+
onlyTabulation,
|
|
925
|
+
tabulationMissedDemandFunctions: onlyDemand.length,
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
export const _internals = { Frontier, fnInfo, segOverlap, calleeName, sourceBearing };
|