@clear-capabilities/agentic-security-scanner 0.185.0 → 0.186.2
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 +34 -0
- package/bin/agentic-security.js +66 -18
- package/dist/1128.index.js +25 -21
- package/dist/{5817.index.js → 1602.index.js} +378 -29
- package/dist/2584.index.js +1 -1
- package/dist/4171.index.js +2 -2
- package/dist/{0b301d45b231f144a1b4.json → 4d06c8944ae121d7085c.json} +8 -8
- package/dist/5752.index.js +466 -0
- package/dist/7405.index.js +1 -1
- package/dist/8158.index.js +1713 -0
- package/dist/8798.index.js +1 -1
- package/dist/9382.index.js +2 -2
- package/dist/9560.index.js +1 -1
- package/dist/9579.index.js +1 -1
- package/dist/agentic-security.mjs +29 -29
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +8 -8
- package/src/dataflow/CLAUDE.md +6 -3
- package/src/dataflow/access-paths.js +12 -2
- package/src/dataflow/demand-query.js +279 -40
- package/src/dataflow/dep-fingerprint.js +1 -1
- package/src/dataflow/exploit-prover.js +6 -2
- package/src/dataflow/ir-cache.js +268 -0
- package/src/dataflow/privacy-taint.js +7 -0
- package/src/engine.js +62 -5
- package/src/ir/CLAUDE.md +4 -0
- package/src/ir/callgraph.js +36 -8
- package/src/ir/index.js +18 -4
- package/src/ir/parser-js.js +10 -0
- package/src/language/CLAUDE.md +13 -0
- package/src/language/nix-option-types.js +170 -0
- package/src/language/nix-script-taint.js +46 -3
- package/src/language/nix-secrets.js +20 -2
- package/src/lineage/CLAUDE.md +2 -1
- package/src/lineage/smt-orchestrator.js +47 -11
- package/src/lineage/smt-z3-backend.js +294 -0
- package/src/posture/CLAUDE.md +5 -1
- package/src/posture/business-logic.js +16 -7
- package/src/posture/dag-commands.js +442 -0
- package/src/posture/finding-defaults.js +19 -0
- package/src/posture/git-history.js +91 -1
- package/src/posture/proof-fabric/cli.js +1 -1
- package/src/posture/proof-fabric/container.js +2 -2
- package/src/posture/proof-fabric/generate-haskell.js +205 -0
- package/src/posture/proof-fabric/generate.js +6 -3
- package/src/posture/proof-fabric/reproducer.js +4 -2
- package/src/posture/proof-fabric/runner.js +4 -4
- package/src/sast/CLAUDE.md +2 -0
- package/src/sast/_call-args.js +70 -0
- package/src/sast/init-scope.js +87 -0
- package/src/sast/listen-bind-address.js +49 -0
- package/src/sast/redirect-target.js +106 -0
- package/src/sast/reflected-input.js +76 -0
- package/src/sast/send-file-root.js +27 -0
- package/src/sast/sensitive-data-logging.js +38 -1
- package/dist/119.index.js +0 -197
- package/dist/128.index.js +0 -129
- package/dist/1293.index.js +0 -5689
- package/dist/1336.index.js +0 -16331
- package/dist/1351.index.js +0 -133
- package/dist/1387.index.js +0 -271
- package/dist/1437.index.js +0 -23
- package/dist/1445.index.js +0 -110
- package/dist/1505.index.js +0 -151
- package/dist/1557.index.js +0 -293
- package/dist/1621.index.js +0 -311
- package/dist/1751.index.js +0 -160
- package/dist/1765.index.js +0 -727
- package/dist/1861.index.js +0 -690
- package/dist/1906.index.js +0 -433
- package/dist/208.index.js +0 -1631
- package/dist/2223.index.js +0 -159
- package/dist/2238.index.js +0 -295
- package/dist/2530.index.js +0 -152
- package/dist/2565.index.js +0 -116
- package/dist/2574.index.js +0 -127
- package/dist/2620.index.js +0 -345
- package/dist/2773.index.js +0 -98
- package/dist/2775.index.js +0 -32
- package/dist/2966.index.js +0 -171
- package/dist/2972.index.js +0 -87
- package/dist/2991.index.js +0 -131
- package/dist/3063.index.js +0 -833
- package/dist/3075.index.js +0 -227
- package/dist/3121.index.js +0 -297
- package/dist/3314.index.js +0 -263
- package/dist/3480.index.js +0 -1822
- package/dist/3481.index.js +0 -24
- package/dist/3506.index.js +0 -268
- package/dist/3533.index.js +0 -591
- package/dist/3653.index.js +0 -450
- package/dist/3740.index.js +0 -722
- package/dist/3822.index.js +0 -131
- package/dist/3868.index.js +0 -292
- package/dist/3918.index.js +0 -192
- package/dist/4090.index.js +0 -288
- package/dist/4113.index.js +0 -1286
- package/dist/4121.index.js +0 -1190
- package/dist/4142.index.js +0 -690
- package/dist/4168.index.js +0 -509
- package/dist/4281.index.js +0 -993
- package/dist/4336.index.js +0 -436
- package/dist/4387.index.js +0 -532
- package/dist/4413.index.js +0 -302
- package/dist/4476.index.js +0 -4510
- package/dist/4491.index.js +0 -133
- package/dist/4538.index.js +0 -185
- package/dist/4684.index.js +0 -319
- package/dist/4740.index.js +0 -165
- package/dist/4780.index.js +0 -209
- package/dist/4802.index.js +0 -225
- package/dist/4812.index.js +0 -379
- package/dist/4835.index.js +0 -126
- package/dist/494.index.js +0 -226
- package/dist/4991.index.js +0 -549
- package/dist/5199.index.js +0 -305
- package/dist/5216.index.js +0 -109
- package/dist/5439.index.js +0 -301
- package/dist/5443.index.js +0 -97
- package/dist/5496.index.js +0 -377
- package/dist/5738.index.js +0 -240
- package/dist/5801.index.js +0 -588
- package/dist/5858.index.js +0 -223
- package/dist/5933.index.js +0 -143
- package/dist/601.index.js +0 -23
- package/dist/6173.index.js +0 -273
- package/dist/6291.index.js +0 -1871
- package/dist/6312.index.js +0 -666
- package/dist/634.index.js +0 -16182
- package/dist/644.index.js +0 -87
- package/dist/6508.index.js +0 -283
- package/dist/6560.index.js +0 -645
- package/dist/6576.index.js +0 -422
- package/dist/6579.index.js +0 -271
- package/dist/6655.index.js +0 -3221
- package/dist/6745.index.js +0 -305
- package/dist/6818.index.js +0 -21642
- package/dist/6932.index.js +0 -1190
- package/dist/6972.index.js +0 -167
- package/dist/6995.index.js +0 -163
- package/dist/7249.index.js +0 -191
- package/dist/7372.index.js +0 -1197
- package/dist/7546.index.js +0 -541
- package/dist/76.index.js +0 -448
- package/dist/7879.index.js +0 -163
- package/dist/8025.index.js +0 -130
- package/dist/8054.index.js +0 -248
- package/dist/808.index.js +0 -1330
- package/dist/8097.index.js +0 -201
- package/dist/8126.index.js +0 -191
- package/dist/8178.index.js +0 -78
- package/dist/8382.index.js +0 -93
- package/dist/8408.index.js +0 -261
- package/dist/8540.index.js +0 -174
- package/dist/8635.index.js +0 -802
- package/dist/8688.index.js +0 -410
- package/dist/8699.index.js +0 -779
- package/dist/8891.index.js +0 -457
- package/dist/8916.index.js +0 -885
- package/dist/9146.index.js +0 -170
- package/dist/9168.index.js +0 -841
- package/dist/9292.index.js +0 -369
- package/dist/9422.index.js +0 -866
- package/dist/9440.index.js +0 -453
- package/dist/9479.index.js +0 -152
- package/dist/9481.index.js +0 -220
- package/dist/9539.index.js +0 -100
- package/dist/9547.index.js +0 -103
- package/dist/9692.index.js +0 -90
- package/dist/9800.index.js +0 -462
- package/dist/9899.index.js +0 -138
- package/dist/9981.index.js +0 -206
- package/dist/999.index.js +0 -117
- package/dist/9999.index.js +0 -1698
|
@@ -14,9 +14,12 @@
|
|
|
14
14
|
// 1. The frontier only SELECTS functions. A backward/forward intersection is a
|
|
15
15
|
// candidate filter and is reported as one; the only witness is a finding the
|
|
16
16
|
// forward engine produced over the selected region.
|
|
17
|
-
// 2. A construct the selector cannot follow (instance
|
|
18
|
-
//
|
|
19
|
-
//
|
|
17
|
+
// 2. A construct the selector cannot follow (an instance field of a class it
|
|
18
|
+
// cannot name, such as a constructor function or a non-JavaScript receiver;
|
|
19
|
+
// an exhausted depth or work budget; an unresolved higher-order shape) never
|
|
20
|
+
// silently drops a function. An instance field of a named JavaScript or
|
|
21
|
+
// TypeScript class IS followed: the class's methods and the receiver at
|
|
22
|
+
// their call sites are selected. Default policy expands to the full function set
|
|
20
23
|
// (`onLimit` / `onUnsupported` = 'full'); the 'partial' policy instead
|
|
21
24
|
// reports partial scope with negativeClaimAllowed false.
|
|
22
25
|
// 3. A limited reverse walk never authorizes a negative verdict. The verdict
|
|
@@ -40,6 +43,11 @@ const DEMAND_CONTEXTS = Object.freeze(['whole-program']);
|
|
|
40
43
|
const DEFAULT_MAX_CALLER_DEPTH = 64;
|
|
41
44
|
const DEFAULT_MAX_WORK = 50000;
|
|
42
45
|
const MAX_PATH_SEGMENTS = 6;
|
|
46
|
+
// Bounds on the class-scoped expansion; past them the construct is reported
|
|
47
|
+
// unsupported and the query takes the conservative full path.
|
|
48
|
+
const MAX_CLASS_MEMBERS = 2000;
|
|
49
|
+
const MAX_ENCLOSING_CANDIDATES = 32;
|
|
50
|
+
const MAX_CLOSURE_HOPS = 6;
|
|
43
51
|
|
|
44
52
|
/** Demand mode is opt-in: this env flag AND an explicit API/query list. */
|
|
45
53
|
export function demandEnabled(env = process.env) {
|
|
@@ -122,6 +130,15 @@ export function sinkQueryKey(query, digests = {}) {
|
|
|
122
130
|
// ---------------------------------------------------------------------------
|
|
123
131
|
|
|
124
132
|
const SELF_ROOTS = new Set(['this', '_this_', 'self', 'cls']);
|
|
133
|
+
const JS_FILE = /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i;
|
|
134
|
+
// In JavaScript and TypeScript the receiver is `this` (lowered to `_this_`);
|
|
135
|
+
// `self` and `cls` are ordinary names there (a local, a parameter, the global
|
|
136
|
+
// `self`, or an alias made by `const self = this`, which the assignment rule
|
|
137
|
+
// follows back to `_this_`). In the other languages they are the receiver.
|
|
138
|
+
function isSelfRoot(root, file) {
|
|
139
|
+
if (root === 'this' || root === '_this_') return true;
|
|
140
|
+
return SELF_ROOTS.has(root) && !JS_FILE.test(file || '');
|
|
141
|
+
}
|
|
125
142
|
const REQ_PARAM = /^(?:req|request|ctx)$/;
|
|
126
143
|
// Roots that are language builtins or ambient globals, so a call on them is
|
|
127
144
|
// modeled by the engine's own summaries rather than being an unresolved callee.
|
|
@@ -213,6 +230,83 @@ function fnInfo(fn) {
|
|
|
213
230
|
return info;
|
|
214
231
|
}
|
|
215
232
|
|
|
233
|
+
// Access paths a call can write through (its receiver and its arguments).
|
|
234
|
+
const _touchCache = new WeakMap();
|
|
235
|
+
function touchedPaths(call) {
|
|
236
|
+
let paths = _touchCache.get(call);
|
|
237
|
+
if (paths) return paths;
|
|
238
|
+
paths = [];
|
|
239
|
+
_touchCache.set(call, paths);
|
|
240
|
+
const collect = (e) => {
|
|
241
|
+
if (!e || typeof e !== 'object') return;
|
|
242
|
+
if (e.kind === 'function-value') return;
|
|
243
|
+
const ap = accessPathOf(e);
|
|
244
|
+
if (ap) { paths.push(ap); return; }
|
|
245
|
+
if (isExpr(e)) forEachChild(e, collect);
|
|
246
|
+
};
|
|
247
|
+
const callee = call.callee;
|
|
248
|
+
if (callee && typeof callee === 'object' && callee.kind === 'member') collect(callee.object);
|
|
249
|
+
else if (typeof callee === 'string' && callee.includes('.')) paths.push(callee.slice(0, callee.lastIndexOf('.')));
|
|
250
|
+
for (const a of (call.args || [])) collect(a);
|
|
251
|
+
return paths;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Per-function views by root segment. Two access paths can overlap only when
|
|
255
|
+
// their first segments agree or one of them is the `*` wildcard, so a lookup
|
|
256
|
+
// for a path rooted at `r` need only visit the assignments and calls rooted at
|
|
257
|
+
// `r` (plus wildcard roots and, for assignments, the opaque ones). The views
|
|
258
|
+
// keep the function's own order, so the walk visits them exactly as a full scan
|
|
259
|
+
// would and only skips what cannot overlap.
|
|
260
|
+
function mergeSorted(lists) {
|
|
261
|
+
const out = [];
|
|
262
|
+
for (const l of lists) for (const i of l) out.push(i);
|
|
263
|
+
return out.sort((a, b) => a - b);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function assignsFor(info, root) {
|
|
267
|
+
let b = info.assignBuckets;
|
|
268
|
+
if (!b) {
|
|
269
|
+
b = { byRoot: new Map(), wild: [], opaque: [], views: new Map() };
|
|
270
|
+
info.assigns.forEach((a, i) => {
|
|
271
|
+
if (a.opaque) { b.opaque.push(i); return; }
|
|
272
|
+
const r = rootOf(a.target);
|
|
273
|
+
if (r === '*') { b.wild.push(i); return; }
|
|
274
|
+
if (!b.byRoot.has(r)) b.byRoot.set(r, []);
|
|
275
|
+
b.byRoot.get(r).push(i);
|
|
276
|
+
});
|
|
277
|
+
info.assignBuckets = b;
|
|
278
|
+
}
|
|
279
|
+
if (root === '*') return info.assigns;
|
|
280
|
+
let v = b.views.get(root);
|
|
281
|
+
if (!v) {
|
|
282
|
+
v = mergeSorted([b.opaque, b.wild, b.byRoot.get(root) || []]).map((i) => info.assigns[i]);
|
|
283
|
+
b.views.set(root, v);
|
|
284
|
+
}
|
|
285
|
+
return v;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function callsFor(info, root) {
|
|
289
|
+
let b = info.callBuckets;
|
|
290
|
+
if (!b) {
|
|
291
|
+
b = { byRoot: new Map(), wild: [], any: [], views: new Map() };
|
|
292
|
+
info.calls.forEach((occ, i) => {
|
|
293
|
+
const paths = touchedPaths(occ.expr);
|
|
294
|
+
if (!paths.length) return;
|
|
295
|
+
b.any.push(i);
|
|
296
|
+
const roots = new Set(paths.map(rootOf));
|
|
297
|
+
if (roots.has('*')) { b.wild.push(i); return; }
|
|
298
|
+
for (const r of roots) { if (!b.byRoot.has(r)) b.byRoot.set(r, []); b.byRoot.get(r).push(i); }
|
|
299
|
+
});
|
|
300
|
+
info.callBuckets = b;
|
|
301
|
+
}
|
|
302
|
+
let v = b.views.get(root);
|
|
303
|
+
if (!v) {
|
|
304
|
+
v = (root === '*' ? b.any : mergeSorted([b.wild, b.byRoot.get(root) || []])).map((i) => info.calls[i]);
|
|
305
|
+
b.views.set(root, v);
|
|
306
|
+
}
|
|
307
|
+
return v;
|
|
308
|
+
}
|
|
309
|
+
|
|
216
310
|
const _indexCache = new WeakMap();
|
|
217
311
|
|
|
218
312
|
/**
|
|
@@ -227,15 +321,34 @@ export function buildIncomingIndex(callGraph) {
|
|
|
227
321
|
const incoming = new Map();
|
|
228
322
|
const byTail = new Map();
|
|
229
323
|
const modules = new Map(); const byFile = new Map();
|
|
324
|
+
const byScope = new Map(); // enclosing-scope segment of the qid -> member qids (class methods share their class name)
|
|
325
|
+
const namesInFile = new Map(); // file -> Map(function name -> [fn])
|
|
230
326
|
const add = (qid, site) => { if (!incoming.has(qid)) incoming.set(qid, []); incoming.get(qid).push(site); };
|
|
231
|
-
|
|
327
|
+
// Name resolution over a fixed graph is a pure function of (name, file), and
|
|
328
|
+
// most call sites repeat a handful of names per file (`push`, `has`, `get`),
|
|
329
|
+
// each of which would otherwise scan every file's name table on a miss.
|
|
330
|
+
const memo = new Map();
|
|
331
|
+
const rk = callGraph.resolveKnownCallee;
|
|
332
|
+
const resolveName = (name, file) => {
|
|
333
|
+
if (!name || !rk) return null;
|
|
334
|
+
let m = memo.get(file);
|
|
335
|
+
if (!m) { m = new Map(); memo.set(file, m); }
|
|
336
|
+
let v = m.get(name);
|
|
337
|
+
if (v === undefined) { v = rk(name, file) || null; m.set(name, v); }
|
|
338
|
+
return v;
|
|
339
|
+
};
|
|
232
340
|
for (const fn of callGraph.functions.values()) {
|
|
233
341
|
const tail = String(fn.name || '').split('.').pop();
|
|
234
342
|
if (tail) { if (!byTail.has(tail)) byTail.set(tail, []); byTail.get(tail).push(fn.qid); }
|
|
235
343
|
if (!byFile.has(fn.file)) byFile.set(fn.file, []); byFile.get(fn.file).push(fn);
|
|
236
344
|
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); }
|
|
345
|
+
const parts = qidParts(fn.qid);
|
|
346
|
+
if (parts) { if (!byScope.has(parts.scope)) byScope.set(parts.scope, []); byScope.get(parts.scope).push(fn.qid); }
|
|
347
|
+
let nf = namesInFile.get(fn.file);
|
|
348
|
+
if (!nf) { nf = new Map(); namesInFile.set(fn.file, nf); }
|
|
349
|
+
if (!nf.has(fn.name)) nf.set(fn.name, []);
|
|
350
|
+
nf.get(fn.name).push(fn);
|
|
237
351
|
}
|
|
238
|
-
const base = buildCallerQidIndex(callGraph); // canonical statement-level edges, retained for parity checks
|
|
239
352
|
for (const fn of callGraph.functions.values()) {
|
|
240
353
|
const info = fnInfo(fn);
|
|
241
354
|
for (const occ of info.calls) {
|
|
@@ -251,11 +364,25 @@ export function buildIncomingIndex(callGraph) {
|
|
|
251
364
|
}
|
|
252
365
|
}
|
|
253
366
|
}
|
|
254
|
-
const idx = { incoming, byTail, modules, byFile,
|
|
367
|
+
const idx = { incoming, byTail, modules, byFile, byScope, namesInFile, definers: new Map(), resolve: resolveName };
|
|
368
|
+
// The canonical statement-level edges, kept for parity checks. Built on first
|
|
369
|
+
// use: nothing on the query path reads them, and building them costs a second
|
|
370
|
+
// full pass of name resolution.
|
|
371
|
+
let base = null;
|
|
372
|
+
Object.defineProperty(idx, 'base', { enumerable: true, get() { if (!base) base = buildCallerQidIndex(callGraph); return base; } });
|
|
255
373
|
_indexCache.set(callGraph, idx);
|
|
256
374
|
return idx;
|
|
257
375
|
}
|
|
258
376
|
|
|
377
|
+
/** `file::scope::name@line` -> { scope, name }; null for a qid of another shape. */
|
|
378
|
+
function qidParts(qid) {
|
|
379
|
+
const j = qid.lastIndexOf('::');
|
|
380
|
+
if (j < 0) return null;
|
|
381
|
+
const i = qid.lastIndexOf('::', j - 1);
|
|
382
|
+
if (i < 0) return null;
|
|
383
|
+
return { scope: qid.slice(i + 2, j), name: qid.slice(j + 2) };
|
|
384
|
+
}
|
|
385
|
+
|
|
259
386
|
// ---------------------------------------------------------------------------
|
|
260
387
|
// Sink-site discovery (nested sinks in assignment and return expressions)
|
|
261
388
|
// ---------------------------------------------------------------------------
|
|
@@ -330,12 +457,14 @@ class Frontier {
|
|
|
330
457
|
this.unsupported = []; // {kind, qid, detail}
|
|
331
458
|
this.unresolved = new Map(); // name@qid -> record
|
|
332
459
|
this.terminals = new Set(); // 'source' | 'param' | 'call' | 'free' | 'callback'
|
|
333
|
-
this.followed = { callers: 0, producers: 0, callbacks: 0, mutators: 0 };
|
|
460
|
+
this.followed = { callers: 0, producers: 0, callbacks: 0, mutators: 0, siblings: 0 };
|
|
334
461
|
this.depthCut = 0;
|
|
335
462
|
this.workLeft = 0;
|
|
336
463
|
this.sinkCalls = new Set();
|
|
337
|
-
this.touchCache = new WeakMap();
|
|
338
464
|
this.expanded = new Set();
|
|
465
|
+
this.mateCache = new Map();
|
|
466
|
+
this.holdersDone = new Set();
|
|
467
|
+
this.classDone = new Set();
|
|
339
468
|
}
|
|
340
469
|
|
|
341
470
|
addRegion(qid, reason) {
|
|
@@ -412,7 +541,7 @@ class Frontier {
|
|
|
412
541
|
}
|
|
413
542
|
for (const a of (e.args || [])) this.reads(fn, a, item);
|
|
414
543
|
const name = calleeName(callee);
|
|
415
|
-
const g =
|
|
544
|
+
const g = this.idx.resolve(name, fn.file);
|
|
416
545
|
if (g) {
|
|
417
546
|
this.followed.producers += 1;
|
|
418
547
|
this.addRegion(g, 'producer');
|
|
@@ -478,10 +607,14 @@ class Frontier {
|
|
|
478
607
|
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
608
|
|
|
480
609
|
const root = rootOf(p);
|
|
481
|
-
|
|
610
|
+
const selfRooted = isSelfRoot(root, fn.file);
|
|
611
|
+
// An instance field can be written by any method of its class and by whoever
|
|
612
|
+
// holds the receiver; select those (class-scoped expansion), or report the
|
|
613
|
+
// construct unsupported when the class cannot be determined.
|
|
614
|
+
if (selfRooted && !this.expandClass(fn, p, item)) return;
|
|
482
615
|
this.checkSource(fn, p);
|
|
483
616
|
|
|
484
|
-
for (const a of info
|
|
617
|
+
for (const a of assignsFor(info, root)) {
|
|
485
618
|
if (a.opaque) { this.reads(fn, a.source, item); continue; }
|
|
486
619
|
if (!segOverlap(a.target, p)) continue;
|
|
487
620
|
// target covers the needed path (equal or a prefix): the value of the
|
|
@@ -493,15 +626,15 @@ class Frontier {
|
|
|
493
626
|
else this.reads(fn, a.source, item);
|
|
494
627
|
}
|
|
495
628
|
|
|
496
|
-
for (const occ of info
|
|
629
|
+
for (const occ of callsFor(info, root)) {
|
|
630
|
+
if (!this.callTouches(occ.expr, p)) continue;
|
|
497
631
|
// the queried sink call itself runs after its argument is formed, so it
|
|
498
632
|
// 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;
|
|
633
|
+
if (this.sinkCalls.size && this.sinkCalls.has(`${fn.qid}|${occ.line}|${calleeName(occ.expr.callee)}`)) continue;
|
|
501
634
|
this.followed.mutators += 1;
|
|
502
635
|
this.reads(fn, occ.expr, item);
|
|
503
636
|
const name = calleeName(occ.expr.callee);
|
|
504
|
-
const g =
|
|
637
|
+
const g = this.idx.resolve(name, fn.file);
|
|
505
638
|
if (g && g !== fn.qid) { this.addRegion(g, 'mutator'); this.push(g, '$params', false, item.depth, 'mutator'); }
|
|
506
639
|
}
|
|
507
640
|
|
|
@@ -509,11 +642,107 @@ class Frontier {
|
|
|
509
642
|
if (pi >= 0) {
|
|
510
643
|
this.terminals.add('param');
|
|
511
644
|
if (item.ep) this.expandCallers(fn, pi, item);
|
|
645
|
+
} else if (selfRooted) {
|
|
646
|
+
this.terminals.add('field');
|
|
512
647
|
} else if (!info.assignedRoots.has(root) && !info.hasOpaqueAssign && !info.params.includes(root)) {
|
|
513
648
|
this.freeVariable(fn, root, p, item);
|
|
514
649
|
}
|
|
515
650
|
}
|
|
516
651
|
|
|
652
|
+
/**
|
|
653
|
+
* The qids sharing the receiver that `fn`'s `this` names, or null when that
|
|
654
|
+
* cannot be decided from the qid structure. A method's qid scope is its class
|
|
655
|
+
* name, so a class is the set of functions with that scope. A closure's scope
|
|
656
|
+
* is the enclosing function's name, and its `this` may be that function's, so
|
|
657
|
+
* the enclosing function(s) are followed outward. Module-level functions and
|
|
658
|
+
* constructor functions (scope `top`) have no class to name.
|
|
659
|
+
*/
|
|
660
|
+
classMates(fn) {
|
|
661
|
+
if (this.mateCache.has(fn.qid)) return this.mateCache.get(fn.qid);
|
|
662
|
+
const out = new Set([fn.qid]);
|
|
663
|
+
const seen = new Set();
|
|
664
|
+
const visit = (f, depth) => {
|
|
665
|
+
if (seen.has(f.qid)) return true;
|
|
666
|
+
seen.add(f.qid);
|
|
667
|
+
const parts = qidParts(f.qid);
|
|
668
|
+
if (!parts || !parts.scope || parts.scope === 'top' || parts.scope === '<module>') return false;
|
|
669
|
+
const named = (this.idx.namesInFile.get(f.file) || new Map()).get(parts.scope);
|
|
670
|
+
if (named && named.length) {
|
|
671
|
+
if (depth >= MAX_CLOSURE_HOPS || named.length > MAX_ENCLOSING_CANDIDATES) return false;
|
|
672
|
+
for (const g of named) { out.add(g.qid); if (!visit(g, depth + 1)) return false; }
|
|
673
|
+
return true;
|
|
674
|
+
}
|
|
675
|
+
const mates = this.idx.byScope.get(parts.scope) || [];
|
|
676
|
+
if (mates.length > MAX_CLASS_MEMBERS) return false;
|
|
677
|
+
for (const q of mates) out.add(q);
|
|
678
|
+
return true;
|
|
679
|
+
};
|
|
680
|
+
const ok = JS_FILE.test(fn.file || '') && visit(fn, 0);
|
|
681
|
+
// A class is named by its scope; a closure's receiver set is its own union.
|
|
682
|
+
const direct = ok && seen.size === 1 ? qidParts(fn.qid).scope : null;
|
|
683
|
+
out.key = direct != null ? `class:${direct}` : `fn:${fn.qid}`;
|
|
684
|
+
const res = ok ? out : null;
|
|
685
|
+
this.mateCache.set(fn.qid, res);
|
|
686
|
+
return res;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Select what can supply the value of a `this`-rooted path read in `fn`: every
|
|
691
|
+
* method of the class (each may write the field; they share one receiver, as the
|
|
692
|
+
* engine's class-field passes assume), and the receiver expression at every call
|
|
693
|
+
* site of those methods (the caller may have set the field before the call).
|
|
694
|
+
* Parameters written into fields are followed by the ordinary caller expansion.
|
|
695
|
+
*/
|
|
696
|
+
expandClass(fn, p, item) {
|
|
697
|
+
const mates = this.classMates(fn);
|
|
698
|
+
if (!mates) { this.markUnsupported('instance-field', fn.qid, p); return false; }
|
|
699
|
+
// one expansion per (class, path): a sibling reaching here for the same path
|
|
700
|
+
// would only repeat what the function that selected it already did
|
|
701
|
+
const done = `${mates.key}|${p}`;
|
|
702
|
+
if (this.classDone.has(done)) return true;
|
|
703
|
+
this.classDone.add(done);
|
|
704
|
+
const dot = p.indexOf('.');
|
|
705
|
+
const suffix = dot < 0 ? '' : p.slice(dot + 1);
|
|
706
|
+
for (const q of mates) {
|
|
707
|
+
// A sibling's parameters reach the field through its call sites, which
|
|
708
|
+
// the receiver tracking below selects (the call that stores the value is
|
|
709
|
+
// a mutator of the receiver path), so no separate caller expansion.
|
|
710
|
+
if (q !== fn.qid) { this.addRegion(q, 'class-sibling'); this.push(q, p, false, item.depth, 'class-sibling'); }
|
|
711
|
+
this.receiverHolders(q, suffix, item);
|
|
712
|
+
}
|
|
713
|
+
this.followed.siblings += mates.size - 1;
|
|
714
|
+
return true;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* At every call site of `qid`, the receiver's `suffix` field is what the callee
|
|
719
|
+
* sees as `this.<suffix>`. Following that one path (not the whole receiver)
|
|
720
|
+
* keeps the walk to the values that can actually reach the field.
|
|
721
|
+
*/
|
|
722
|
+
receiverHolders(qid, suffix, item) {
|
|
723
|
+
const hk = `${qid}|${suffix}`;
|
|
724
|
+
if (this.holdersDone.has(hk)) return;
|
|
725
|
+
this.holdersDone.add(hk);
|
|
726
|
+
const sites = this.idx.incoming.get(qid) || [];
|
|
727
|
+
if (!sites.length) return;
|
|
728
|
+
if (item.depth + 1 > this.maxDepth) { this.depthCut += sites.length; this.limits.push({ kind: 'depth-limit', count: sites.length }); return; }
|
|
729
|
+
const withSuffix = (base) => (suffix ? `${base}.${suffix}` : base);
|
|
730
|
+
for (const s of sites) {
|
|
731
|
+
const caller = this.cg.functions.get(s.caller);
|
|
732
|
+
if (!caller) continue;
|
|
733
|
+
this.followed.callers += 1;
|
|
734
|
+
this.addRegion(s.caller, s.kind === 'call' ? 'caller' : 'callback-registrar');
|
|
735
|
+
const ni = { ...item, qid: s.caller, depth: item.depth + 1, ep: true, reason: 'caller' };
|
|
736
|
+
const callee = s.call.callee;
|
|
737
|
+
let recvPath = null;
|
|
738
|
+
if (callee && typeof callee === 'object' && callee.kind === 'member') {
|
|
739
|
+
recvPath = accessPathOf(callee.object);
|
|
740
|
+
if (!recvPath) this.reads(caller, callee.object, ni);
|
|
741
|
+
} else if (typeof callee === 'string' && callee.includes('.')) recvPath = callee.slice(0, callee.lastIndexOf('.'));
|
|
742
|
+
if (recvPath) this.push(s.caller, withSuffix(recvPath), true, ni.depth, 'caller');
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
517
746
|
assignSource(fn, a, suffix, item) {
|
|
518
747
|
const src = a.source;
|
|
519
748
|
if (!src) return;
|
|
@@ -541,22 +770,7 @@ class Frontier {
|
|
|
541
770
|
}
|
|
542
771
|
|
|
543
772
|
callTouches(call, p) {
|
|
544
|
-
|
|
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));
|
|
773
|
+
return touchedPaths(call).some((q) => segOverlap(q, p));
|
|
560
774
|
}
|
|
561
775
|
|
|
562
776
|
expandCallers(fn, paramIdx, item) {
|
|
@@ -607,12 +821,19 @@ class Frontier {
|
|
|
607
821
|
}
|
|
608
822
|
|
|
609
823
|
freeVariable(fn, root, p, item) {
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
824
|
+
// every function of the file that binds the name, memoized per (file, name)
|
|
825
|
+
const dk = `${fn.file}\0${root}`;
|
|
826
|
+
let all = this.idx.definers.get(dk);
|
|
827
|
+
if (!all) {
|
|
828
|
+
all = [];
|
|
829
|
+
for (const other of (this.idx.byFile.get(fn.file) || [])) {
|
|
830
|
+
if (other.file !== fn.file) continue;
|
|
831
|
+
const oi = fnInfo(other);
|
|
832
|
+
if (oi.assignedRoots.has(root) || oi.params.includes(root)) all.push(other);
|
|
833
|
+
}
|
|
834
|
+
this.idx.definers.set(dk, all);
|
|
615
835
|
}
|
|
836
|
+
const definers = all.filter((d) => d.qid !== fn.qid);
|
|
616
837
|
// import bindings (`const x = require(...)`) carry no data of their own
|
|
617
838
|
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
839
|
if (!definers.length) return; // ambient global; the engine models it by name only
|
|
@@ -695,7 +916,15 @@ function pickFindings(raw, sites, q) {
|
|
|
695
916
|
return [...byKey.values()].sort((a, b) => (findingKey(a) < findingKey(b) ? -1 : 1));
|
|
696
917
|
}
|
|
697
918
|
|
|
919
|
+
const _sbCache = new WeakMap();
|
|
698
920
|
function sourceBearing(fn) {
|
|
921
|
+
if (_sbCache.has(fn)) return _sbCache.get(fn);
|
|
922
|
+
const v = sourceBearingUncached(fn);
|
|
923
|
+
_sbCache.set(fn, v);
|
|
924
|
+
return v;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function sourceBearingUncached(fn) {
|
|
699
928
|
const info = fnInfo(fn);
|
|
700
929
|
let found = Array.isArray(fn.paramAnnotations) && fn.paramAnnotations.length > 0;
|
|
701
930
|
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); };
|
|
@@ -773,10 +1002,19 @@ export function runDemandSinkQuery(perFileIR, callGraph, query, opts = {}) {
|
|
|
773
1002
|
|
|
774
1003
|
let regionIds = [...frontier.region.keys()].sort();
|
|
775
1004
|
if (fallback) regionIds = [...callGraph.functions.keys()].sort();
|
|
776
|
-
|
|
1005
|
+
// A fallback analyzes every function: hand the engine the graph itself (the
|
|
1006
|
+
// same input, in the same order, as the full-analysis reference), not a copy.
|
|
1007
|
+
const regionGraph = fallback ? callGraph : { ...callGraph, functions: new Map(regionIds.map((id) => [id, callGraph.functions.get(id)])) };
|
|
777
1008
|
|
|
778
1009
|
const run = opts.engine || runTaintEngine;
|
|
779
|
-
|
|
1010
|
+
// Within one batch every fallback asks the engine the same question (the whole
|
|
1011
|
+
// graph, same options), so the first answer is shared by the rest. The memo
|
|
1012
|
+
// belongs to one runDemandQueries call and is dropped with it.
|
|
1013
|
+
let raw;
|
|
1014
|
+
if (fallback && opts._fullMemo) {
|
|
1015
|
+
if (!opts._fullMemo.has('raw')) opts._fullMemo.set('raw', run(perFileIR, regionGraph, engineOptsOf(opts)));
|
|
1016
|
+
raw = opts._fullMemo.get('raw');
|
|
1017
|
+
} else raw = run(perFileIR, regionGraph, engineOptsOf(opts));
|
|
780
1018
|
for (const r of engineIncomplete(raw)) reasons.push(`engine:${r}`);
|
|
781
1019
|
|
|
782
1020
|
let findings = pickFindings(raw, sites, q);
|
|
@@ -788,7 +1026,7 @@ export function runDemandSinkQuery(perFileIR, callGraph, query, opts = {}) {
|
|
|
788
1026
|
res.findings = findings;
|
|
789
1027
|
res.siblingFieldSuppressed = siblingSuppressed;
|
|
790
1028
|
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') };
|
|
1029
|
+
res.candidateFilter = { role: 'candidate-filter-only', sourceBearingFunctionsInRegion: sbIn.length, candidate: sbIn.length > 0 || frontier.terminals.has('param') || frontier.terminals.has('free') || frontier.terminals.has('field') };
|
|
792
1030
|
res.selectorCoverage = {
|
|
793
1031
|
totalFunctions: total,
|
|
794
1032
|
selectedFunctions: frontier.region.size,
|
|
@@ -884,7 +1122,8 @@ export function compareToFull(demand, full) {
|
|
|
884
1122
|
|
|
885
1123
|
/** Run a batch of queries (the shape the optional dataflow/index.js hook uses). */
|
|
886
1124
|
export function runDemandQueries(perFileIR, callGraph, queries, opts = {}) {
|
|
887
|
-
|
|
1125
|
+
const batchOpts = opts.engine ? opts : { ...opts, _fullMemo: new Map() };
|
|
1126
|
+
return (queries || []).map((q) => runDemandSinkQuery(perFileIR, callGraph, q, batchOpts));
|
|
888
1127
|
}
|
|
889
1128
|
|
|
890
1129
|
/** Enumerate every sink site in the graph as a query (used by parity checks and the benchmark). */
|
|
@@ -526,7 +526,7 @@ function analyzerRoots() {
|
|
|
526
526
|
|
|
527
527
|
let _analyzerMemo = null;
|
|
528
528
|
/** Digest of the analyzer and parser sources (or the bundle when running from one). */
|
|
529
|
-
function analyzerDigest() {
|
|
529
|
+
export function analyzerDigest() {
|
|
530
530
|
if (_analyzerMemo) return _analyzerMemo;
|
|
531
531
|
const { here, src, isSource } = analyzerRoots();
|
|
532
532
|
const h = crypto.createHash('sha256');
|
|
@@ -22,8 +22,13 @@
|
|
|
22
22
|
//
|
|
23
23
|
// We do NOT attempt to prove ARBITRARY satisfiability — the v2 use case.
|
|
24
24
|
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
25
26
|
import { provablyMatches, safeSanitizerOutputRegex } from './string-domain.js';
|
|
26
27
|
|
|
28
|
+
// Optional package, loaded at first use through a runtime require so a bundler keeps it external and a
|
|
29
|
+
// build without it fails here (caught below), never at start-up.
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
|
|
27
32
|
// CWE → canonical exploit input. Conservative payloads safe to display.
|
|
28
33
|
const EXPLOIT_INPUTS = {
|
|
29
34
|
'CWE-89': `1' OR '1'='1`, // SQL injection
|
|
@@ -93,8 +98,7 @@ async function _maybeLoadZ3() {
|
|
|
93
98
|
if (_z3Loaded) return _z3;
|
|
94
99
|
_z3Loaded = true;
|
|
95
100
|
try {
|
|
96
|
-
|
|
97
|
-
_z3 = mod || null;
|
|
101
|
+
_z3 = require('z3-solver') || null;
|
|
98
102
|
} catch { _z3 = null; }
|
|
99
103
|
return _z3;
|
|
100
104
|
}
|