@clear-capabilities/agentic-security-scanner 0.136.9 → 0.137.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +644 -0
- package/bin/agentic-security.js +3 -0
- package/dist/435.index.js +9 -1
- package/dist/agentic-security.mjs +3 -3
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +8 -8
- package/src/dataflow/CLAUDE.md +3 -1
- package/src/dataflow/catalog-expanded.js +1 -0
- package/src/dataflow/catalog.js +157 -31
- package/src/dataflow/engine.js +318 -55
- package/src/dataflow/index.js +15 -0
- package/src/dataflow/points-to.js +19 -6
- package/src/engine.js +281 -207
- package/src/ir/CLAUDE.md +14 -3
- package/src/ir/class-hierarchy.js +57 -11
- package/src/ir/index.js +14 -2
- package/src/ir/parser-cs.js +451 -31
- package/src/ir/parser-java.js +205 -2
- package/src/ir/parser-js.js +149 -2
- package/src/ir/parser-kt.js +436 -18
- package/src/ir/parser-php.js +587 -29
- package/src/ir/parser-py.helper.py +32 -2
- package/src/ir/parser-py.js +31 -4
- package/src/ir/parser-rb.js +124 -19
- package/src/lsp/server.js +7 -1
- package/src/mcp/tools.js +9 -1
- package/src/posture/clustering.js +12 -1
- package/src/posture/sbom.js +2 -2
package/src/dataflow/engine.js
CHANGED
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
// sanitizer must never silently drop a real vulnerability, so the walk never
|
|
43
43
|
// treats a sanitizer call as clearing the tainted path on its own.
|
|
44
44
|
|
|
45
|
-
import { matchSource, matchSinkOrSanitizer } from './catalog.js';
|
|
45
|
+
import { matchSource, matchSinkOrSanitizer, matchMemberWriteSink, matchAnnotationParams } from './catalog.js';
|
|
46
46
|
import { functionRecord } from '../ir/callgraph.js';
|
|
47
47
|
import { accessPathOf, isCoveredBy, addPath, removePathAndDescendants, joinSets as joinAccessSets, setsEqual as accessSetsEqual } from './access-paths.js';
|
|
48
48
|
import { aliasesForVar } from './points-to.js';
|
|
@@ -50,6 +50,13 @@ import { higherOrderTaintFlow } from './higher-order.js';
|
|
|
50
50
|
import { SummaryCache, entryStateFromCall } from './summaries.js';
|
|
51
51
|
import { lookupBuiltinSummary } from './builtin-summaries.js';
|
|
52
52
|
import { isImplicitFlowEnabled, buildImplicitContext, implicitAssignTarget, markImplicitTaint, createImplicitFinding } from './implicit-flow.js';
|
|
53
|
+
// NOTE: receiver-context.js's receiverTypeAtCall is deliberately NOT imported
|
|
54
|
+
// here. It was the implementation of _receiverTypeFor's old `this.field`
|
|
55
|
+
// branch, whose PascalCase-of-field-name guess is the false-negative bug this
|
|
56
|
+
// file's _receiverTypeFor comment describes. Its other exports are still used
|
|
57
|
+
// (summaries.js imports hashReceiverType for cache keying); only this call
|
|
58
|
+
// site is gone.
|
|
59
|
+
import { resolveMethod, classOfVar } from '../ir/class-hierarchy.js';
|
|
53
60
|
|
|
54
61
|
// v0.70 #2 — addPath that also taints every alias of the variable.
|
|
55
62
|
// When `target` is a dotted path like "a.x" and the root `a` has aliases
|
|
@@ -102,6 +109,74 @@ function _flattenCalleeName(calleeExpr) {
|
|
|
102
109
|
return null;
|
|
103
110
|
}
|
|
104
111
|
|
|
112
|
+
// PRD R6/R11 (docs/DETECTION_GAP_REMEDIATION_PRD.md): unlike _flattenCalleeName
|
|
113
|
+
// (which only flattens ONE level — `x.method`/`this.method` — because that is
|
|
114
|
+
// the 2-segment shape catalog matching and resolveKnownCallee both key on),
|
|
115
|
+
// _receiverTypeFor needs the FULL dotted chain, including `this`, to see how
|
|
116
|
+
// LONG the chain actually is: a 3+-segment chain (`this.userRepo.save` ->
|
|
117
|
+
// ['this','userRepo','save'], `svc.db.query` -> ['svc','db','query']) names a
|
|
118
|
+
// receiver that is a property path, and CHA cannot type property paths at all.
|
|
119
|
+
// A partial flatten (`_flattenCalleeName` returns just 'save' for
|
|
120
|
+
// `this.userRepo.save`, since its object isn't a bare ident) would hide that
|
|
121
|
+
// distinction and make a 3-segment chain look like an untyped bare call.
|
|
122
|
+
//
|
|
123
|
+
// Note: parser-js.js encodes ThisExpression as {kind:'ident', name:'_this_'}
|
|
124
|
+
// (a sentinel, not literal 'this'). We convert it to the literal string
|
|
125
|
+
// 'this' so the flattened chain reads the way the source does and its segment
|
|
126
|
+
// count is honest (`this.db.query` is 3 segments, not 2).
|
|
127
|
+
function _fullyFlattenMemberChain(calleeExpr) {
|
|
128
|
+
if (!calleeExpr) return null;
|
|
129
|
+
if (typeof calleeExpr === 'string') return calleeExpr;
|
|
130
|
+
if (calleeExpr.kind === 'ident') {
|
|
131
|
+
const name = calleeExpr.name || null;
|
|
132
|
+
// Convert the parser's _this_ sentinel to the literal 'this' string
|
|
133
|
+
return name === '_this_' ? 'this' : name;
|
|
134
|
+
}
|
|
135
|
+
if (calleeExpr.kind === 'member' && typeof calleeExpr.prop === 'string') {
|
|
136
|
+
const base = _fullyFlattenMemberChain(calleeExpr.object);
|
|
137
|
+
return base ? `${base}.${calleeExpr.prop}` : calleeExpr.prop;
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Shared by R6 (catalog receiver-type gating) and R11 (member-call
|
|
143
|
+
// resolution) so both use the exact same precision bar, per the PRD's own
|
|
144
|
+
// sequencing note that R11 must not be more permissive than R6. Returns null
|
|
145
|
+
// whenever CHA has nothing useful to say — callers must treat null as
|
|
146
|
+
// "unknown", never as a signal to suppress or refuse (see this file's
|
|
147
|
+
// "Unknown ≠ clean" global constraint).
|
|
148
|
+
function _receiverTypeFor(calleeExpr, callContext) {
|
|
149
|
+
if (!callContext || !callContext._cha) return null;
|
|
150
|
+
const flat = _fullyFlattenMemberChain(calleeExpr);
|
|
151
|
+
if (!flat || !flat.includes('.')) return null;
|
|
152
|
+
const parts = flat.split('.');
|
|
153
|
+
// Only a bare `x.method()` receiver (exactly 2 dot-separated parts) is
|
|
154
|
+
// something CHA can genuinely verify: classOfVar only tracks bare local
|
|
155
|
+
// variable -> class bindings from `let/const x = new Foo()`, never
|
|
156
|
+
// property-path types. This one condition replaces two separate prior
|
|
157
|
+
// bugs found in whole-branch review: the this.field branch (`this.x.y()`
|
|
158
|
+
// is 3 parts, `this` as root) used to PascalCase-guess a type from the
|
|
159
|
+
// field name and could never return null, silently suppressing real
|
|
160
|
+
// findings on any field name outside a fixed vocabulary
|
|
161
|
+
// (this.dbConn.query(), this.readReplica.query(), ...); the non-this
|
|
162
|
+
// branch used to resolve parts[0] (the chain ROOT) for multi-segment
|
|
163
|
+
// chains like svc.db.query(), which answers "what type is svc?" instead
|
|
164
|
+
// of the actual question "what type is svc.db?" -- a question CHA has no
|
|
165
|
+
// way to answer, since it never tracks field types, only local-variable
|
|
166
|
+
// types. Both were name/shape guesses being trusted as confident
|
|
167
|
+
// resolutions. A multi-segment or `this`-rooted chain now honestly
|
|
168
|
+
// returns null (unknown, permissive) rather than guessing.
|
|
169
|
+
//
|
|
170
|
+
// The same doctrine already killed a third instance of this bug class: a
|
|
171
|
+
// receiver assigned via `const c = mysql.createConnection({})` cannot be
|
|
172
|
+
// typed by CHA (member-call factory, not `new X()`), so classOfVar returns
|
|
173
|
+
// null — returning the bare name 'c' as a "type" suppressed a real finding
|
|
174
|
+
// (regression: CVE-2021-22214-node-sqli-shape). There is no name-based
|
|
175
|
+
// fallback anywhere in this function for exactly that reason.
|
|
176
|
+
if (parts.length !== 2) return null;
|
|
177
|
+
return classOfVar(callContext._cha, _currentFile, callContext._currentFnQid, parts[0]);
|
|
178
|
+
}
|
|
179
|
+
|
|
105
180
|
// Narrower than _flattenCalleeName: the name to hand to callGraph.resolve().
|
|
106
181
|
// Only a bare identifier call (`helper()`) — or a pre-flattened STRING, which
|
|
107
182
|
// is how the Go/PHP/Ruby/Python/C++ parsers already emit call targets —
|
|
@@ -120,7 +195,106 @@ function _resolvableCalleeName(calleeExpr) {
|
|
|
120
195
|
return null;
|
|
121
196
|
}
|
|
122
197
|
|
|
123
|
-
|
|
198
|
+
// R11 (docs/DETECTION_GAP_REMEDIATION_PRD.md): unlike R6's _receiverTypeFor
|
|
199
|
+
// (used only to narrow an ALREADY-pattern-matched catalog sink — safe to be
|
|
200
|
+
// wrong in either direction, since the worst case is over/under-gating an
|
|
201
|
+
// existing match), this function creates a NEW interprocedural call-graph
|
|
202
|
+
// edge. A wrong resolution here fabricates a data-flow path that does not
|
|
203
|
+
// exist, which this codebase's own doctrine treats as strictly worse than
|
|
204
|
+
// a missed one (see _resolvableCalleeName's comment above). It therefore
|
|
205
|
+
// calls classOfVar DIRECTLY, which only returns non-null when the receiver
|
|
206
|
+
// was genuinely assignment-tracked (`let x = new Foo()`), and additionally
|
|
207
|
+
// requires the receiver to be a bare, non-`this` identifier expression.
|
|
208
|
+
//
|
|
209
|
+
// Historically _receiverTypeFor carried NAME-based fallbacks (a
|
|
210
|
+
// PascalCase-of-`this.field` guess and a bare-identifier-name fallback) that
|
|
211
|
+
// this function was careful never to reuse, because a same-named
|
|
212
|
+
// parameter/variable/field (e.g. a duck-typed
|
|
213
|
+
// `function process(Model, data) { Model.save(data); }`) would resolve to an
|
|
214
|
+
// unrelated real class purely by name coincidence. Whole-branch review found
|
|
215
|
+
// those same guesses were also wrong for R6's much softer use, so they are
|
|
216
|
+
// gone: _receiverTypeFor now bottoms out in the same classOfVar call this
|
|
217
|
+
// function makes. The two are deliberately kept as separate functions
|
|
218
|
+
// anyway — they take different inputs (a flattened chain vs. a member
|
|
219
|
+
// expression) and R11's extra `resolveMethod` step means it can still refuse
|
|
220
|
+
// where R6 does not.
|
|
221
|
+
function _resolveMemberCalleeViaCHA(calleeExpr, callContext) {
|
|
222
|
+
if (!calleeExpr || calleeExpr.kind !== 'member' || typeof calleeExpr.prop !== 'string') return null;
|
|
223
|
+
if (!callContext || !callContext._cha) return null;
|
|
224
|
+
if (!calleeExpr.object || calleeExpr.object.kind !== 'ident' || calleeExpr.object.name === '_this_') return null;
|
|
225
|
+
const className = classOfVar(callContext._cha, _currentFile, callContext._currentFnQid, calleeExpr.object.name);
|
|
226
|
+
if (!className) return null;
|
|
227
|
+
const found = resolveMethod(callContext._cha, className, calleeExpr.prop);
|
|
228
|
+
if (!found) return null;
|
|
229
|
+
return `${found.className}.${found.methodName}`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Resolve calleeExpr to { qid, fn } via the call graph — the shared
|
|
233
|
+
// resolve-and-lookup sequence every summary-consulting call site needs.
|
|
234
|
+
// Extracted from what were two independent, drifting copies (assign-RHS and
|
|
235
|
+
// plain-call-statement) so a future change (like PRD R11 in this same file)
|
|
236
|
+
// only has to land once. See _resolvableCalleeName's own comment for why a
|
|
237
|
+
// bare-name/pre-flattened-string callee is the ONLY case handled here for
|
|
238
|
+
// now — Task 4 (PRD R11) extends this function's body to add a second,
|
|
239
|
+
// CHA-gated resolution path for member-expression callees.
|
|
240
|
+
function _resolveCalleeForSummary(calleeExpr, callContext) {
|
|
241
|
+
if (!callContext || !callContext._callGraph || !callContext._callGraph.resolveKnownCallee) return null;
|
|
242
|
+
const _callerFile = (callContext._currentFnQid || '').split('::')[0] || undefined;
|
|
243
|
+
let _resolvableName = _resolvableCalleeName(calleeExpr);
|
|
244
|
+
// PRD R11: _resolvableCalleeName refuses every member-expression callee.
|
|
245
|
+
// When that's the reason we have nothing, try the CHA-gated path before
|
|
246
|
+
// giving up — but ONLY then, so the existing exact/bare-name behavior is
|
|
247
|
+
// completely unchanged for every case it already handled.
|
|
248
|
+
if (!_resolvableName) _resolvableName = _resolveMemberCalleeViaCHA(calleeExpr, callContext);
|
|
249
|
+
if (!_resolvableName) return null;
|
|
250
|
+
const resolved = callContext._callGraph.resolveKnownCallee(_resolvableName, _callerFile);
|
|
251
|
+
const fn = functionRecord(callContext._callGraph, resolved);
|
|
252
|
+
const qid = resolved && (resolved.qid || resolved);
|
|
253
|
+
return typeof qid === 'string' ? { qid, fn } : null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// PRD R10 (docs/DETECTION_GAP_REMEDIATION_PRD.md): the only two places that
|
|
257
|
+
// consult a callee's SummaryCache entry are the assign-RHS and plain-call-
|
|
258
|
+
// statement paths in step() below — a call nested INSIDE another expression
|
|
259
|
+
// (most commonly a sink's own argument list, `sink(getUserInput())`) reaches
|
|
260
|
+
// neither, so exprTaint's 'call' case fell back to checking only the nested
|
|
261
|
+
// call's OWN arguments, silently losing the callee's return-taint. Mirrors
|
|
262
|
+
// the same resolve -> get-or-compute -> merge sequence step()'s two existing
|
|
263
|
+
// call sites use, via the Task 3/4 shared _resolveCalleeForSummary.
|
|
264
|
+
function _nestedCallReturnTainted(calleeExpr, argExprs, state, callContext) {
|
|
265
|
+
if (!callContext || !callContext._summaryCache) return false;
|
|
266
|
+
const target = _resolveCalleeForSummary(calleeExpr, callContext);
|
|
267
|
+
if (!target) return false;
|
|
268
|
+
const { qid, fn } = target;
|
|
269
|
+
const paramNames = (fn && Array.isArray(fn.params)) ? fn.params : [];
|
|
270
|
+
const entry = paramNames.length
|
|
271
|
+
? entryStateFromCall(paramNames, argExprs || [], state)
|
|
272
|
+
: new Set();
|
|
273
|
+
let sum = callContext._summaryCache.get(qid, entry);
|
|
274
|
+
if (!sum && fn && fn.cfg) {
|
|
275
|
+
sum = callContext._summaryCache.compute(qid, entry, () => {
|
|
276
|
+
const inner = {
|
|
277
|
+
_findings: [], _taintSources: [], _returnTainted: false,
|
|
278
|
+
_stack: new Set(), deadlineMs: callContext.deadlineMs,
|
|
279
|
+
_summaryCache: callContext._summaryCache,
|
|
280
|
+
_callGraph: callContext._callGraph,
|
|
281
|
+
_mutatedParamsOut: new Set(),
|
|
282
|
+
_cha: callContext._cha,
|
|
283
|
+
};
|
|
284
|
+
try { analyzeFunction(fn, _unionAnnotationTaint(fn, entry), inner); } catch {}
|
|
285
|
+
return {
|
|
286
|
+
returnTainted: !!inner._returnTainted,
|
|
287
|
+
mutatedParams: inner._mutatedParamsOut || new Set(),
|
|
288
|
+
taintedGlobals: new Set(),
|
|
289
|
+
findings: inner._findings,
|
|
290
|
+
};
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
_mergeSummaryFindings(callContext, callContext._currentFnQid, sum, 'interproc');
|
|
294
|
+
return !!(sum && sum.returnTainted);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function exprTaint(expr, state, callContext) {
|
|
124
298
|
if (expr && (expr.kind === 'member' || expr.kind === 'call') && exprIsSource(expr)) return true;
|
|
125
299
|
if (!expr) return false;
|
|
126
300
|
// Constant propagation: variables assigned from literals are never tainted
|
|
@@ -134,16 +308,18 @@ function exprTaint(expr, state) {
|
|
|
134
308
|
switch (expr.kind) {
|
|
135
309
|
case 'literal': return false;
|
|
136
310
|
case 'binary':
|
|
137
|
-
case 'logical': return exprTaint(expr.left, state) || exprTaint(expr.right, state);
|
|
138
|
-
case 'tpl': return (expr.parts || []).some(p => exprTaint(p, state));
|
|
139
|
-
case 'union': return (expr.branches || []).some(b => exprTaint(b, state));
|
|
140
|
-
case 'object': return (expr.props || []).some(p => exprTaint(p.value, state));
|
|
141
|
-
case 'array': return (expr.elements || []).some(e => exprTaint(e, state));
|
|
311
|
+
case 'logical': return exprTaint(expr.left, state, callContext) || exprTaint(expr.right, state, callContext);
|
|
312
|
+
case 'tpl': return (expr.parts || []).some(p => exprTaint(p, state, callContext));
|
|
313
|
+
case 'union': return (expr.branches || []).some(b => exprTaint(b, state, callContext));
|
|
314
|
+
case 'object': return (expr.props || []).some(p => exprTaint(p.value, state, callContext));
|
|
315
|
+
case 'array': return (expr.elements || []).some(e => exprTaint(e, state, callContext));
|
|
142
316
|
case 'call': {
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
|
|
317
|
+
// The call's own arguments (unchanged from before) OR — PRD R10 — the
|
|
318
|
+
// resolved callee's own return-taint summary. Args-tainted is checked
|
|
319
|
+
// FIRST and short-circuits: it's the cheap, no-resolve-attempt case and
|
|
320
|
+
// was already correct.
|
|
321
|
+
if ((expr.args || []).some(a => exprTaint(a, state, callContext))) return true;
|
|
322
|
+
return _nestedCallReturnTainted(expr.callee, expr.args, state, callContext);
|
|
147
323
|
}
|
|
148
324
|
case 'unknown': return false;
|
|
149
325
|
default: return false;
|
|
@@ -264,10 +440,12 @@ function literalSkeletonMatchesFamily(expr, cwe) {
|
|
|
264
440
|
|
|
265
441
|
// calleeExpr / argExprs: the IR nodes for the call's callee and arguments.
|
|
266
442
|
// state: the taint-state Set to evaluate argument taint against.
|
|
443
|
+
// callContext: context from the engine (contains _cha for CHA lookups).
|
|
267
444
|
// Returns { cat, argTaints }.
|
|
268
|
-
function _matchCallCatalog(calleeExpr, argExprs, state) {
|
|
269
|
-
const
|
|
270
|
-
const
|
|
445
|
+
function _matchCallCatalog(calleeExpr, argExprs, state, callContext) {
|
|
446
|
+
const receiverType = _receiverTypeFor(calleeExpr, callContext);
|
|
447
|
+
const cat = matchSinkOrSanitizer(calleeExpr, _currentFile, receiverType);
|
|
448
|
+
const argTaints = (argExprs || []).map(a => exprTaint(a, state, callContext));
|
|
271
449
|
return { cat, argTaints };
|
|
272
450
|
}
|
|
273
451
|
|
|
@@ -374,6 +552,38 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
|
|
|
374
552
|
return { findings };
|
|
375
553
|
}
|
|
376
554
|
|
|
555
|
+
// PRD R13(a): finding shape for a member-write sink match (el.innerHTML =
|
|
556
|
+
// tainted). Distinct from _sinkFindingsForCall because there is no call
|
|
557
|
+
// argument list to index into — the "argument" of interest is the whole
|
|
558
|
+
// assignment RHS, which the catalog entries already mark via argIndex:'rhs'
|
|
559
|
+
// (a sentinel that existed in these 3 entries since they were added, with no
|
|
560
|
+
// consumer until now). Mirrors _sinkFindingsForCall's trace/sanitizer
|
|
561
|
+
// attribution exactly, so a member-write finding looks like any other
|
|
562
|
+
// deep-mode finding downstream.
|
|
563
|
+
function _memberWriteSinkFindings(hits, sourceExpr, state, callContext, line, targetPath) {
|
|
564
|
+
const findings = [];
|
|
565
|
+
for (const e of hits) {
|
|
566
|
+
const reachingSources = _sourcesReachingExpr(sourceExpr, state, callContext._taintSources);
|
|
567
|
+
const traceForThisFinding = reachingSources.length ? reachingSources.slice(0, 5) : [];
|
|
568
|
+
const _sanNames = _sanitizersForExpr(sourceExpr, callContext);
|
|
569
|
+
findings.push({
|
|
570
|
+
...(_sanNames.size ? { _sanitizersOnPath: [..._sanNames] } : {}),
|
|
571
|
+
kind: 'taint',
|
|
572
|
+
sinkId: e.id,
|
|
573
|
+
vuln: e.vuln?.name || 'Tainted Sink',
|
|
574
|
+
severity: e.vuln?.severity || 'high',
|
|
575
|
+
cwe: e.vuln?.cwe || null,
|
|
576
|
+
remediation: e.vuln?.remediation || null,
|
|
577
|
+
line,
|
|
578
|
+
argIndex: 'rhs',
|
|
579
|
+
callee: targetPath,
|
|
580
|
+
sourceProvenance: (traceForThisFinding[0]?.provenance) || null,
|
|
581
|
+
trace: traceForThisFinding,
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
return findings;
|
|
585
|
+
}
|
|
586
|
+
|
|
377
587
|
// Surfaces a cached (or freshly computed) summary's `findings` into the
|
|
378
588
|
// CURRENT caller's context — the only place a class-field/k=2 pre-pass's
|
|
379
589
|
// speculative findings (in runTaintEngine) become reportable, because
|
|
@@ -426,11 +636,24 @@ function step(node, stateIn, callContext) {
|
|
|
426
636
|
// return path below, including the early interprocedural returns.
|
|
427
637
|
if (node.source && node.source.kind === 'call') {
|
|
428
638
|
const { cat: _sinkCat, argTaints: _sinkArgTaints } =
|
|
429
|
-
_matchCallCatalog(node.source.callee, node.source.args, state);
|
|
639
|
+
_matchCallCatalog(node.source.callee, node.source.args, state, callContext);
|
|
430
640
|
findings.push(..._sinkFindingsForCall(
|
|
431
641
|
node.source.callee, node.source.args, _sinkCat, _sinkArgTaints,
|
|
432
642
|
state, callContext, node.line).findings);
|
|
433
643
|
}
|
|
644
|
+
// PRD R13(a): the assignment TARGET can itself be a sink shape
|
|
645
|
+
// (el.innerHTML = tainted) — additive to the RHS-call-sink check
|
|
646
|
+
// above, which only ever looked at node.source. `target` is only a
|
|
647
|
+
// dotted member-access path when the LHS was a member expression
|
|
648
|
+
// (lhsPath in parser-js.js); a bare identifier target ("x") has no
|
|
649
|
+
// dot and _matchMemberWriteSink correctly returns null for it.
|
|
650
|
+
if (target && target.includes('.')) {
|
|
651
|
+
const _memberHits = matchMemberWriteSink(target, _currentFile);
|
|
652
|
+
if (_memberHits && exprTaint(node.source, state, callContext)) {
|
|
653
|
+
findings.push(..._memberWriteSinkFindings(
|
|
654
|
+
_memberHits, node.source, state, callContext, node.line, target));
|
|
655
|
+
}
|
|
656
|
+
}
|
|
434
657
|
// Record which sanitizers were applied to the value now held by `target`
|
|
435
658
|
// (inline in the RHS, or inherited from the vars the RHS reads). Placed
|
|
436
659
|
// before every early return in this case so the map cannot go stale on
|
|
@@ -457,18 +680,10 @@ function step(node, stateIn, callContext) {
|
|
|
457
680
|
const calleeName = node.source && node.source.kind === 'call'
|
|
458
681
|
? _flattenCalleeName(node.source.callee) : null;
|
|
459
682
|
if (target && calleeName && callContext._summaryCache && callContext._callGraph) {
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
// fallback. _resolvableCalleeName already refuses JS member
|
|
465
|
-
// expressions, but a pre-flattened STRING callee (Go/PHP/Ruby/
|
|
466
|
-
// C++/Python parsers) can still be dotted, and only the resolver
|
|
467
|
-
// itself can tell — see callgraph.js.
|
|
468
|
-
const resolved = (_resolvableName && callContext._callGraph.resolveKnownCallee)
|
|
469
|
-
? callContext._callGraph.resolveKnownCallee(_resolvableName, _callerFile) : null;
|
|
470
|
-
const fn = functionRecord(callContext._callGraph, resolved);
|
|
471
|
-
const qid = resolved && (resolved.qid || resolved);
|
|
683
|
+
const _resolvedTarget = node.source && node.source.kind === 'call'
|
|
684
|
+
? _resolveCalleeForSummary(node.source.callee, callContext) : null;
|
|
685
|
+
const fn = _resolvedTarget && _resolvedTarget.fn;
|
|
686
|
+
const qid = _resolvedTarget && _resolvedTarget.qid;
|
|
472
687
|
if (typeof qid === 'string') {
|
|
473
688
|
// v0.66 — context-sensitive lookup. Build the entry-state from
|
|
474
689
|
// the call args + current taint; look up (and lazily compute) the
|
|
@@ -493,8 +708,9 @@ function step(node, stateIn, callContext) {
|
|
|
493
708
|
_summaryCache: callContext._summaryCache,
|
|
494
709
|
_callGraph: callContext._callGraph,
|
|
495
710
|
_mutatedParamsOut: new Set(),
|
|
711
|
+
_cha: callContext._cha,
|
|
496
712
|
};
|
|
497
|
-
try { analyzeFunction(fn, entry, inner); } catch {}
|
|
713
|
+
try { analyzeFunction(fn, _unionAnnotationTaint(fn, entry), inner); } catch {}
|
|
498
714
|
return {
|
|
499
715
|
returnTainted: !!inner._returnTainted,
|
|
500
716
|
mutatedParams: inner._mutatedParamsOut || new Set(),
|
|
@@ -533,16 +749,35 @@ function step(node, stateIn, callContext) {
|
|
|
533
749
|
// Fallback: check builtin summaries for unresolved external calls
|
|
534
750
|
const builtin = lookupBuiltinSummary(calleeName);
|
|
535
751
|
if (builtin) {
|
|
536
|
-
|
|
752
|
+
const _argTainted = (node.source.args || []).some(a => exprTaint(a, newState, callContext));
|
|
753
|
+
if (builtin.returnTainted && _argTainted) {
|
|
537
754
|
newState = _addPathAliasAware(newState, target, callContext);
|
|
538
755
|
} else if (!builtin.returnTainted) {
|
|
539
|
-
|
|
756
|
+
// PRD R4b: a builtin summary saying returnTainted:false can mean
|
|
757
|
+
// two very different things — a genuinely non-deriving function
|
|
758
|
+
// (crypto.randomBytes) where clearing taint is correct, or a
|
|
759
|
+
// sanitizer-shaped function (encodeURIComponent, parseInt,
|
|
760
|
+
// DOMPurify.sanitize...) that DOES receive tainted input and
|
|
761
|
+
// whose safety is family-scoped (a URL encoder does nothing for
|
|
762
|
+
// SQLi). `_sanitizersForExpr` above already recorded the latter
|
|
763
|
+
// case into `_sanitizersByVar` when this callee is ALSO a
|
|
764
|
+
// registered catalog sanitizer — defer to sanitizer-gate.js's
|
|
765
|
+
// family-aware demotion there instead of unconditionally
|
|
766
|
+
// killing every family's taint here. Only a genuinely-untainted
|
|
767
|
+
// argument, or a callee with no catalog-sanitizer registration,
|
|
768
|
+
// still clears via removePathAndDescendants.
|
|
769
|
+
const _recordedSan = target && callContext._sanitizersByVar && callContext._sanitizersByVar.get(target);
|
|
770
|
+
if (_argTainted && _recordedSan && _recordedSan.size) {
|
|
771
|
+
newState = _addPathAliasAware(newState, target, callContext);
|
|
772
|
+
} else {
|
|
773
|
+
newState = removePathAndDescendants(newState, target);
|
|
774
|
+
}
|
|
540
775
|
return { state: newState, findings };
|
|
541
776
|
}
|
|
542
777
|
if (builtin.mutatedParams && builtin.mutatedParams.size) {
|
|
543
778
|
for (const idx of builtin.mutatedParams) {
|
|
544
779
|
const argExpr = (node.source.args || [])[parseInt(idx)];
|
|
545
|
-
if (argExpr && argExpr.kind === 'ident' && (node.source.args || []).some(a => exprTaint(a, newState))) {
|
|
780
|
+
if (argExpr && argExpr.kind === 'ident' && (node.source.args || []).some(a => exprTaint(a, newState, callContext))) {
|
|
546
781
|
newState = _addPathAliasAware(newState, argExpr.name, callContext);
|
|
547
782
|
}
|
|
548
783
|
}
|
|
@@ -555,7 +790,7 @@ function step(node, stateIn, callContext) {
|
|
|
555
790
|
const sourcePath = accessPathOf(node.source);
|
|
556
791
|
if (sourcePath) newState = addPath(newState, sourcePath);
|
|
557
792
|
callContext._taintSources.push({ varName: target, sourceId: src.id, sourceLabel: src.label, provenance: src.provenance || null, line: node.line });
|
|
558
|
-
} else if (exprTaint(node.source, newState)) {
|
|
793
|
+
} else if (exprTaint(node.source, newState, callContext)) {
|
|
559
794
|
// P1.1: when the source IS a pure access path (e.g., RHS is `obj.foo.bar`),
|
|
560
795
|
// taint the TARGET as well as transitively propagate the source path so
|
|
561
796
|
// later uses of the same source remain tainted. The target path
|
|
@@ -579,20 +814,14 @@ function step(node, stateIn, callContext) {
|
|
|
579
814
|
// Computed here (before the mutation passes below) so that argTaints
|
|
580
815
|
// reflects the pre-mutation state, exactly as before this logic was
|
|
581
816
|
// extracted into _matchCallCatalog/_sinkFindingsForCall.
|
|
582
|
-
const { cat, argTaints } = _matchCallCatalog(node.callee, node.args, state);
|
|
817
|
+
const { cat, argTaints } = _matchCallCatalog(node.callee, node.args, state, callContext);
|
|
583
818
|
// v0.66 — apply mutated-param taint at plain (non-assign) call sites.
|
|
584
819
|
// Object.assign(target, tainted) → target becomes tainted in caller.
|
|
585
820
|
const _plainCallCalleeName = _flattenCalleeName(node.callee);
|
|
586
821
|
if (callContext._summaryCache && callContext._callGraph && _plainCallCalleeName) {
|
|
587
|
-
const
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
// — a pre-flattened dotted STRING callee must not be guessed via
|
|
591
|
-
// resolve()'s bare-tail fallback.
|
|
592
|
-
const resolved = (_resolvableName && callContext._callGraph.resolveKnownCallee)
|
|
593
|
-
? callContext._callGraph.resolveKnownCallee(_resolvableName, _callerFile) : null;
|
|
594
|
-
const fn = functionRecord(callContext._callGraph, resolved);
|
|
595
|
-
const qid = resolved && (resolved.qid || resolved);
|
|
822
|
+
const _resolvedTarget = _resolveCalleeForSummary(node.callee, callContext);
|
|
823
|
+
const fn = _resolvedTarget && _resolvedTarget.fn;
|
|
824
|
+
const qid = _resolvedTarget && _resolvedTarget.qid;
|
|
596
825
|
if (typeof qid === 'string' && fn && Array.isArray(fn.params)) {
|
|
597
826
|
const paramNames = fn.params;
|
|
598
827
|
const entry = paramNames.length
|
|
@@ -613,8 +842,9 @@ function step(node, stateIn, callContext) {
|
|
|
613
842
|
_summaryCache: callContext._summaryCache,
|
|
614
843
|
_callGraph: callContext._callGraph,
|
|
615
844
|
_mutatedParamsOut: new Set(),
|
|
845
|
+
_cha: callContext._cha,
|
|
616
846
|
};
|
|
617
|
-
try { analyzeFunction(fn, entry, inner); } catch {}
|
|
847
|
+
try { analyzeFunction(fn, _unionAnnotationTaint(fn, entry), inner); } catch {}
|
|
618
848
|
return {
|
|
619
849
|
returnTainted: !!inner._returnTainted,
|
|
620
850
|
mutatedParams: inner._mutatedParamsOut || new Set(),
|
|
@@ -730,7 +960,7 @@ function step(node, stateIn, callContext) {
|
|
|
730
960
|
}
|
|
731
961
|
|
|
732
962
|
case 'return': {
|
|
733
|
-
if (exprTaint(node.value, state)) {
|
|
963
|
+
if (exprTaint(node.value, state, callContext)) {
|
|
734
964
|
callContext._returnTainted = true;
|
|
735
965
|
}
|
|
736
966
|
return { state, findings };
|
|
@@ -746,6 +976,18 @@ function step(node, stateIn, callContext) {
|
|
|
746
976
|
}
|
|
747
977
|
}
|
|
748
978
|
|
|
979
|
+
// R14(a): annotation-derived taint is a function-invariant fact — identical
|
|
980
|
+
// for every call to this qid — so it is unioned into the per-call-site entry
|
|
981
|
+
// state, never into the SummaryCache key (that stays exactly what the caller
|
|
982
|
+
// supplied). Returns the ORIGINAL Set unchanged when there's nothing to add,
|
|
983
|
+
// so callers that never touch annotation-shaped params pay zero extra cost.
|
|
984
|
+
function _unionAnnotationTaint(fn, entrySet) {
|
|
985
|
+
if (!fn.paramAnnotations || !fn.paramAnnotations.length) return entrySet;
|
|
986
|
+
const extra = matchAnnotationParams(fn.paramAnnotations, fn.file);
|
|
987
|
+
if (!extra.size) return entrySet;
|
|
988
|
+
return new Set([...entrySet, ...extra]);
|
|
989
|
+
}
|
|
990
|
+
|
|
749
991
|
// Worklist traversal of one function's CFG with a given entry-taint-state.
|
|
750
992
|
// Returns the merged exit state + the union of findings on every path + the
|
|
751
993
|
// taint sources observed (for evidence trails).
|
|
@@ -812,7 +1054,7 @@ function analyzeFunction(fn, entryState, callContext) {
|
|
|
812
1054
|
try {
|
|
813
1055
|
const union = new Set();
|
|
814
1056
|
for (const s of inStates.values()) for (const p of s) union.add(p);
|
|
815
|
-
const ictx = buildImplicitContext(fn.cfg, (expr) => exprTaint(expr, union));
|
|
1057
|
+
const ictx = buildImplicitContext(fn.cfg, (expr) => exprTaint(expr, union, callContext));
|
|
816
1058
|
// Mark vars assigned inside a tainted branch as implicit-tainted.
|
|
817
1059
|
let implicitState = new Set();
|
|
818
1060
|
for (const [nid, ctx] of ictx) {
|
|
@@ -849,7 +1091,7 @@ function analyzeFunction(fn, entryState, callContext) {
|
|
|
849
1091
|
const sink = cat && cat.find((e) => e.kind === 'sink');
|
|
850
1092
|
if (!sink) continue;
|
|
851
1093
|
const inS = inStates.get(nid) || new Set();
|
|
852
|
-
if ((node.args || []).some((a) => exprTaint(a, inS))) continue;
|
|
1094
|
+
if ((node.args || []).some((a) => exprTaint(a, inS, callContext))) continue;
|
|
853
1095
|
const allConst = (node.args || []).length > 0 && (node.args || []).every((a) => a && a.kind === 'literal');
|
|
854
1096
|
if (allConst) reportImplicit(nid, node, sink, ctx.conditionLabel);
|
|
855
1097
|
}
|
|
@@ -863,7 +1105,7 @@ function analyzeFunction(fn, entryState, callContext) {
|
|
|
863
1105
|
const sink = cat && cat.find((e) => e.kind === 'sink');
|
|
864
1106
|
if (!sink) continue;
|
|
865
1107
|
const inS = inStates.get(nid) || new Set();
|
|
866
|
-
if ((node.args || []).some((a) => exprTaint(a, inS))) continue;
|
|
1108
|
+
if ((node.args || []).some((a) => exprTaint(a, inS, callContext))) continue;
|
|
867
1109
|
const argRefsImplicit = (node.args || []).some((a) => {
|
|
868
1110
|
const ap = accessPathOf(a); return ap && isCoveredBy(implicitState, `implicit:${ap}`);
|
|
869
1111
|
});
|
|
@@ -957,8 +1199,11 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
957
1199
|
_stack: new Set(), deadlineMs,
|
|
958
1200
|
_summaryCache: summaryCache, _callGraph: callGraph,
|
|
959
1201
|
_mutatedParamsOut: new Set(),
|
|
1202
|
+
_currentFnQid: fn.qid,
|
|
1203
|
+
_cha: opts._cha,
|
|
1204
|
+
_pointsTo: opts._pointsTo,
|
|
960
1205
|
};
|
|
961
|
-
try { analyzeFunction(fn, entry, ctx); } catch {}
|
|
1206
|
+
try { analyzeFunction(fn, _unionAnnotationTaint(fn, entry), ctx); } catch {}
|
|
962
1207
|
// Report real findings discovered by this probe rather than letting
|
|
963
1208
|
// them die with `ctx` — see _collectFindings's header comment. Safe to
|
|
964
1209
|
// call every iteration and again from the main loop below: dedup is by
|
|
@@ -1023,8 +1268,11 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
1023
1268
|
_stack: new Set(), deadlineMs,
|
|
1024
1269
|
_summaryCache: summaryCache, _callGraph: callGraph,
|
|
1025
1270
|
_mutatedParamsOut: new Set(),
|
|
1271
|
+
_currentFnQid: fn.qid,
|
|
1272
|
+
_cha: opts._cha,
|
|
1273
|
+
_pointsTo: opts._pointsTo,
|
|
1026
1274
|
};
|
|
1027
|
-
try { analyzeFunction(fn, fields, ctx); } catch {}
|
|
1275
|
+
try { analyzeFunction(fn, _unionAnnotationTaint(fn, fields), ctx); } catch {}
|
|
1028
1276
|
// `findings` carries the REAL findings from this probe (was hardcoded
|
|
1029
1277
|
// `[]`, discarding them) — but this pass is speculative (every field
|
|
1030
1278
|
// in `fields` is assumed simultaneously tainted; nothing here confirms
|
|
@@ -1055,8 +1303,11 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
1055
1303
|
_stack: new Set(), deadlineMs,
|
|
1056
1304
|
_summaryCache: summaryCache, _callGraph: callGraph,
|
|
1057
1305
|
_mutatedParamsOut: new Set(),
|
|
1306
|
+
_currentFnQid: fn.qid,
|
|
1307
|
+
_cha: opts._cha,
|
|
1308
|
+
_pointsTo: opts._pointsTo,
|
|
1058
1309
|
};
|
|
1059
|
-
try { analyzeFunction(fn, taintedEntry, ctx); } catch {}
|
|
1310
|
+
try { analyzeFunction(fn, _unionAnnotationTaint(fn, taintedEntry), ctx); } catch {}
|
|
1060
1311
|
// `findings` carries the real findings from this probe (was hardcoded
|
|
1061
1312
|
// `[]`). This pass assumes EVERY param is simultaneously tainted —
|
|
1062
1313
|
// there's no check that any real caller ever passes tainted data here
|
|
@@ -1093,9 +1344,20 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
1093
1344
|
deadlineMs, // honored by the worklist inside analyzeFunction
|
|
1094
1345
|
_summaryCache: summaryCache,
|
|
1095
1346
|
_callGraph: callGraph,
|
|
1347
|
+
_currentFnQid: fn.qid,
|
|
1348
|
+
// PRD R12: index.js builds this graph (AGENTIC_SECURITY_POINTS_TO=1)
|
|
1349
|
+
// and passes it in opts._pointsTo, but nothing previously copied it
|
|
1350
|
+
// onto callContext — _addPathAliasAware reads callContext._pointsTo,
|
|
1351
|
+
// which was therefore always undefined, and alias-aware tainting was
|
|
1352
|
+
// a no-op even with the flag set.
|
|
1353
|
+
_pointsTo: opts._pointsTo,
|
|
1354
|
+
// PRD R6/R11: same pattern as _pointsTo above — the CHA opts.js builds
|
|
1355
|
+
// must reach callContext or every receiver-type/member-call consumer
|
|
1356
|
+
// is permanently a no-op.
|
|
1357
|
+
_cha: opts._cha,
|
|
1096
1358
|
};
|
|
1097
1359
|
try {
|
|
1098
|
-
analyzeFunction(fn, new Set(), callContext);
|
|
1360
|
+
analyzeFunction(fn, _unionAnnotationTaint(fn, new Set()), callContext);
|
|
1099
1361
|
} catch { continue; }
|
|
1100
1362
|
// Process higher-order invocations: resolve callbacks and analyze with
|
|
1101
1363
|
// tainted first-param. Feed findings back into the caller's finding set.
|
|
@@ -1128,8 +1390,9 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
1128
1390
|
_stack: new Set(), deadlineMs,
|
|
1129
1391
|
_summaryCache: summaryCache, _callGraph: callGraph,
|
|
1130
1392
|
_mutatedParamsOut: new Set(),
|
|
1393
|
+
_cha: callContext._cha,
|
|
1131
1394
|
};
|
|
1132
|
-
try { analyzeFunction(cbFn, cbEntry, inner); } catch {}
|
|
1395
|
+
try { analyzeFunction(cbFn, _unionAnnotationTaint(cbFn, cbEntry), inner); } catch {}
|
|
1133
1396
|
return {
|
|
1134
1397
|
returnTainted: !!inner._returnTainted,
|
|
1135
1398
|
mutatedParams: inner._mutatedParamsOut || new Set(),
|
|
@@ -1151,14 +1414,14 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
1151
1414
|
// Dead code suppression: demote findings in functions with zero callers
|
|
1152
1415
|
// (except route handlers which are entry points)
|
|
1153
1416
|
const calledQids = new Set();
|
|
1154
|
-
if (callGraph.edges) for (const e of callGraph.edges) calledQids.add(typeof e.
|
|
1155
|
-
if (callGraph.callersOf) for (const [qid, callers] of callGraph.callersOf) { if (callers
|
|
1417
|
+
if (callGraph.edges) for (const e of callGraph.edges) if (e.callee) calledQids.add(typeof e.callee === 'string' ? e.callee : e.callee?.qid);
|
|
1418
|
+
if (callGraph.callersOf) for (const [qid, callers] of callGraph.callersOf) { if (Array.isArray(callers) ? callers.length : callers?.size) calledQids.add(qid); }
|
|
1156
1419
|
for (const f of all) {
|
|
1157
1420
|
if (!f._funcQid) continue;
|
|
1158
1421
|
const fn = callGraph.functions?.get(f._funcQid);
|
|
1159
1422
|
if (!fn) continue;
|
|
1160
1423
|
if (calledQids.has(f._funcQid)) continue;
|
|
1161
|
-
if (/handler|route|controller|middleware|endpoint/i.test(fn.name || '')) continue;
|
|
1424
|
+
if (fn.name === '<module>' || /handler|route|controller|middleware|endpoint/i.test(fn.name || '')) continue;
|
|
1162
1425
|
f._inDeadCode = true;
|
|
1163
1426
|
const dg = { critical: 'high', high: 'medium', medium: 'low', low: 'info' };
|
|
1164
1427
|
if (dg[f.severity]) f.severity = dg[f.severity];
|
package/src/dataflow/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
serializeSummaries, commitIncrementalState,
|
|
13
13
|
} from './incremental.js';
|
|
14
14
|
import { buildPointsTo } from './points-to.js';
|
|
15
|
+
import { buildClassHierarchy } from '../ir/class-hierarchy.js';
|
|
15
16
|
import { annotateSoftTaint } from './soft-taint.js';
|
|
16
17
|
import { runIfdsTaintEngine } from './ifds.js';
|
|
17
18
|
import { proveExploits } from './exploit-prover.js';
|
|
@@ -79,6 +80,19 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
|
|
|
79
80
|
priorState = null;
|
|
80
81
|
}
|
|
81
82
|
}
|
|
83
|
+
// PRD R6/R11 (docs/DETECTION_GAP_REMEDIATION_PRD.md): Class Hierarchy
|
|
84
|
+
// Analysis. Built once per scan (mirrors the points-to graph immediately
|
|
85
|
+
// below) and threaded through opts so the taint engine can (a) narrow
|
|
86
|
+
// catalog sink matches to the receiver's inferred type (R6) and (b) safely
|
|
87
|
+
// resolve a member call to a concrete callee when the receiver resolves to
|
|
88
|
+
// exactly one known class (R11). Unlike points-to, this is NOT gated behind
|
|
89
|
+
// an env flag — receiver-context.js and class-hierarchy.js were both
|
|
90
|
+
// already built, unit-tested, and left completely unreachable from the
|
|
91
|
+
// production pipeline (see PRD R6's evidence); building it always is cheap
|
|
92
|
+
// (a single walk of the already-parsed IR, no fixed-point iteration) and
|
|
93
|
+
// every consumer degrades to today's behavior when it finds no useful type.
|
|
94
|
+
let classHierarchy = null;
|
|
95
|
+
try { classHierarchy = buildClassHierarchy(perFileIR); } catch { classHierarchy = null; }
|
|
82
96
|
// v0.70 #2 — Steensgaard points-to / alias analysis. Built once before
|
|
83
97
|
// the worklist, passed via opts so the engine can resolve aliased
|
|
84
98
|
// mutations (`let a = obj; a.x = tainted; sink(obj.x)`).
|
|
@@ -95,6 +109,7 @@ export function runDeepAnalysis(perFileIR, callGraph, opts = {}) {
|
|
|
95
109
|
...opts,
|
|
96
110
|
summaryCache: preSeededCache || undefined,
|
|
97
111
|
_pointsTo: pointsToGraph || undefined,
|
|
112
|
+
_cha: classHierarchy || undefined,
|
|
98
113
|
});
|
|
99
114
|
if (process.env.AGENTIC_SECURITY_IFDS === '1') {
|
|
100
115
|
try {
|
|
@@ -330,14 +330,27 @@ export function aliasesForVar(pointsTo, qid, varName) {
|
|
|
330
330
|
const fullName = `${qid}::${varName}`;
|
|
331
331
|
const aliases = pointsTo.aliasesOf(fullName);
|
|
332
332
|
const out = new Set([varName]);
|
|
333
|
+
// PRD R12 (docs/DETECTION_GAP_REMEDIATION_PRD.md): a real function qid is
|
|
334
|
+
// itself multi-segment (`file::scope::name@line`) and contains `::`, so
|
|
335
|
+
// stripping up to the FIRST `::` truncates mid-qid instead of removing
|
|
336
|
+
// it — `app.js::<module>::anon@5::obj` became `<module>::anon@5::obj`,
|
|
337
|
+
// not the bare `obj` the engine's per-function taint state keys on. Strip
|
|
338
|
+
// the exact known `${qid}::` prefix first; only fall back to the
|
|
339
|
+
// first-`::` heuristic for an alias that (unusually) carries a different
|
|
340
|
+
// qid than the one being queried.
|
|
341
|
+
const prefix = `${qid}::`;
|
|
333
342
|
for (const a of aliases) {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
if (local
|
|
343
|
+
let local = null;
|
|
344
|
+
if (a.startsWith(prefix)) {
|
|
345
|
+
local = a.slice(prefix.length);
|
|
346
|
+
} else {
|
|
347
|
+
const idx = a.indexOf('::');
|
|
348
|
+
if (idx > 0) local = a.slice(idx + 2);
|
|
340
349
|
}
|
|
350
|
+
// Skip __loc: / __virt: synthetic names, and any alias that still
|
|
351
|
+
// carries a qid fragment (a cross-function alias isn't a valid path in
|
|
352
|
+
// THIS function's per-fn taint state).
|
|
353
|
+
if (local && !local.startsWith('__') && !local.includes('::')) out.add(local);
|
|
341
354
|
}
|
|
342
355
|
return [...out];
|
|
343
356
|
}
|