@clear-capabilities/agentic-security-scanner 0.184.0 → 0.186.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/bin/agentic-security.js +111 -1
- package/dist/1128.index.js +912 -0
- package/dist/1665.index.js +828 -0
- package/dist/2584.index.js +1 -1
- package/dist/4171.index.js +2 -2
- 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/9579.index.js +1 -1
- package/dist/agentic-security.mjs +29 -29
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/{dad58d1113a12c23a142.json → ca62ee93079be4c24eee.json} +29 -12
- package/dist/frontend/src/shell.js +12 -2
- package/package.json +29 -12
- package/src/dataflow/CLAUDE.md +46 -1
- package/src/dataflow/access-paths.js +12 -2
- package/src/dataflow/backward.js +42 -7
- package/src/dataflow/cross-service-taint.js +83 -1
- package/src/dataflow/demand-query.js +1168 -0
- package/src/dataflow/dep-fingerprint.js +718 -0
- package/src/dataflow/engine.js +48 -6
- package/src/dataflow/exploit-prover.js +6 -2
- 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/ir-cache.js +268 -0
- package/src/dataflow/privacy-taint.js +7 -0
- package/src/dataflow/summaries.js +72 -6
- package/src/dataflow/tabulation.js +80 -24
- package/src/engine.js +113 -8
- 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/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 +72 -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/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 +174 -0
- package/src/lineage/smt-z3-backend.js +294 -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 +18 -0
- package/src/posture/CLAUDE.md +17 -0
- package/src/posture/business-logic.js +16 -7
- package/src/posture/dag-commands.js +442 -0
- package/src/posture/evidence-bundle.js +1 -1
- package/src/posture/finding-defaults.js +19 -0
- package/src/posture/git-history.js +91 -1
- package/src/posture/witness-bundle.js +573 -0
- package/src/posture/witness-runtime.js +276 -0
- package/src/report/index.js +2 -0
- 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 -16182
- 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/5817.index.js +0 -714
- 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
|
@@ -0,0 +1,1168 @@
|
|
|
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 (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
|
|
23
|
+
// (`onLimit` / `onUnsupported` = 'full'); the 'partial' policy instead
|
|
24
|
+
// reports partial scope with negativeClaimAllowed false.
|
|
25
|
+
// 3. A limited reverse walk never authorizes a negative verdict. The verdict
|
|
26
|
+
// vocabulary has no "safe": `flow-found`, `no-flow-in-modeled-scope`
|
|
27
|
+
// (only when the completion envelope allows a negative claim) and
|
|
28
|
+
// `unknown`.
|
|
29
|
+
//
|
|
30
|
+
// Opt-in: AGENTIC_SECURITY_DEMAND=1 (see demandEnabled) plus the API below. The
|
|
31
|
+
// default scan never calls this module.
|
|
32
|
+
|
|
33
|
+
import * as crypto from 'node:crypto';
|
|
34
|
+
import { accessPathOf } from './access-paths.js';
|
|
35
|
+
import { matchSource, matchSinkOrSanitizer, matchMemberWriteSink, _catalogSize } from './catalog.js';
|
|
36
|
+
import { lookupBuiltinSummary } from './builtin-summaries.js';
|
|
37
|
+
import { runTaintEngine } from './engine.js';
|
|
38
|
+
import { buildCallerQidIndex, reachabilitySliceWithCompletion } from './tabulation.js';
|
|
39
|
+
import { makeCompletion } from '../lineage/completion.js';
|
|
40
|
+
|
|
41
|
+
const DEMAND_SCHEMA_VERSION = '1.0.0';
|
|
42
|
+
const DEMAND_CONTEXTS = Object.freeze(['whole-program']);
|
|
43
|
+
const DEFAULT_MAX_CALLER_DEPTH = 64;
|
|
44
|
+
const DEFAULT_MAX_WORK = 50000;
|
|
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;
|
|
51
|
+
|
|
52
|
+
/** Demand mode is opt-in: this env flag AND an explicit API/query list. */
|
|
53
|
+
export function demandEnabled(env = process.env) {
|
|
54
|
+
return env.AGENTIC_SECURITY_DEMAND === '1';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// R02.1 query contract
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Normalize a sink query. Keys: exact sink site (file + line, optionally
|
|
63
|
+
* nodeId / callee / sinkId to narrow), argument index, field/access path within
|
|
64
|
+
* the sink argument, analysis context, rule family (CWE id or family name), and
|
|
65
|
+
* the graph/configuration digests the answer is valid for.
|
|
66
|
+
*/
|
|
67
|
+
export function normalizeSinkQuery(q = {}) {
|
|
68
|
+
const num = (v) => (Number.isInteger(v) && v >= 0 ? v : null);
|
|
69
|
+
const str = (v) => (typeof v === 'string' && v.length ? v : null);
|
|
70
|
+
return {
|
|
71
|
+
file: str(q.file),
|
|
72
|
+
line: num(q.line),
|
|
73
|
+
nodeId: str(q.nodeId),
|
|
74
|
+
fnQid: str(q.fnQid),
|
|
75
|
+
callee: str(q.callee),
|
|
76
|
+
sinkId: str(q.sinkId),
|
|
77
|
+
argIndex: num(q.argIndex),
|
|
78
|
+
accessPath: str(q.accessPath),
|
|
79
|
+
context: str(q.context) || 'whole-program',
|
|
80
|
+
ruleFamily: str(q.ruleFamily),
|
|
81
|
+
graphDigest: str(q.graphDigest),
|
|
82
|
+
configDigest: str(q.configDigest),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const _digestCache = new WeakMap();
|
|
87
|
+
|
|
88
|
+
/** Stable digest of the call graph content the query was answered over. */
|
|
89
|
+
export function graphDigest(callGraph) {
|
|
90
|
+
if (!callGraph || !callGraph.functions) return null;
|
|
91
|
+
if (_digestCache.has(callGraph)) return _digestCache.get(callGraph);
|
|
92
|
+
const h = crypto.createHash('sha256');
|
|
93
|
+
for (const qid of [...callGraph.functions.keys()].sort()) {
|
|
94
|
+
const fn = callGraph.functions.get(qid);
|
|
95
|
+
h.update(qid); h.update('\0');
|
|
96
|
+
h.update(JSON.stringify(fn.params || [])); h.update('\0');
|
|
97
|
+
h.update(JSON.stringify(fn.cfg ? fn.cfg.nodes : null)); h.update('\n');
|
|
98
|
+
}
|
|
99
|
+
const d = h.digest('hex').slice(0, 32);
|
|
100
|
+
_digestCache.set(callGraph, d);
|
|
101
|
+
return d;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Digest of everything besides the graph that can change an answer. */
|
|
105
|
+
export function configDigest(opts = {}) {
|
|
106
|
+
const h = crypto.createHash('sha256');
|
|
107
|
+
h.update(JSON.stringify({
|
|
108
|
+
catalog: _catalogSize(),
|
|
109
|
+
kcfa: process.env.AGENTIC_SECURITY_KCFA_MAX_CONTEXTS || null,
|
|
110
|
+
callstring: process.env.AGENTIC_SECURITY_KCFA_CALLSTRING || null,
|
|
111
|
+
fnLimit: opts.fnLimit || null,
|
|
112
|
+
maxCallerDepth: opts.maxCallerDepth || DEFAULT_MAX_CALLER_DEPTH,
|
|
113
|
+
maxWork: opts.maxWork || DEFAULT_MAX_WORK,
|
|
114
|
+
onLimit: opts.onLimit || 'full',
|
|
115
|
+
onUnsupported: opts.onUnsupported || 'full',
|
|
116
|
+
}));
|
|
117
|
+
return h.digest('hex').slice(0, 16);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Stable key of a normalized query bound to its digests. */
|
|
121
|
+
export function sinkQueryKey(query, digests = {}) {
|
|
122
|
+
const q = normalizeSinkQuery(query);
|
|
123
|
+
q.graphDigest = digests.graphDigest ?? q.graphDigest;
|
|
124
|
+
q.configDigest = digests.configDigest ?? q.configDigest;
|
|
125
|
+
return crypto.createHash('sha256').update(JSON.stringify(q)).digest('hex').slice(0, 24);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Expression helpers
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
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
|
+
}
|
|
142
|
+
const REQ_PARAM = /^(?:req|request|ctx)$/;
|
|
143
|
+
// Roots that are language builtins or ambient globals, so a call on them is
|
|
144
|
+
// modeled by the engine's own summaries rather than being an unresolved callee.
|
|
145
|
+
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']);
|
|
146
|
+
|
|
147
|
+
const HIGHER_ORDER_METHODS = new Set(['map', 'forEach', 'filter', 'flatMap', 'find', 'findIndex', 'some', 'every', 'reduce', 'reduceRight', 'sort', 'then', 'catch', 'finally', 'all', 'allSettled', 'race', 'any']);
|
|
148
|
+
|
|
149
|
+
function isExpr(v) { return v && typeof v === 'object' && typeof v.kind === 'string'; }
|
|
150
|
+
|
|
151
|
+
function visitVal(v, f) {
|
|
152
|
+
if (!v || typeof v !== 'object') return;
|
|
153
|
+
if (isExpr(v)) { f(v); return; }
|
|
154
|
+
if (Array.isArray(v)) { for (const x of v) visitVal(x, f); return; }
|
|
155
|
+
for (const x of Object.values(v)) visitVal(x, f);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function forEachChild(e, f) {
|
|
159
|
+
for (const k of Object.keys(e)) {
|
|
160
|
+
if (k === 'kind') continue;
|
|
161
|
+
visitVal(e[k], f);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const rootOf = (p) => { const i = p.indexOf('.'); return i < 0 ? p : p.slice(0, i); };
|
|
166
|
+
|
|
167
|
+
/** Segment-wise overlap; a `*` segment (computed key) matches any segment. */
|
|
168
|
+
const _segs = new Map();
|
|
169
|
+
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; }
|
|
170
|
+
function segOverlap(a, b) {
|
|
171
|
+
if (a === b) return true;
|
|
172
|
+
const x = segsOf(a); const y = segsOf(b);
|
|
173
|
+
const n = Math.min(x.length, y.length);
|
|
174
|
+
for (let i = 0; i < n; i++) if (x[i] !== y[i] && x[i] !== '*' && y[i] !== '*') return false;
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function exprOfPath(path) {
|
|
179
|
+
const segs = path.split('.');
|
|
180
|
+
let e = { kind: 'ident', name: segs[0] };
|
|
181
|
+
for (let i = 1; i < segs.length; i++) e = { kind: 'member', object: e, prop: segs[i] };
|
|
182
|
+
return e;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function calleeName(callee) {
|
|
186
|
+
if (typeof callee === 'string') return callee;
|
|
187
|
+
if (!callee) return null;
|
|
188
|
+
if (callee.kind === 'ident') return callee.name;
|
|
189
|
+
if (callee.kind === 'member') return accessPathOf(callee);
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Function facts and the extended incoming-call index (R02.2)
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
const _infoCache = new WeakMap();
|
|
198
|
+
|
|
199
|
+
function fnInfo(fn) {
|
|
200
|
+
if (_infoCache.has(fn)) return _infoCache.get(fn);
|
|
201
|
+
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 };
|
|
202
|
+
const walk = (e, nid, line) => {
|
|
203
|
+
if (!e || typeof e !== 'object') return;
|
|
204
|
+
if (e.kind === 'function-value') { if (e.qid) info.fnValues.add(e.qid); return; }
|
|
205
|
+
if (e.kind === 'call') info.calls.push({ expr: e, nid, line: e.line || line });
|
|
206
|
+
if (isExpr(e)) forEachChild(e, (c) => walk(c, nid, line));
|
|
207
|
+
};
|
|
208
|
+
const nodes = (fn.cfg && fn.cfg.nodes) || {};
|
|
209
|
+
for (const nid of Object.keys(nodes)) {
|
|
210
|
+
const n = nodes[nid];
|
|
211
|
+
if (!n) continue;
|
|
212
|
+
if (n.kind === 'assign') {
|
|
213
|
+
if (typeof n.target === 'string') { info.assigns.push({ nid, target: n.target, source: n.source, line: n.line }); info.assignedRoots.add(rootOf(n.target)); }
|
|
214
|
+
else { info.assigns.push({ nid, target: null, source: n.source, line: n.line, opaque: true }); info.hasOpaqueAssign = true; }
|
|
215
|
+
walk(n.source, nid, n.line);
|
|
216
|
+
} else if (n.kind === 'call') {
|
|
217
|
+
info.calls.push({ expr: n, nid, line: n.line, top: true });
|
|
218
|
+
for (const a of (n.args || [])) walk(a, nid, n.line);
|
|
219
|
+
if (n.callee && typeof n.callee === 'object') walk(n.callee, nid, n.line);
|
|
220
|
+
} else if (n.kind === 'return') {
|
|
221
|
+
if (n.value) { info.returns.push(n.value); walk(n.value, nid, n.line); }
|
|
222
|
+
} else {
|
|
223
|
+
for (const k of Object.keys(n)) {
|
|
224
|
+
if (['id', 'kind', 'succ', 'pred', 'line'].includes(k)) continue;
|
|
225
|
+
visitVal(n[k], (c) => walk(c, nid, n.line));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
_infoCache.set(fn, info);
|
|
230
|
+
return info;
|
|
231
|
+
}
|
|
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
|
+
|
|
310
|
+
const _indexCache = new WeakMap();
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Incoming-call index over the WHOLE call graph: callee qid -> sites, each
|
|
314
|
+
* {caller, nid, call, kind: 'call'|'callback'|'callback-ref'}. Starts from the
|
|
315
|
+
* canonical buildCallerQidIndex (statement-level calls) and adds embedded call
|
|
316
|
+
* sites that `fn.calls` omits, plus callback registrations. Also indexes
|
|
317
|
+
* functions by bare name for method-name candidate expansion.
|
|
318
|
+
*/
|
|
319
|
+
export function buildIncomingIndex(callGraph) {
|
|
320
|
+
if (_indexCache.has(callGraph)) return _indexCache.get(callGraph);
|
|
321
|
+
const incoming = new Map();
|
|
322
|
+
const byTail = new Map();
|
|
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])
|
|
326
|
+
const add = (qid, site) => { if (!incoming.has(qid)) incoming.set(qid, []); incoming.get(qid).push(site); };
|
|
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
|
+
};
|
|
340
|
+
for (const fn of callGraph.functions.values()) {
|
|
341
|
+
const tail = String(fn.name || '').split('.').pop();
|
|
342
|
+
if (tail) { if (!byTail.has(tail)) byTail.set(tail, []); byTail.get(tail).push(fn.qid); }
|
|
343
|
+
if (!byFile.has(fn.file)) byFile.set(fn.file, []); byFile.get(fn.file).push(fn);
|
|
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);
|
|
351
|
+
}
|
|
352
|
+
for (const fn of callGraph.functions.values()) {
|
|
353
|
+
const info = fnInfo(fn);
|
|
354
|
+
for (const occ of info.calls) {
|
|
355
|
+
const name = calleeName(occ.expr.callee);
|
|
356
|
+
const g = resolveName(name, fn.file);
|
|
357
|
+
if (g) add(g, { caller: fn.qid, nid: occ.nid, call: occ.expr, kind: 'call' });
|
|
358
|
+
for (const a of (occ.expr.args || [])) {
|
|
359
|
+
if (a && a.kind === 'function-value' && a.qid) add(a.qid, { caller: fn.qid, nid: occ.nid, call: occ.expr, kind: 'callback' });
|
|
360
|
+
else if (a && a.kind === 'ident') {
|
|
361
|
+
const r = resolveName(a.name, fn.file);
|
|
362
|
+
if (r && r !== fn.qid) add(r, { caller: fn.qid, nid: occ.nid, call: occ.expr, kind: 'callback-ref' });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
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; } });
|
|
373
|
+
_indexCache.set(callGraph, idx);
|
|
374
|
+
return idx;
|
|
375
|
+
}
|
|
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
|
+
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
// Sink-site discovery (nested sinks in assignment and return expressions)
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
function matchesFamily(entry, family) {
|
|
391
|
+
if (!family) return true;
|
|
392
|
+
const v = entry.vuln || {};
|
|
393
|
+
return v.cwe === family || entry.id === family || (v.name && v.name === family) || (entry.family && entry.family === family);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Enumerate sink sites in the graph that satisfy the query's site keys. */
|
|
397
|
+
export function findSinkSites(callGraph, query) {
|
|
398
|
+
const q = normalizeSinkQuery(query);
|
|
399
|
+
const sites = [];
|
|
400
|
+
for (const fn of callGraph.functions.values()) {
|
|
401
|
+
if (q.file && fn.file !== q.file) continue;
|
|
402
|
+
if (q.fnQid && fn.qid !== q.fnQid) continue;
|
|
403
|
+
const info = fnInfo(fn);
|
|
404
|
+
for (const occ of info.calls) {
|
|
405
|
+
if (q.line != null && occ.line !== q.line) continue;
|
|
406
|
+
if (q.nodeId && occ.nid !== q.nodeId) continue;
|
|
407
|
+
const hits = matchSinkOrSanitizer(occ.expr.callee, fn.file) || [];
|
|
408
|
+
for (const e of hits) {
|
|
409
|
+
if (e.kind !== 'sink' || !matchesFamily(e, q.ruleFamily)) continue;
|
|
410
|
+
if (q.sinkId && e.id !== q.sinkId) continue;
|
|
411
|
+
if (q.callee && calleeName(occ.expr.callee) !== q.callee) continue;
|
|
412
|
+
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' });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
for (const a of info.assigns) {
|
|
416
|
+
if (!a.target || (q.line != null && a.line !== q.line)) continue;
|
|
417
|
+
if (q.nodeId && a.nid !== q.nodeId) continue;
|
|
418
|
+
const hits = matchMemberWriteSink(a.target, fn.file) || [];
|
|
419
|
+
for (const e of hits) {
|
|
420
|
+
if (!matchesFamily(e, q.ruleFamily) || (q.sinkId && e.id !== q.sinkId)) continue;
|
|
421
|
+
if (q.callee && a.target !== q.callee) continue;
|
|
422
|
+
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' });
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
// The IR can carry one call both as a statement node and as the value of a
|
|
427
|
+
// return node; both name the same source site.
|
|
428
|
+
const seenSite = new Set();
|
|
429
|
+
for (let i = sites.length - 1; i >= 0; i--) {
|
|
430
|
+
const s = sites[i];
|
|
431
|
+
const k = `${s.fnQid}|${s.line}|${s.sinkId}|${s.callee}`;
|
|
432
|
+
if (seenSite.has(k)) sites.splice(i, 1); else seenSite.add(k);
|
|
433
|
+
}
|
|
434
|
+
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));
|
|
435
|
+
return sites;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
// R02.2 / R02.4: the backward demand frontier
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
class Frontier {
|
|
443
|
+
constructor(callGraph, opts) {
|
|
444
|
+
this.cg = callGraph;
|
|
445
|
+
this.idx = buildIncomingIndex(callGraph);
|
|
446
|
+
this.maxDepth = opts.maxCallerDepth ?? DEFAULT_MAX_CALLER_DEPTH;
|
|
447
|
+
this.maxWork = opts.maxWork ?? DEFAULT_MAX_WORK;
|
|
448
|
+
this.hasCha = !!(opts.engineOpts && opts.engineOpts._cha);
|
|
449
|
+
this.region = new Map(); // qid -> Set(reason)
|
|
450
|
+
this.work = [];
|
|
451
|
+
this.seen = new Set();
|
|
452
|
+
this.cbDone = new Set();
|
|
453
|
+
this.reqDone = new Set();
|
|
454
|
+
this.steps = 0;
|
|
455
|
+
this.maxDepthReached = 0;
|
|
456
|
+
this.limits = []; // {kind, count}
|
|
457
|
+
this.unsupported = []; // {kind, qid, detail}
|
|
458
|
+
this.unresolved = new Map(); // name@qid -> record
|
|
459
|
+
this.terminals = new Set(); // 'source' | 'param' | 'call' | 'free' | 'callback'
|
|
460
|
+
this.followed = { callers: 0, producers: 0, callbacks: 0, mutators: 0, siblings: 0 };
|
|
461
|
+
this.depthCut = 0;
|
|
462
|
+
this.workLeft = 0;
|
|
463
|
+
this.sinkCalls = new Set();
|
|
464
|
+
this.expanded = new Set();
|
|
465
|
+
this.mateCache = new Map();
|
|
466
|
+
this.holdersDone = new Set();
|
|
467
|
+
this.classDone = new Set();
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
addRegion(qid, reason) {
|
|
471
|
+
if (!this.cg.functions.has(qid)) return;
|
|
472
|
+
if (!this.region.has(qid)) this.region.set(qid, new Set());
|
|
473
|
+
this.region.get(qid).add(reason);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
push(qid, path, ep, depth, reason) {
|
|
477
|
+
// recursive field growth (`x = x.next` in a loop) would otherwise generate
|
|
478
|
+
// an unbounded family of paths; fold to a capped prefix, which selects
|
|
479
|
+
// more, never less
|
|
480
|
+
if (path.length > 24 && path.charCodeAt(0) !== 36) {
|
|
481
|
+
const segs = segsOf(path);
|
|
482
|
+
if (segs.length > MAX_PATH_SEGMENTS) path = segs.slice(0, MAX_PATH_SEGMENTS).join('.');
|
|
483
|
+
}
|
|
484
|
+
const k = `${qid}|${path}|${ep ? 1 : 0}`;
|
|
485
|
+
if (this.seen.has(k)) return;
|
|
486
|
+
this.seen.add(k);
|
|
487
|
+
this.work.push({ qid, path, ep, depth, reason });
|
|
488
|
+
if (depth > this.maxDepthReached) this.maxDepthReached = depth;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
run() {
|
|
492
|
+
while (this.work.length) {
|
|
493
|
+
if (++this.steps > this.maxWork) {
|
|
494
|
+
this.limits.push({ kind: 'work-limit', count: this.work.length });
|
|
495
|
+
this.workLeft = this.work.length;
|
|
496
|
+
this.work = [];
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
const item = this.work.pop();
|
|
500
|
+
this.process(item);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
markUnsupported(kind, qid, detail) {
|
|
505
|
+
if (!this.unsupported.some((u) => u.kind === kind && u.qid === qid)) this.unsupported.push({ kind, qid, detail });
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Walk an expression in `fn` and register every path/call it depends on. */
|
|
509
|
+
reads(fn, expr, item) {
|
|
510
|
+
const rd = (e) => {
|
|
511
|
+
if (!e || typeof e !== 'object') return;
|
|
512
|
+
if (e.kind === 'literal') return;
|
|
513
|
+
if (e.kind === 'function-value') { this.callbackValue(e.qid, item.depth); return; }
|
|
514
|
+
const p = accessPathOf(e);
|
|
515
|
+
if (p) { this.push(fn.qid, p, item.ep, item.depth, item.reason); this.checkSourceExpr(fn, e, p); return; }
|
|
516
|
+
if (e.kind === 'call') { this.callExpr(fn, e, item); return; }
|
|
517
|
+
if (matchSource(e, fn.file)) this.terminals.add('source');
|
|
518
|
+
forEachChild(e, rd);
|
|
519
|
+
};
|
|
520
|
+
rd(expr);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
callbackValue(qid, depth) {
|
|
524
|
+
if (!qid || !this.cg.functions.has(qid)) return;
|
|
525
|
+
this.followed.callbacks += 1;
|
|
526
|
+
this.addRegion(qid, 'callback');
|
|
527
|
+
this.push(qid, '$ret', false, depth, 'callback');
|
|
528
|
+
this.push(qid, '$params', false, depth, 'callback');
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
callExpr(fn, e, item) {
|
|
532
|
+
if (matchSource(e, fn.file)) this.terminals.add('source');
|
|
533
|
+
const callee = e.callee;
|
|
534
|
+
// receiver: taint on the receiver flows through the call (`tainted.trim()`)
|
|
535
|
+
if (callee && typeof callee === 'object' && callee.kind === 'member') {
|
|
536
|
+
const rp = accessPathOf(callee.object);
|
|
537
|
+
if (rp) this.push(fn.qid, rp, item.ep, item.depth, item.reason);
|
|
538
|
+
else this.reads(fn, callee.object, item);
|
|
539
|
+
} else if (typeof callee === 'string' && callee.includes('.')) {
|
|
540
|
+
this.push(fn.qid, callee.slice(0, callee.lastIndexOf('.')), item.ep, item.depth, item.reason);
|
|
541
|
+
}
|
|
542
|
+
for (const a of (e.args || [])) this.reads(fn, a, item);
|
|
543
|
+
const name = calleeName(callee);
|
|
544
|
+
const g = this.idx.resolve(name, fn.file);
|
|
545
|
+
if (g) {
|
|
546
|
+
this.followed.producers += 1;
|
|
547
|
+
this.addRegion(g, 'producer');
|
|
548
|
+
this.push(g, '$ret', false, item.depth, 'producer');
|
|
549
|
+
this.terminals.add('call');
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
// member call on an unresolved receiver: the engine may resolve it through
|
|
553
|
+
// class-hierarchy analysis, so every project function with that bare name
|
|
554
|
+
// is a candidate producer (conservative expansion, never a drop).
|
|
555
|
+
const tail = name ? name.split('.').pop() : null;
|
|
556
|
+
// Only the engine's class-hierarchy resolution (opts._cha) can reach such a
|
|
557
|
+
// callee, so without it there is nothing to over-select; with it, only
|
|
558
|
+
// class methods are candidates.
|
|
559
|
+
const cands = tail && this.hasCha && callee && typeof callee === 'object' && callee.kind === 'member'
|
|
560
|
+
? (this.idx.byTail.get(tail) || []).filter((q) => /::[A-Za-z_$][\w$]*::[\w$]+@/.test(q) && !q.includes('::<module>::'))
|
|
561
|
+
: null;
|
|
562
|
+
if (cands && cands.length) {
|
|
563
|
+
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'); }
|
|
564
|
+
this.terminals.add('call');
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const info = fnInfo(fn);
|
|
568
|
+
const prop = callee && typeof callee === 'object' && callee.kind === 'member' && typeof callee.prop === 'string' ? callee.prop : (name ? name.split('.').pop() : null);
|
|
569
|
+
// A call through a parameter is a callback invocation (the registration
|
|
570
|
+
// site is followed by expandCallers); an array or promise combinator is
|
|
571
|
+
// modeled by the engine's higher-order handling.
|
|
572
|
+
const viaParam = callee && typeof callee === 'object' && callee.kind === 'ident' && info.params.includes(callee.name);
|
|
573
|
+
const modeled = viaParam || (prop && HIGHER_ORDER_METHODS.has(prop)) || (callee && matchSinkOrSanitizer(callee, fn.file)) || (name && lookupBuiltinSummary(name)) || (name && BUILTIN_ROOTS.has(rootOf(name)));
|
|
574
|
+
if (!modeled) {
|
|
575
|
+
const key = `${name || '<dynamic>'}@${fn.qid}`;
|
|
576
|
+
if (!this.unresolved.has(key)) this.unresolved.set(key, { callee: name || '<dynamic>', fnQid: fn.qid, line: e.line || null });
|
|
577
|
+
}
|
|
578
|
+
this.terminals.add('call');
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
checkSource(fn, path) {
|
|
582
|
+
const segs = path.split('.');
|
|
583
|
+
for (let i = 1; i <= segs.length; i++) {
|
|
584
|
+
if (matchSource(exprOfPath(segs.slice(0, i).join('.')), fn.file)) { this.terminals.add('source'); return; }
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
checkSourceExpr(fn, e, p) {
|
|
589
|
+
if (matchSource(e, fn.file)) this.terminals.add('source');
|
|
590
|
+
else this.checkSource(fn, p);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
process(item) {
|
|
594
|
+
const fn = this.cg.functions.get(item.qid);
|
|
595
|
+
if (!fn || !fn.cfg || !fn.cfg.nodes) { this.markUnsupported('missing-function-body', item.qid, item.path); return; }
|
|
596
|
+
this.addRegion(item.qid, item.reason || 'sink');
|
|
597
|
+
const info = fnInfo(fn);
|
|
598
|
+
const p = item.path;
|
|
599
|
+
|
|
600
|
+
if (!this.cbDone.has(fn.qid)) {
|
|
601
|
+
this.cbDone.add(fn.qid);
|
|
602
|
+
// callbacks defined inside a selected function run under its taint
|
|
603
|
+
for (const q of info.fnValues) this.callbackValue(q, item.depth);
|
|
604
|
+
this.requestFieldWriters(fn, info, item);
|
|
605
|
+
}
|
|
606
|
+
if (p === '$ret') { for (const r of info.returns) this.reads(fn, r, item); return; }
|
|
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; }
|
|
608
|
+
|
|
609
|
+
const root = rootOf(p);
|
|
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;
|
|
615
|
+
this.checkSource(fn, p);
|
|
616
|
+
|
|
617
|
+
for (const a of assignsFor(info, root)) {
|
|
618
|
+
if (a.opaque) { this.reads(fn, a.source, item); continue; }
|
|
619
|
+
if (!segOverlap(a.target, p)) continue;
|
|
620
|
+
// target covers the needed path (equal or a prefix): the value of the
|
|
621
|
+
// needed path comes from the matching part of the source. Otherwise the
|
|
622
|
+
// write is to a sub-field of the needed path (a partial write): the whole
|
|
623
|
+
// source matters.
|
|
624
|
+
const tl = a.target.split('.').length; const pl = p.split('.').length;
|
|
625
|
+
if (tl <= pl) this.assignSource(fn, a, p.split('.').slice(tl).join('.'), item);
|
|
626
|
+
else this.reads(fn, a.source, item);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
for (const occ of callsFor(info, root)) {
|
|
630
|
+
if (!this.callTouches(occ.expr, p)) continue;
|
|
631
|
+
// the queried sink call itself runs after its argument is formed, so it
|
|
632
|
+
// cannot change what the argument holds
|
|
633
|
+
if (this.sinkCalls.size && this.sinkCalls.has(`${fn.qid}|${occ.line}|${calleeName(occ.expr.callee)}`)) continue;
|
|
634
|
+
this.followed.mutators += 1;
|
|
635
|
+
this.reads(fn, occ.expr, item);
|
|
636
|
+
const name = calleeName(occ.expr.callee);
|
|
637
|
+
const g = this.idx.resolve(name, fn.file);
|
|
638
|
+
if (g && g !== fn.qid) { this.addRegion(g, 'mutator'); this.push(g, '$params', false, item.depth, 'mutator'); }
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const pi = info.params.indexOf(root);
|
|
642
|
+
if (pi >= 0) {
|
|
643
|
+
this.terminals.add('param');
|
|
644
|
+
if (item.ep) this.expandCallers(fn, pi, item);
|
|
645
|
+
} else if (selfRooted) {
|
|
646
|
+
this.terminals.add('field');
|
|
647
|
+
} else if (!info.assignedRoots.has(root) && !info.hasOpaqueAssign && !info.params.includes(root)) {
|
|
648
|
+
this.freeVariable(fn, root, p, item);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
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
|
+
|
|
746
|
+
assignSource(fn, a, suffix, item) {
|
|
747
|
+
const src = a.source;
|
|
748
|
+
if (!src) return;
|
|
749
|
+
if (src.kind === 'object' && Array.isArray(src.props)) {
|
|
750
|
+
if (!suffix) { for (const pr of src.props) this.reads(fn, pr.value, item); return; }
|
|
751
|
+
const segs = suffix.split('.');
|
|
752
|
+
const key = segs[0];
|
|
753
|
+
let found = false;
|
|
754
|
+
for (const pr of src.props) {
|
|
755
|
+
if (pr.key === key || pr.key === '*' || key === '*') {
|
|
756
|
+
found = true;
|
|
757
|
+
const rest = segs.slice(1).join('.');
|
|
758
|
+
const vp = accessPathOf(pr.value);
|
|
759
|
+
if (rest && vp) this.push(fn.qid, `${vp}.${rest}`, item.ep, item.depth, item.reason);
|
|
760
|
+
else this.reads(fn, pr.value, item);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
// a spread entry (`...other`) can supply any key
|
|
764
|
+
if (!found || src.props.some((pr) => pr.key === '...' || pr.spread)) this.reads(fn, src, item);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
const sp = accessPathOf(src);
|
|
768
|
+
if (sp) { this.push(fn.qid, suffix ? `${sp}.${suffix}` : sp, item.ep, item.depth, item.reason); this.checkSource(fn, sp); return; }
|
|
769
|
+
this.reads(fn, src, item);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
callTouches(call, p) {
|
|
773
|
+
return touchedPaths(call).some((q) => segOverlap(q, p));
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
expandCallers(fn, paramIdx, item) {
|
|
777
|
+
// the caller-side argument is read whole, so one expansion per parameter
|
|
778
|
+
// covers every access path rooted at it
|
|
779
|
+
const ek = `${fn.qid}|${paramIdx}`;
|
|
780
|
+
if (this.expanded.has(ek)) return;
|
|
781
|
+
this.expanded.add(ek);
|
|
782
|
+
const sites = this.idx.incoming.get(fn.qid) || [];
|
|
783
|
+
if (!sites.length) return;
|
|
784
|
+
if (item.depth + 1 > this.maxDepth) { this.depthCut += sites.length; this.limits.push({ kind: 'depth-limit', count: sites.length }); return; }
|
|
785
|
+
for (const s of sites) {
|
|
786
|
+
const caller = this.cg.functions.get(s.caller);
|
|
787
|
+
if (!caller) continue;
|
|
788
|
+
this.followed.callers += 1;
|
|
789
|
+
this.addRegion(s.caller, s.kind === 'call' ? 'caller' : 'callback-registrar');
|
|
790
|
+
const ni = { ...item, qid: s.caller, depth: item.depth + 1, ep: true, reason: 'caller' };
|
|
791
|
+
if (s.kind === 'call') {
|
|
792
|
+
const arg = (s.call.args || [])[paramIdx];
|
|
793
|
+
if (arg) this.reads(caller, arg, ni);
|
|
794
|
+
const callee = s.call.callee;
|
|
795
|
+
if (callee && typeof callee === 'object' && callee.kind === 'member') this.reads(caller, callee.object, ni);
|
|
796
|
+
} else {
|
|
797
|
+
for (const a of (s.call.args || [])) if (!(a && a.kind === 'function-value' && a.qid === fn.qid)) this.reads(caller, a, ni);
|
|
798
|
+
const callee = s.call.callee;
|
|
799
|
+
if (callee && typeof callee === 'object' && callee.kind === 'member') this.reads(caller, callee.object, ni);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
requestFieldWriters(fn, info, item) {
|
|
805
|
+
if (!item.ep && item.reason !== 'sink') return;
|
|
806
|
+
const p0 = info.params[0];
|
|
807
|
+
if (!p0 || !REQ_PARAM.test(p0) || this.reqDone.has(fn.file)) return;
|
|
808
|
+
this.reqDone.add(fn.file);
|
|
809
|
+
for (const other of (this.idx.byFile.get(fn.file) || [])) {
|
|
810
|
+
if (other.file !== fn.file) continue;
|
|
811
|
+
const oi = fnInfo(other);
|
|
812
|
+
const q0 = oi.params[0];
|
|
813
|
+
if (!q0 || !REQ_PARAM.test(q0)) continue;
|
|
814
|
+
for (const a of oi.assigns) {
|
|
815
|
+
if (a.target && a.target.startsWith(`${q0}.`)) {
|
|
816
|
+
this.addRegion(other.qid, 'request-field-writer');
|
|
817
|
+
this.push(other.qid, a.target, true, item.depth, 'request-field-writer');
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
freeVariable(fn, root, p, item) {
|
|
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);
|
|
835
|
+
}
|
|
836
|
+
const definers = all.filter((d) => d.qid !== fn.qid);
|
|
837
|
+
// import bindings (`const x = require(...)`) carry no data of their own
|
|
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'));
|
|
839
|
+
if (!definers.length) return; // ambient global; the engine models it by name only
|
|
840
|
+
this.terminals.add('free');
|
|
841
|
+
if (importOnly) return;
|
|
842
|
+
for (const d of definers) {
|
|
843
|
+
if (d.qid === fn.qid) continue;
|
|
844
|
+
this.addRegion(d.qid, 'free-variable-definer');
|
|
845
|
+
this.push(d.qid, p, item.ep, item.depth, 'free-variable-definer');
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// ---------------------------------------------------------------------------
|
|
851
|
+
// Seeds from a sink site
|
|
852
|
+
// ---------------------------------------------------------------------------
|
|
853
|
+
|
|
854
|
+
function seedExprs(site, q) {
|
|
855
|
+
let args;
|
|
856
|
+
if (q.argIndex != null) args = site.argExprs[q.argIndex] ? [site.argExprs[q.argIndex]] : [];
|
|
857
|
+
else if (site.entry.argIndex === 'all') args = site.argExprs;
|
|
858
|
+
else if (typeof site.entry.argIndex === 'number') args = site.argExprs[site.entry.argIndex] ? [site.argExprs[site.entry.argIndex]] : [];
|
|
859
|
+
else args = site.argExprs; // 'rhs' and member-write
|
|
860
|
+
return args;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function seedFrontier(frontier, fn, site, q) {
|
|
864
|
+
const item = { qid: site.fnQid, path: '', ep: true, depth: 0, reason: 'sink' };
|
|
865
|
+
frontier.addRegion(site.fnQid, 'sink');
|
|
866
|
+
if (site.kind === 'call') frontier.sinkCalls.add(`${site.fnQid}|${site.line}|${site.callee}`);
|
|
867
|
+
for (const a of seedExprs(site, q)) {
|
|
868
|
+
if (!q.accessPath) { frontier.reads(fn, a, item); continue; }
|
|
869
|
+
const ap = accessPathOf(a);
|
|
870
|
+
if (ap) { frontier.push(site.fnQid, `${ap}.${q.accessPath}`, true, 0, 'sink'); continue; }
|
|
871
|
+
if (a && a.kind === 'object' && Array.isArray(a.props)) {
|
|
872
|
+
const segs = q.accessPath.split('.');
|
|
873
|
+
for (const pr of a.props) {
|
|
874
|
+
if (pr.key !== segs[0] && pr.key !== '*') continue;
|
|
875
|
+
const vp = accessPathOf(pr.value);
|
|
876
|
+
if (segs.length > 1 && vp) frontier.push(site.fnQid, `${vp}.${segs.slice(1).join('.')}`, true, 0, 'sink');
|
|
877
|
+
else frontier.reads(fn, pr.value, item);
|
|
878
|
+
}
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
// not a path or literal: no field refinement is possible, read the whole expression
|
|
882
|
+
frontier.reads(fn, a, item);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// ---------------------------------------------------------------------------
|
|
887
|
+
// Normalization, running, comparing
|
|
888
|
+
// ---------------------------------------------------------------------------
|
|
889
|
+
|
|
890
|
+
export function normalizeFinding(f) {
|
|
891
|
+
const sources = new Set();
|
|
892
|
+
if (f.source && f.source.label) sources.add(`${f.source.label}@${f.source.line ?? ''}`);
|
|
893
|
+
for (const c of (f.chain || [])) if (c && c.label) sources.add(`${c.label}@${c.line ?? ''}`);
|
|
894
|
+
return {
|
|
895
|
+
file: f.file,
|
|
896
|
+
line: f.line,
|
|
897
|
+
sinkId: f.sink ? f.sink.label : null,
|
|
898
|
+
argIndex: f.argIndex ?? null,
|
|
899
|
+
cwe: f.cwe ?? null,
|
|
900
|
+
sources: [...sources].sort(),
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const findingKey = (n) => JSON.stringify([n.file, n.line, n.sinkId, n.argIndex, n.cwe, n.sources]);
|
|
905
|
+
|
|
906
|
+
function pickFindings(raw, sites, q) {
|
|
907
|
+
const out = [];
|
|
908
|
+
const want = new Set(sites.map((s) => `${s.file}|${s.line}|${s.sinkId}`));
|
|
909
|
+
for (const f of raw) {
|
|
910
|
+
if (!f.sink || !want.has(`${f.file}|${f.line}|${f.sink.label}`)) continue;
|
|
911
|
+
if (q.argIndex != null && f.argIndex !== q.argIndex) continue;
|
|
912
|
+
out.push(normalizeFinding(f));
|
|
913
|
+
}
|
|
914
|
+
const byKey = new Map();
|
|
915
|
+
for (const n of out) byKey.set(findingKey(n), n);
|
|
916
|
+
return [...byKey.values()].sort((a, b) => (findingKey(a) < findingKey(b) ? -1 : 1));
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const _sbCache = new WeakMap();
|
|
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) {
|
|
928
|
+
const info = fnInfo(fn);
|
|
929
|
+
let found = Array.isArray(fn.paramAnnotations) && fn.paramAnnotations.length > 0;
|
|
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); };
|
|
931
|
+
for (const a of info.assigns) rd(a.source);
|
|
932
|
+
for (const c of info.calls) rd(c.expr);
|
|
933
|
+
for (const r of info.returns) rd(r);
|
|
934
|
+
return found;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const _lru = new Map();
|
|
938
|
+
const LRU_MAX = 64;
|
|
939
|
+
|
|
940
|
+
function resultCacheGet(key) { if (!_lru.has(key)) return null; const v = _lru.get(key); _lru.delete(key); _lru.set(key, v); return v; }
|
|
941
|
+
function resultCachePut(key, v) { _lru.set(key, v); while (_lru.size > LRU_MAX) _lru.delete(_lru.keys().next().value); }
|
|
942
|
+
export function clearDemandCache() { _lru.clear(); }
|
|
943
|
+
|
|
944
|
+
function engineOptsOf(opts) {
|
|
945
|
+
const o = { ...(opts.engineOpts || {}) };
|
|
946
|
+
if (opts.fnLimit) o.fnLimit = opts.fnLimit;
|
|
947
|
+
if (typeof opts.deadlineMs === 'number') o.deadlineMs = opts.deadlineMs;
|
|
948
|
+
return o;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function engineIncomplete(raw) {
|
|
952
|
+
const list = raw && raw.analysisIncomplete;
|
|
953
|
+
return Array.isArray(list) ? list.map((x) => x.reason).filter(Boolean) : [];
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function fieldAttributable(frontier) {
|
|
957
|
+
return frontier.terminals.size > 0;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function baseResult(q, key, digests) {
|
|
961
|
+
return { schemaVersion: DEMAND_SCHEMA_VERSION, queryKey: key, query: q, digests };
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Run one sink query in demand mode. Returns the result record described in
|
|
966
|
+
* the module header. Never throws for an unanswerable query: it returns an
|
|
967
|
+
* `unavailable` completion instead.
|
|
968
|
+
*/
|
|
969
|
+
export function runDemandSinkQuery(perFileIR, callGraph, query, opts = {}) {
|
|
970
|
+
const q = normalizeSinkQuery(query);
|
|
971
|
+
const digests = { graphDigest: opts.graphDigest ?? graphDigest(callGraph), configDigest: opts.configDigest ?? configDigest(opts) };
|
|
972
|
+
const key = sinkQueryKey(q, digests);
|
|
973
|
+
const useCache = opts.cache !== false && !opts.engine;
|
|
974
|
+
if (useCache) { const hit = resultCacheGet(key); if (hit) return { ...hit, cached: true }; }
|
|
975
|
+
const res = baseResult(q, key, digests);
|
|
976
|
+
res.mode = 'demand';
|
|
977
|
+
const total = callGraph && callGraph.functions ? callGraph.functions.size : 0;
|
|
978
|
+
|
|
979
|
+
if (!callGraph || !callGraph.functions) return unavailable(res, 'no-call-graph', total);
|
|
980
|
+
if (!DEMAND_CONTEXTS.includes(q.context)) return unavailable(res, 'unsupported-context', total);
|
|
981
|
+
const sites = findSinkSites(callGraph, q);
|
|
982
|
+
if (!sites.length) return unavailable(res, 'sink-site-not-found', total);
|
|
983
|
+
|
|
984
|
+
const frontier = new Frontier(callGraph, opts);
|
|
985
|
+
for (const s of sites) seedFrontier(frontier, callGraph.functions.get(s.fnQid), s, q);
|
|
986
|
+
frontier.run();
|
|
987
|
+
|
|
988
|
+
const onLimit = opts.onLimit || 'full';
|
|
989
|
+
const onUnsupported = opts.onUnsupported || 'full';
|
|
990
|
+
const reasons = [];
|
|
991
|
+
let fallback = null;
|
|
992
|
+
if (frontier.limits.length) {
|
|
993
|
+
if (onLimit === 'full') fallback = { reason: frontier.limits.map((l) => l.kind).sort()[0], expandedToFull: true };
|
|
994
|
+
else for (const l of frontier.limits) reasons.push(l.kind);
|
|
995
|
+
}
|
|
996
|
+
if (frontier.unsupported.length) {
|
|
997
|
+
const kinds = frontier.unsupported.map((u) => u.kind).sort();
|
|
998
|
+
if (onUnsupported === 'full') fallback = fallback || { reason: `unsupported:${kinds[0]}`, expandedToFull: true };
|
|
999
|
+
else for (const k of new Set(kinds)) reasons.push(`unsupported-construct:${k}`);
|
|
1000
|
+
}
|
|
1001
|
+
if (frontier.unresolved.size) reasons.push('unresolved-callee');
|
|
1002
|
+
|
|
1003
|
+
let regionIds = [...frontier.region.keys()].sort();
|
|
1004
|
+
if (fallback) regionIds = [...callGraph.functions.keys()].sort();
|
|
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)])) };
|
|
1008
|
+
|
|
1009
|
+
const run = opts.engine || runTaintEngine;
|
|
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));
|
|
1018
|
+
for (const r of engineIncomplete(raw)) reasons.push(`engine:${r}`);
|
|
1019
|
+
|
|
1020
|
+
let findings = pickFindings(raw, sites, q);
|
|
1021
|
+
let siblingSuppressed = 0;
|
|
1022
|
+
if (q.accessPath && !fieldAttributable(frontier)) { siblingSuppressed = findings.length; findings = []; }
|
|
1023
|
+
|
|
1024
|
+
const sbIn = regionIds.filter((id) => sourceBearing(callGraph.functions.get(id)));
|
|
1025
|
+
res.sites = sites.map((s) => ({ file: s.file, line: s.line, fnQid: s.fnQid, nodeId: s.nodeId, sinkId: s.sinkId, kind: s.kind }));
|
|
1026
|
+
res.findings = findings;
|
|
1027
|
+
res.siblingFieldSuppressed = siblingSuppressed;
|
|
1028
|
+
res.witness = findings.length ? 'forward-engine-corroborated' : 'none';
|
|
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') };
|
|
1030
|
+
res.selectorCoverage = {
|
|
1031
|
+
totalFunctions: total,
|
|
1032
|
+
selectedFunctions: frontier.region.size,
|
|
1033
|
+
selectedFraction: total ? frontier.region.size / total : 0,
|
|
1034
|
+
byReason: countReasons(frontier.region),
|
|
1035
|
+
followed: { ...frontier.followed },
|
|
1036
|
+
maxDepthReached: frontier.maxDepthReached,
|
|
1037
|
+
workSteps: frontier.steps,
|
|
1038
|
+
limits: { maxCallerDepth: frontier.maxDepth, maxWork: frontier.maxWork },
|
|
1039
|
+
limitHits: frontier.limits.map((l) => ({ ...l })),
|
|
1040
|
+
unsupported: frontier.unsupported.map((u) => ({ ...u })),
|
|
1041
|
+
unresolvedCallees: [...frontier.unresolved.values()],
|
|
1042
|
+
terminals: [...frontier.terminals].sort(),
|
|
1043
|
+
};
|
|
1044
|
+
res.analyzedFunctions = regionIds.length;
|
|
1045
|
+
res.fallback = fallback;
|
|
1046
|
+
res.completion = completionOf(reasons, frontier, fallback);
|
|
1047
|
+
res.negativeClaimAllowed = res.completion.negativeClaimAllowed;
|
|
1048
|
+
res.verdict = findings.length ? 'flow-found' : (res.negativeClaimAllowed ? 'no-flow-in-modeled-scope' : 'unknown');
|
|
1049
|
+
if (useCache) resultCachePut(key, res);
|
|
1050
|
+
return res;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function countReasons(region) {
|
|
1054
|
+
const out = {};
|
|
1055
|
+
for (const set of region.values()) for (const r of set) out[r] = (out[r] || 0) + 1;
|
|
1056
|
+
return out;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function completionOf(reasons, frontier, fallback) {
|
|
1060
|
+
const unexplored = frontier.depthCut + frontier.workLeft;
|
|
1061
|
+
const partial = reasons.length > 0;
|
|
1062
|
+
return makeCompletion({
|
|
1063
|
+
status: partial ? 'partial' : 'complete',
|
|
1064
|
+
modelCoverage: partial ? 'partial' : 'complete',
|
|
1065
|
+
reasons,
|
|
1066
|
+
unexploredFrontierCount: partial ? Math.max(unexplored, 0) : 0,
|
|
1067
|
+
limits: { maxCallerDepth: frontier.maxDepth, maxWork: frontier.maxWork, expandedToFull: !!fallback },
|
|
1068
|
+
unit: 'demand-query',
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function unavailable(res, reason, total) {
|
|
1073
|
+
res.sites = [];
|
|
1074
|
+
res.findings = [];
|
|
1075
|
+
res.siblingFieldSuppressed = 0;
|
|
1076
|
+
res.witness = 'none';
|
|
1077
|
+
res.candidateFilter = { role: 'candidate-filter-only', sourceBearingFunctionsInRegion: 0, candidate: false };
|
|
1078
|
+
res.selectorCoverage = { totalFunctions: total, selectedFunctions: 0, selectedFraction: 0, byReason: {}, followed: {}, maxDepthReached: 0, workSteps: 0, limits: {}, limitHits: [], unsupported: [], unresolvedCallees: [], terminals: [] };
|
|
1079
|
+
res.analyzedFunctions = 0;
|
|
1080
|
+
res.fallback = null;
|
|
1081
|
+
res.completion = makeCompletion({ status: 'unavailable', reasons: [reason], unit: 'demand-query' });
|
|
1082
|
+
res.negativeClaimAllowed = false;
|
|
1083
|
+
res.verdict = 'unknown';
|
|
1084
|
+
return res;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/** Reference answer: the same query over the FULL function set. */
|
|
1088
|
+
export function runFullSinkQuery(perFileIR, callGraph, query, opts = {}) {
|
|
1089
|
+
const q = normalizeSinkQuery(query);
|
|
1090
|
+
const digests = { graphDigest: opts.graphDigest ?? graphDigest(callGraph), configDigest: opts.configDigest ?? configDigest(opts) };
|
|
1091
|
+
const res = baseResult(q, sinkQueryKey(q, digests), digests);
|
|
1092
|
+
res.mode = 'full';
|
|
1093
|
+
const total = callGraph.functions.size;
|
|
1094
|
+
const sites = findSinkSites(callGraph, q);
|
|
1095
|
+
if (!sites.length) return unavailable(res, 'sink-site-not-found', total);
|
|
1096
|
+
const run = opts.engine || runTaintEngine;
|
|
1097
|
+
const raw = run(perFileIR, callGraph, engineOptsOf(opts));
|
|
1098
|
+
let findings = pickFindings(raw, sites, q);
|
|
1099
|
+
let suppressed = 0;
|
|
1100
|
+
if (q.accessPath) {
|
|
1101
|
+
const frontier = new Frontier(callGraph, opts);
|
|
1102
|
+
for (const s of sites) seedFrontier(frontier, callGraph.functions.get(s.fnQid), s, q);
|
|
1103
|
+
frontier.run();
|
|
1104
|
+
if (!fieldAttributable(frontier)) { suppressed = findings.length; findings = []; }
|
|
1105
|
+
}
|
|
1106
|
+
res.sites = sites.map((s) => ({ file: s.file, line: s.line, fnQid: s.fnQid, nodeId: s.nodeId, sinkId: s.sinkId, kind: s.kind }));
|
|
1107
|
+
res.findings = findings;
|
|
1108
|
+
res.siblingFieldSuppressed = suppressed;
|
|
1109
|
+
res.analyzedFunctions = total;
|
|
1110
|
+
res.engineIncomplete = engineIncomplete(raw);
|
|
1111
|
+
return res;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/** Compare a demand result with the full reference for the same query. */
|
|
1115
|
+
export function compareToFull(demand, full) {
|
|
1116
|
+
const a = demand.findings.map(findingKey);
|
|
1117
|
+
const b = full.findings.map(findingKey);
|
|
1118
|
+
const missing = b.filter((k) => !a.includes(k));
|
|
1119
|
+
const extra = a.filter((k) => !b.includes(k));
|
|
1120
|
+
return { equal: missing.length === 0 && extra.length === 0, missing, extra };
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/** Run a batch of queries (the shape the optional dataflow/index.js hook uses). */
|
|
1124
|
+
export function runDemandQueries(perFileIR, callGraph, queries, opts = {}) {
|
|
1125
|
+
const batchOpts = opts.engine ? opts : { ...opts, _fullMemo: new Map() };
|
|
1126
|
+
return (queries || []).map((q) => runDemandSinkQuery(perFileIR, callGraph, q, batchOpts));
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/** Enumerate every sink site in the graph as a query (used by parity checks and the benchmark). */
|
|
1130
|
+
export function enumerateSinkQueries(callGraph) {
|
|
1131
|
+
return findSinkSites(callGraph, {}).map((s) => ({ file: s.file, line: s.line, nodeId: s.nodeId, fnQid: s.fnQid, sinkId: s.sinkId }));
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// ---------------------------------------------------------------------------
|
|
1135
|
+
// R02.5: adapters over the existing reverse implementations
|
|
1136
|
+
// ---------------------------------------------------------------------------
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Compare the demand region of a query result with the coarse tabulation slice
|
|
1140
|
+
* for the same sink functions, using the shared completion metadata. Nothing is
|
|
1141
|
+
* replaced: the report states which role each selector keeps.
|
|
1142
|
+
*/
|
|
1143
|
+
export function reconcileSelectors(callGraph, demandResult) {
|
|
1144
|
+
const sinkFns = [...new Set((demandResult.sites || []).map((s) => s.fnQid))];
|
|
1145
|
+
const tab = reachabilitySliceWithCompletion(sinkFns.map((fnQid) => ({ fnQid })), callGraph);
|
|
1146
|
+
const demandIds = new Set();
|
|
1147
|
+
const frontier = new Frontier(callGraph, {});
|
|
1148
|
+
for (const s of findSinkSites(callGraph, demandResult.query)) seedFrontier(frontier, callGraph.functions.get(s.fnQid), s, demandResult.query);
|
|
1149
|
+
frontier.run();
|
|
1150
|
+
for (const id of frontier.region.keys()) demandIds.add(id);
|
|
1151
|
+
const onlyDemand = [...demandIds].filter((x) => !tab.reachable.has(x)).sort();
|
|
1152
|
+
const onlyTabulation = [...tab.reachable].filter((x) => !demandIds.has(x)).sort();
|
|
1153
|
+
return {
|
|
1154
|
+
roles: {
|
|
1155
|
+
tabulation: 'coarse caller-only selector; kept, cannot see producer helpers or callback registrars',
|
|
1156
|
+
backward: 'explanation trace of an emitted finding; kept, not a selector',
|
|
1157
|
+
ifdsPrecise: 'depth-capped predecessor chain; kept, now reports a completion record',
|
|
1158
|
+
demand: 'sink-keyed selector with forward corroboration',
|
|
1159
|
+
},
|
|
1160
|
+
tabulationCompletion: makeCompletion({ status: tab.completion.status, reasons: tab.completion.reasons, unexploredFrontierCount: tab.completion.unexploredFrontierCount, limits: tab.completion.limits, unit: 'tabulation-slice' }),
|
|
1161
|
+
demandCompletion: demandResult.completion,
|
|
1162
|
+
onlyDemand,
|
|
1163
|
+
onlyTabulation,
|
|
1164
|
+
tabulationMissedDemandFunctions: onlyDemand.length,
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
export const _internals = { Frontier, fnInfo, segOverlap, calleeName, sourceBearing };
|