@clear-capabilities/agentic-security-scanner 0.136.2 → 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 +880 -0
- package/bin/agentic-security.js +189 -37
- package/dist/113.index.js +13 -4
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +5 -4
- package/dist/238.index.js +1 -1
- package/dist/317.index.js +36 -6
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +192 -15
- package/dist/444.index.js +20 -11
- package/dist/449.index.js +8 -1
- package/dist/526.index.js +3 -3
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +15 -15
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/nist-privacy-1-1.json +2 -2
- package/dist/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/package.json +21 -13
- package/src/dataflow/CLAUDE.md +12 -4
- package/src/dataflow/builtin-summaries.js +1 -1
- package/src/dataflow/catalog-expanded.js +1 -0
- package/src/dataflow/catalog.js +157 -31
- package/src/dataflow/engine.js +639 -112
- package/src/dataflow/implicit-flow.js +68 -36
- package/src/dataflow/incremental.js +18 -3
- package/src/dataflow/index.js +17 -1
- package/src/dataflow/points-to.js +19 -6
- package/src/dataflow/proven-clean.js +41 -0
- package/src/dataflow/sanitizer-gate.js +35 -9
- package/src/dataflow/sanitizer-proof.js +21 -3
- package/src/dataflow/stub-aware-filter.js +36 -13
- package/src/dataflow/summaries.js +21 -2
- package/src/engine.js +430 -196
- package/src/ir/CLAUDE.md +16 -2
- package/src/ir/balanced-call.js +55 -0
- package/src/ir/class-hierarchy.js +57 -11
- package/src/ir/index.js +14 -2
- package/src/ir/parser-cs.js +513 -40
- package/src/ir/parser-go.js +29 -11
- package/src/ir/parser-java.js +300 -20
- package/src/ir/parser-js.js +300 -22
- package/src/ir/parser-kt.js +436 -18
- package/src/ir/parser-php.js +631 -38
- package/src/ir/parser-py.helper.py +32 -2
- package/src/ir/parser-py.js +31 -4
- package/src/ir/parser-rb.js +161 -26
- package/src/ir/ssa.js +6 -1
- package/src/lsp/server.js +35 -3
- package/src/mcp/CLAUDE.md +9 -2
- package/src/mcp/redact.js +26 -0
- package/src/mcp/tools.js +164 -15
- package/src/posture/CLAUDE.md +19 -7
- package/src/posture/accuracy-scorecard.js +9 -1
- package/src/posture/aibom.js +12 -8
- package/src/posture/auditor-walkthrough.js +102 -3
- package/src/posture/autopilot.js +8 -1
- package/src/posture/calibration-drift.js +11 -5
- package/src/posture/calibration.js +24 -2
- package/src/posture/clustering.js +12 -1
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +2 -2
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/src/posture/compliance-policy.js +33 -1
- package/src/posture/confidence.js +44 -10
- package/src/posture/corpus-enroll.js +9 -5
- package/src/posture/corpus-match.js +19 -0
- package/src/posture/csharp-analysis.js +62 -3
- package/src/posture/deploy-platform.js +4 -1
- package/src/posture/drift.js +7 -1
- package/src/posture/epss.js +13 -1
- package/src/posture/evidence-bundle.js +36 -6
- package/src/posture/exploitability-probability.js +13 -1
- package/src/posture/falsification.js +23 -2
- package/src/posture/fix-metrics.js +1 -1
- package/src/posture/fix-verify-loop.js +10 -1
- package/src/posture/iac-reachability.js +14 -8
- package/src/posture/integrity.js +25 -7
- package/src/posture/model-rescan.js +65 -0
- package/src/posture/mttr.js +5 -0
- package/src/posture/poc-inprocess.js +27 -8
- package/src/posture/regression-test-gen.js +23 -8
- package/src/posture/reverse-blast-radius.js +5 -1
- package/src/posture/risk-dollars.js +18 -1
- package/src/posture/sbom.js +2 -2
- package/src/posture/secret-history.js +20 -11
- package/src/posture/security-trend.js +7 -1
- package/src/posture/stack-playbook.js +22 -1
- package/src/posture/threat-model-grounding.js +2 -2
- package/src/posture/validator-metrics.js +10 -3
- package/src/posture/verifier.js +32 -57
- package/src/report/index.js +183 -14
- package/src/runScan.js +1 -1
- package/src/sast/_comment-strip.js +15 -4
- package/src/sast/_secret-entropy.js +1 -1
- package/src/sast/authz.js +6 -4
- package/src/sast/bench-shape/index.js +2 -7
- package/src/sast/claude-md-prompt-injection.js +14 -3
- package/src/sast/cloud-iam.js +60 -7
- package/src/sast/cpp-bench-extras.js +1 -1
- package/src/sast/csrf.js +7 -5
- package/src/sast/env-hygiene.js +5 -2
- package/src/sast/iac-terraform.js +25 -0
- package/src/sast/java-bench-extras.js +1 -1
- package/src/sast/java-constant-fold.js +5 -5
- package/src/sast/llm-owasp.js +4 -2
- package/src/sast/mcp-audit.js +7 -0
- package/src/sast/pipeline.js +8 -0
- package/src/sast/prompt-template.js +8 -6
- package/src/sast/prototype-pollution.js +6 -2
- package/src/sast/redos-nfa.js +6 -6
- package/src/sast/secret-concat.js +13 -2
- package/src/sast/ssrf-cloud-metadata.js +6 -3
- package/src/sast/xss-reflected-multilang.js +1 -1
- package/src/sast/xxe.js +1 -1
- package/src/sca/CLAUDE.md +3 -4
- package/src/sca/container.js +35 -3
- package/src/sca/dep-confusion.js +7 -0
- package/src/sca/sarif-ingest.js +0 -187
package/src/dataflow/engine.js
CHANGED
|
@@ -30,16 +30,19 @@
|
|
|
30
30
|
// Sinks: anywhere a CFG node calls a catalog-registered sink with a tainted
|
|
31
31
|
// argument, we emit a finding.
|
|
32
32
|
//
|
|
33
|
-
// Sanitizers:
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
33
|
+
// Sanitizers: RECORDED, but they do not kill taint in this walk.
|
|
34
|
+
// `_sanitizersForExpr` (below) collects the sanitizer callees applied to the
|
|
35
|
+
// value reaching each sink argument — inline, or inherited via
|
|
36
|
+
// `_sanitizersByVar` from the variable it reads — and stamps them on the
|
|
37
|
+
// finding as `_sanitizersOnPath`. `dataflow/sanitizer-gate.js` then labels the
|
|
38
|
+
// finding `sanitized:true` only when the sanitizer's `appliesTo` family
|
|
39
|
+
// actually covers the finding's threat class; `engine.js`'s proof gate demotes
|
|
40
|
+
// from there. Taint itself still dies only when a variable is re-assigned from
|
|
41
|
+
// a clean expression (removePathAndDescendants, below) — a mislabelled
|
|
42
|
+
// sanitizer must never silently drop a real vulnerability, so the walk never
|
|
43
|
+
// treats a sanitizer call as clearing the tainted path on its own.
|
|
44
|
+
|
|
45
|
+
import { matchSource, matchSinkOrSanitizer, matchMemberWriteSink, matchAnnotationParams } from './catalog.js';
|
|
43
46
|
import { functionRecord } from '../ir/callgraph.js';
|
|
44
47
|
import { accessPathOf, isCoveredBy, addPath, removePathAndDescendants, joinSets as joinAccessSets, setsEqual as accessSetsEqual } from './access-paths.js';
|
|
45
48
|
import { aliasesForVar } from './points-to.js';
|
|
@@ -47,6 +50,13 @@ import { higherOrderTaintFlow } from './higher-order.js';
|
|
|
47
50
|
import { SummaryCache, entryStateFromCall } from './summaries.js';
|
|
48
51
|
import { lookupBuiltinSummary } from './builtin-summaries.js';
|
|
49
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';
|
|
50
60
|
|
|
51
61
|
// v0.70 #2 — addPath that also taints every alias of the variable.
|
|
52
62
|
// When `target` is a dotted path like "a.x" and the root `a` has aliases
|
|
@@ -99,6 +109,74 @@ function _flattenCalleeName(calleeExpr) {
|
|
|
99
109
|
return null;
|
|
100
110
|
}
|
|
101
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
|
+
|
|
102
180
|
// Narrower than _flattenCalleeName: the name to hand to callGraph.resolve().
|
|
103
181
|
// Only a bare identifier call (`helper()`) — or a pre-flattened STRING, which
|
|
104
182
|
// is how the Go/PHP/Ruby/Python/C++ parsers already emit call targets —
|
|
@@ -117,7 +195,106 @@ function _resolvableCalleeName(calleeExpr) {
|
|
|
117
195
|
return null;
|
|
118
196
|
}
|
|
119
197
|
|
|
120
|
-
|
|
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) {
|
|
121
298
|
if (expr && (expr.kind === 'member' || expr.kind === 'call') && exprIsSource(expr)) return true;
|
|
122
299
|
if (!expr) return false;
|
|
123
300
|
// Constant propagation: variables assigned from literals are never tainted
|
|
@@ -131,16 +308,18 @@ function exprTaint(expr, state) {
|
|
|
131
308
|
switch (expr.kind) {
|
|
132
309
|
case 'literal': return false;
|
|
133
310
|
case 'binary':
|
|
134
|
-
case 'logical': return exprTaint(expr.left, state) || exprTaint(expr.right, state);
|
|
135
|
-
case 'tpl': return (expr.parts || []).some(p => exprTaint(p, state));
|
|
136
|
-
case 'union': return (expr.branches || []).some(b => exprTaint(b, state));
|
|
137
|
-
case 'object': return (expr.props || []).some(p => exprTaint(p.value, state));
|
|
138
|
-
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));
|
|
139
316
|
case 'call': {
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
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);
|
|
144
323
|
}
|
|
145
324
|
case 'unknown': return false;
|
|
146
325
|
default: return false;
|
|
@@ -261,13 +440,62 @@ function literalSkeletonMatchesFamily(expr, cwe) {
|
|
|
261
440
|
|
|
262
441
|
// calleeExpr / argExprs: the IR nodes for the call's callee and arguments.
|
|
263
442
|
// state: the taint-state Set to evaluate argument taint against.
|
|
443
|
+
// callContext: context from the engine (contains _cha for CHA lookups).
|
|
264
444
|
// Returns { cat, argTaints }.
|
|
265
|
-
function _matchCallCatalog(calleeExpr, argExprs, state) {
|
|
266
|
-
const
|
|
267
|
-
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));
|
|
268
449
|
return { cat, argTaints };
|
|
269
450
|
}
|
|
270
451
|
|
|
452
|
+
// Sanitizer callees observed on an expression.
|
|
453
|
+
//
|
|
454
|
+
// The engine deliberately does NOT let a sanitizer kill taint. A blanket
|
|
455
|
+
// "any sanitizer clears the flow" rule scores well on benchmarks and silently
|
|
456
|
+
// drops a real SQL injection whenever the code applied an HTML escaper — the
|
|
457
|
+
// C/C++ catalog work already found strncpy/snprintf tagged effect:'strip' when
|
|
458
|
+
// they bound length rather than sanitising content. So the walk RECORDS which
|
|
459
|
+
// sanitizers touched the value and hands them to sanitizer-gate.js, which
|
|
460
|
+
// labels the finding, and the proof gate demotes it. Recall-preserving, same
|
|
461
|
+
// precedent as falsification.js / proof-gate.js: never removed, never
|
|
462
|
+
// severity-touched.
|
|
463
|
+
function _sanitizersInExprTree(expr, out) {
|
|
464
|
+
if (!expr || typeof expr !== 'object') return;
|
|
465
|
+
if (expr.kind === 'call') {
|
|
466
|
+
const cat = matchSinkOrSanitizer(expr.callee, _currentFile);
|
|
467
|
+
if (cat) {
|
|
468
|
+
for (const e of cat) {
|
|
469
|
+
if (e.kind === 'sanitizer' && e.match && e.match.callee) out.add(e.match.callee);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
for (const k of ['left', 'right', 'callee', 'object', 'property', 'value']) {
|
|
474
|
+
if (expr[k] && typeof expr[k] === 'object') _sanitizersInExprTree(expr[k], out);
|
|
475
|
+
}
|
|
476
|
+
for (const k of ['args', 'parts', 'branches', 'elements']) {
|
|
477
|
+
if (Array.isArray(expr[k])) for (const e of expr[k]) _sanitizersInExprTree(e, out);
|
|
478
|
+
}
|
|
479
|
+
if (Array.isArray(expr.props)) for (const p of expr.props) _sanitizersInExprTree(p && p.value, out);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Sanitizers applied to `expr`: those called inline within it, plus those
|
|
483
|
+
// recorded against any variable it reads (`const safe = escapeHtml(x); sink(safe)`).
|
|
484
|
+
function _sanitizersForExpr(expr, callContext) {
|
|
485
|
+
const out = new Set();
|
|
486
|
+
_sanitizersInExprTree(expr, out);
|
|
487
|
+
const byVar = callContext && callContext._sanitizersByVar;
|
|
488
|
+
if (byVar && byVar.size) {
|
|
489
|
+
const vars = new Set();
|
|
490
|
+
_collectExprVars(expr, vars);
|
|
491
|
+
for (const v of vars) {
|
|
492
|
+
const s = byVar.get(v);
|
|
493
|
+
if (s) for (const n of s) out.add(n);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return out;
|
|
497
|
+
}
|
|
498
|
+
|
|
271
499
|
// cat / argTaints: the result of _matchCallCatalog (computed by the caller,
|
|
272
500
|
// at whatever point in its case is appropriate for its own state-mutation
|
|
273
501
|
// ordering).
|
|
@@ -299,7 +527,13 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
|
|
|
299
527
|
? reachingSources.slice(0, 5)
|
|
300
528
|
// Fallback: better to surface "no precise source" than the wrong source.
|
|
301
529
|
: [];
|
|
530
|
+
// Sanitizers seen on the value reaching THIS argument. Consumed by
|
|
531
|
+
// sanitizer-gate.js, which labels only when the sanitizer's family
|
|
532
|
+
// covers the finding's threat class — an xss escaper on a SQL sink
|
|
533
|
+
// must not read as sanitised.
|
|
534
|
+
const _sanNames = _sanitizersForExpr(taintedArgExpr, callContext);
|
|
302
535
|
findings.push({
|
|
536
|
+
...(_sanNames.size ? { _sanitizersOnPath: [..._sanNames] } : {}),
|
|
303
537
|
kind: 'taint',
|
|
304
538
|
sinkId: e.id,
|
|
305
539
|
vuln: e.vuln?.name || 'Tainted Sink',
|
|
@@ -318,9 +552,68 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
|
|
|
318
552
|
return { findings };
|
|
319
553
|
}
|
|
320
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
|
+
|
|
587
|
+
// Surfaces a cached (or freshly computed) summary's `findings` into the
|
|
588
|
+
// CURRENT caller's context — the only place a class-field/k=2 pre-pass's
|
|
589
|
+
// speculative findings (in runTaintEngine) become reportable, because
|
|
590
|
+
// reaching this point means a real call site actually consulted that exact
|
|
591
|
+
// qid+entry. Called uniformly on both a cache HIT (`summaryCache.get()`)
|
|
592
|
+
// and a cache MISS (`summaryCache.compute()`'s return value), so it doesn't
|
|
593
|
+
// matter whether this call site is the first one to ever reach this
|
|
594
|
+
// qid+entry or the fifth — findings ride on the summary object itself now,
|
|
595
|
+
// not on a one-shot merge inside compute()'s callback. Module-level (not
|
|
596
|
+
// nested in runTaintEngine) because step()'s assign/plain-call interproc
|
|
597
|
+
// branches call it too, and step() is a top-level function with no access
|
|
598
|
+
// to runTaintEngine's locals.
|
|
599
|
+
function _mergeSummaryFindings(callContext, callerQid, sum, via) {
|
|
600
|
+
if (!sum || !Array.isArray(sum.findings) || !sum.findings.length) return;
|
|
601
|
+
callContext._findings.push(...sum.findings.map(f => ({ ...f, _funcQid: callerQid || null, _via: via })));
|
|
602
|
+
}
|
|
603
|
+
|
|
321
604
|
// Apply a CFG node to a taint-state. Returns the new state + any finding emitted.
|
|
322
605
|
function step(node, stateIn, callContext) {
|
|
323
|
-
const
|
|
606
|
+
// `let`, not `const` — the 'call' case (built-in-mutation and mutated-param
|
|
607
|
+
// branches below) reassigns this binding. It was `const` until Stage 3 of
|
|
608
|
+
// the correctness audit: a bare-statement call to Object.assign/_.merge/
|
|
609
|
+
// etc. with a tainted source arg, or any plain call whose callee summary
|
|
610
|
+
// reports mutated params, threw "Assignment to constant variable" here.
|
|
611
|
+
// The engine's per-function analyzeFunction() call sites all wrap in a
|
|
612
|
+
// blanket try/catch, so the exception was silent — and it discarded every
|
|
613
|
+
// finding already collected for the ENTIRE containing function, not just
|
|
614
|
+
// the mutation site, since the throw unwound past `findings.push(...)`
|
|
615
|
+
// calls for unrelated sinks earlier in the same function body.
|
|
616
|
+
let state = new Set(stateIn);
|
|
324
617
|
const findings = [];
|
|
325
618
|
|
|
326
619
|
switch (node.kind) {
|
|
@@ -343,11 +636,36 @@ function step(node, stateIn, callContext) {
|
|
|
343
636
|
// return path below, including the early interprocedural returns.
|
|
344
637
|
if (node.source && node.source.kind === 'call') {
|
|
345
638
|
const { cat: _sinkCat, argTaints: _sinkArgTaints } =
|
|
346
|
-
_matchCallCatalog(node.source.callee, node.source.args, state);
|
|
639
|
+
_matchCallCatalog(node.source.callee, node.source.args, state, callContext);
|
|
347
640
|
findings.push(..._sinkFindingsForCall(
|
|
348
641
|
node.source.callee, node.source.args, _sinkCat, _sinkArgTaints,
|
|
349
642
|
state, callContext, node.line).findings);
|
|
350
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
|
+
}
|
|
657
|
+
// Record which sanitizers were applied to the value now held by `target`
|
|
658
|
+
// (inline in the RHS, or inherited from the vars the RHS reads). Placed
|
|
659
|
+
// before every early return in this case so the map cannot go stale on
|
|
660
|
+
// the interprocedural paths below. A clean RHS clears the entry, mirroring
|
|
661
|
+
// removePathAndDescendants — a stale sanitizer would label a later,
|
|
662
|
+
// genuinely unsanitized flow.
|
|
663
|
+
if (target) {
|
|
664
|
+
const _san = _sanitizersForExpr(node.source, callContext);
|
|
665
|
+
const _byVar = (callContext._sanitizersByVar ||= new Map());
|
|
666
|
+
if (_san.size) _byVar.set(target, _san);
|
|
667
|
+
else _byVar.delete(target);
|
|
668
|
+
}
|
|
351
669
|
// Constant propagation: track variables assigned from literals
|
|
352
670
|
if (target && _activeConstantVars) {
|
|
353
671
|
if (node.source && node.source.kind === 'literal') _activeConstantVars.set(target, node.source.value);
|
|
@@ -362,18 +680,10 @@ function step(node, stateIn, callContext) {
|
|
|
362
680
|
const calleeName = node.source && node.source.kind === 'call'
|
|
363
681
|
? _flattenCalleeName(node.source.callee) : null;
|
|
364
682
|
if (target && calleeName && callContext._summaryCache && callContext._callGraph) {
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
// fallback. _resolvableCalleeName already refuses JS member
|
|
370
|
-
// expressions, but a pre-flattened STRING callee (Go/PHP/Ruby/
|
|
371
|
-
// C++/Python parsers) can still be dotted, and only the resolver
|
|
372
|
-
// itself can tell — see callgraph.js.
|
|
373
|
-
const resolved = (_resolvableName && callContext._callGraph.resolveKnownCallee)
|
|
374
|
-
? callContext._callGraph.resolveKnownCallee(_resolvableName, _callerFile) : null;
|
|
375
|
-
const fn = functionRecord(callContext._callGraph, resolved);
|
|
376
|
-
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;
|
|
377
687
|
if (typeof qid === 'string') {
|
|
378
688
|
// v0.66 — context-sensitive lookup. Build the entry-state from
|
|
379
689
|
// the call args + current taint; look up (and lazily compute) the
|
|
@@ -398,16 +708,26 @@ function step(node, stateIn, callContext) {
|
|
|
398
708
|
_summaryCache: callContext._summaryCache,
|
|
399
709
|
_callGraph: callContext._callGraph,
|
|
400
710
|
_mutatedParamsOut: new Set(),
|
|
711
|
+
_cha: callContext._cha,
|
|
401
712
|
};
|
|
402
|
-
try { analyzeFunction(fn, entry, inner); } catch {}
|
|
713
|
+
try { analyzeFunction(fn, _unionAnnotationTaint(fn, entry), inner); } catch {}
|
|
403
714
|
return {
|
|
404
715
|
returnTainted: !!inner._returnTainted,
|
|
405
716
|
mutatedParams: inner._mutatedParamsOut || new Set(),
|
|
406
717
|
taintedGlobals: new Set(),
|
|
407
|
-
findings
|
|
718
|
+
// Real findings from the callee's own body — e.g.
|
|
719
|
+
// `function makeQuery(id){ db.query(...id) } ... makeQuery(uid)`
|
|
720
|
+
// — ride on the summary itself (was hardcoded `[]`, so
|
|
721
|
+
// nothing ever read inner._findings and the SQLi inside
|
|
722
|
+
// makeQuery was silently dropped). _mergeSummaryFindings
|
|
723
|
+
// below surfaces them into THIS caller now that a real
|
|
724
|
+
// call site has been established, and does the same on a
|
|
725
|
+
// future cache hit from any other real caller.
|
|
726
|
+
findings: inner._findings,
|
|
408
727
|
};
|
|
409
728
|
});
|
|
410
729
|
}
|
|
730
|
+
_mergeSummaryFindings(callContext, callContext._currentFnQid, sum, 'interproc');
|
|
411
731
|
if (sum && sum.returnTainted) {
|
|
412
732
|
newState = _addPathAliasAware(newState, target, callContext);
|
|
413
733
|
callContext._taintSources.push({
|
|
@@ -429,16 +749,35 @@ function step(node, stateIn, callContext) {
|
|
|
429
749
|
// Fallback: check builtin summaries for unresolved external calls
|
|
430
750
|
const builtin = lookupBuiltinSummary(calleeName);
|
|
431
751
|
if (builtin) {
|
|
432
|
-
|
|
752
|
+
const _argTainted = (node.source.args || []).some(a => exprTaint(a, newState, callContext));
|
|
753
|
+
if (builtin.returnTainted && _argTainted) {
|
|
433
754
|
newState = _addPathAliasAware(newState, target, callContext);
|
|
434
755
|
} else if (!builtin.returnTainted) {
|
|
435
|
-
|
|
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
|
+
}
|
|
436
775
|
return { state: newState, findings };
|
|
437
776
|
}
|
|
438
777
|
if (builtin.mutatedParams && builtin.mutatedParams.size) {
|
|
439
778
|
for (const idx of builtin.mutatedParams) {
|
|
440
779
|
const argExpr = (node.source.args || [])[parseInt(idx)];
|
|
441
|
-
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))) {
|
|
442
781
|
newState = _addPathAliasAware(newState, argExpr.name, callContext);
|
|
443
782
|
}
|
|
444
783
|
}
|
|
@@ -451,7 +790,7 @@ function step(node, stateIn, callContext) {
|
|
|
451
790
|
const sourcePath = accessPathOf(node.source);
|
|
452
791
|
if (sourcePath) newState = addPath(newState, sourcePath);
|
|
453
792
|
callContext._taintSources.push({ varName: target, sourceId: src.id, sourceLabel: src.label, provenance: src.provenance || null, line: node.line });
|
|
454
|
-
} else if (exprTaint(node.source, newState)) {
|
|
793
|
+
} else if (exprTaint(node.source, newState, callContext)) {
|
|
455
794
|
// P1.1: when the source IS a pure access path (e.g., RHS is `obj.foo.bar`),
|
|
456
795
|
// taint the TARGET as well as transitively propagate the source path so
|
|
457
796
|
// later uses of the same source remain tainted. The target path
|
|
@@ -475,20 +814,14 @@ function step(node, stateIn, callContext) {
|
|
|
475
814
|
// Computed here (before the mutation passes below) so that argTaints
|
|
476
815
|
// reflects the pre-mutation state, exactly as before this logic was
|
|
477
816
|
// extracted into _matchCallCatalog/_sinkFindingsForCall.
|
|
478
|
-
const { cat, argTaints } = _matchCallCatalog(node.callee, node.args, state);
|
|
817
|
+
const { cat, argTaints } = _matchCallCatalog(node.callee, node.args, state, callContext);
|
|
479
818
|
// v0.66 — apply mutated-param taint at plain (non-assign) call sites.
|
|
480
819
|
// Object.assign(target, tainted) → target becomes tainted in caller.
|
|
481
820
|
const _plainCallCalleeName = _flattenCalleeName(node.callee);
|
|
482
821
|
if (callContext._summaryCache && callContext._callGraph && _plainCallCalleeName) {
|
|
483
|
-
const
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
// — a pre-flattened dotted STRING callee must not be guessed via
|
|
487
|
-
// resolve()'s bare-tail fallback.
|
|
488
|
-
const resolved = (_resolvableName && callContext._callGraph.resolveKnownCallee)
|
|
489
|
-
? callContext._callGraph.resolveKnownCallee(_resolvableName, _callerFile) : null;
|
|
490
|
-
const fn = functionRecord(callContext._callGraph, resolved);
|
|
491
|
-
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;
|
|
492
825
|
if (typeof qid === 'string' && fn && Array.isArray(fn.params)) {
|
|
493
826
|
const paramNames = fn.params;
|
|
494
827
|
const entry = paramNames.length
|
|
@@ -509,16 +842,21 @@ function step(node, stateIn, callContext) {
|
|
|
509
842
|
_summaryCache: callContext._summaryCache,
|
|
510
843
|
_callGraph: callContext._callGraph,
|
|
511
844
|
_mutatedParamsOut: new Set(),
|
|
845
|
+
_cha: callContext._cha,
|
|
512
846
|
};
|
|
513
|
-
try { analyzeFunction(fn, entry, inner); } catch {}
|
|
847
|
+
try { analyzeFunction(fn, _unionAnnotationTaint(fn, entry), inner); } catch {}
|
|
514
848
|
return {
|
|
515
849
|
returnTainted: !!inner._returnTainted,
|
|
516
850
|
mutatedParams: inner._mutatedParamsOut || new Set(),
|
|
517
851
|
taintedGlobals: new Set(),
|
|
518
|
-
|
|
852
|
+
// See the sibling assign-call-site compute() above — same
|
|
853
|
+
// fix, same reason: this callee's own findings were
|
|
854
|
+
// computed correctly and then thrown away (hardcoded `[]`).
|
|
855
|
+
findings: inner._findings,
|
|
519
856
|
};
|
|
520
857
|
});
|
|
521
858
|
}
|
|
859
|
+
_mergeSummaryFindings(callContext, callContext._currentFnQid, sum, 'interproc');
|
|
522
860
|
if (sum && sum.mutatedParams && sum.mutatedParams.size) {
|
|
523
861
|
const mutated = callContext._summaryCache.applyAtCallSite(
|
|
524
862
|
sum, paramNames, node.args || [], state);
|
|
@@ -574,7 +912,13 @@ function step(node, stateIn, callContext) {
|
|
|
574
912
|
if (dot <= 0) return null;
|
|
575
913
|
const recv = callee.slice(0, dot);
|
|
576
914
|
const recvTainted = isCoveredBy(state, recv);
|
|
577
|
-
|
|
915
|
+
// higherOrderTaintFlow requires a flattened STRING callee (its own
|
|
916
|
+
// `typeof callee !== 'string'` guard) — passing the raw `node` here
|
|
917
|
+
// handed it a JS/TS structured callee expr ({kind:'member',...})
|
|
918
|
+
// unconditionally, which never passed that guard, so this feature
|
|
919
|
+
// was entirely dead for JS/TS (the primary catalogued language).
|
|
920
|
+
// `callee` here is `_plainCallCalleeName`, already flattened above.
|
|
921
|
+
return higherOrderTaintFlow({ ...node, callee }, recvTainted);
|
|
578
922
|
})();
|
|
579
923
|
if (hoFlow && hoFlow.taintsCallbackParam === 0) {
|
|
580
924
|
// The first arg should be the callback. If it's a plain ident or
|
|
@@ -587,7 +931,17 @@ function step(node, stateIn, callContext) {
|
|
|
587
931
|
if (cb && (cb.kind === 'ident' || cb.kind === 'function-value')) {
|
|
588
932
|
callContext._higherOrderInvocations = callContext._higherOrderInvocations || [];
|
|
589
933
|
callContext._higherOrderInvocations.push({
|
|
590
|
-
|
|
934
|
+
// A resolved-by-name callback (`arr.map(processItem)`) and an
|
|
935
|
+
// inline callback (`arr.map(x => ...)`, parser-js.js's
|
|
936
|
+
// exprOf now emits {kind:'function-value', qid}) need different
|
|
937
|
+
// resolution strategies downstream — a bare name looked up via
|
|
938
|
+
// the call graph's byNameInFile index (ambiguous/guessable) vs.
|
|
939
|
+
// an exact qid looked up directly in callGraph.functions. Kept
|
|
940
|
+
// as two fields rather than overloading `callee` with either
|
|
941
|
+
// shape, so the consumer can't accidentally hand a qid to the
|
|
942
|
+
// name-based resolver (or vice versa).
|
|
943
|
+
callee: cb.kind === 'ident' ? cb.name : null,
|
|
944
|
+
calleeQid: cb.kind === 'function-value' ? (cb.qid || null) : null,
|
|
591
945
|
paramIndex: 0,
|
|
592
946
|
taintedParam: true,
|
|
593
947
|
line: node.line,
|
|
@@ -606,7 +960,7 @@ function step(node, stateIn, callContext) {
|
|
|
606
960
|
}
|
|
607
961
|
|
|
608
962
|
case 'return': {
|
|
609
|
-
if (exprTaint(node.value, state)) {
|
|
963
|
+
if (exprTaint(node.value, state, callContext)) {
|
|
610
964
|
callContext._returnTainted = true;
|
|
611
965
|
}
|
|
612
966
|
return { state, findings };
|
|
@@ -622,6 +976,18 @@ function step(node, stateIn, callContext) {
|
|
|
622
976
|
}
|
|
623
977
|
}
|
|
624
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
|
+
|
|
625
991
|
// Worklist traversal of one function's CFG with a given entry-taint-state.
|
|
626
992
|
// Returns the merged exit state + the union of findings on every path + the
|
|
627
993
|
// taint sources observed (for evidence trails).
|
|
@@ -688,15 +1054,36 @@ function analyzeFunction(fn, entryState, callContext) {
|
|
|
688
1054
|
try {
|
|
689
1055
|
const union = new Set();
|
|
690
1056
|
for (const s of inStates.values()) for (const p of s) union.add(p);
|
|
691
|
-
const ictx = buildImplicitContext(fn.cfg, (expr) => exprTaint(expr, union));
|
|
1057
|
+
const ictx = buildImplicitContext(fn.cfg, (expr) => exprTaint(expr, union, callContext));
|
|
692
1058
|
// Mark vars assigned inside a tainted branch as implicit-tainted.
|
|
693
1059
|
let implicitState = new Set();
|
|
694
1060
|
for (const [nid, ctx] of ictx) {
|
|
695
1061
|
const t = implicitAssignTarget(nodes[nid], ctx);
|
|
696
1062
|
if (t) implicitState = markImplicitTaint(implicitState, t);
|
|
697
1063
|
}
|
|
698
|
-
//
|
|
699
|
-
//
|
|
1064
|
+
// Stage 6 correctness audit: these are two genuinely different gates,
|
|
1065
|
+
// previously conflated into one loop over `ictx`. `allConst` is a
|
|
1066
|
+
// leak from the SINK CALL'S OWN EXECUTION revealing the branch was
|
|
1067
|
+
// taken — that requires the call itself to be genuinely inside the
|
|
1068
|
+
// tainted branch (now correctly dominance-scoped by
|
|
1069
|
+
// buildImplicitContext, see its header). `argRefsImplicit` is a leak
|
|
1070
|
+
// from a VARIABLE that was implicit-tainted earlier — once a var is
|
|
1071
|
+
// marked, its taint is a normal fact about the var, not about where
|
|
1072
|
+
// it's later read; requiring the READ site to also be lexically
|
|
1073
|
+
// inside a branch would miss the canonical
|
|
1074
|
+
// `if (tainted) { p = x; } eval(p)` pattern the moment `eval(p)` is
|
|
1075
|
+
// (correctly) recognized as being outside the branch.
|
|
1076
|
+
const reportedNids = new Set();
|
|
1077
|
+
const reportImplicit = (nid, node, sink, conditionLabel) => {
|
|
1078
|
+
if (reportedNids.has(nid)) return;
|
|
1079
|
+
reportedNids.add(nid);
|
|
1080
|
+
callContext._findings.push({
|
|
1081
|
+
...createImplicitFinding(node, conditionLabel),
|
|
1082
|
+
_funcQid: fn.qid, sinkId: sink.id,
|
|
1083
|
+
cwe: (sink.vuln && sink.vuln.cwe) || 'CWE-200',
|
|
1084
|
+
});
|
|
1085
|
+
};
|
|
1086
|
+
// Pass 1 — constant-arg sink calls genuinely inside a tainted branch.
|
|
700
1087
|
for (const [nid, ctx] of ictx) {
|
|
701
1088
|
const node = nodes[nid];
|
|
702
1089
|
if (!node || node.kind !== 'call') continue;
|
|
@@ -704,17 +1091,25 @@ function analyzeFunction(fn, entryState, callContext) {
|
|
|
704
1091
|
const sink = cat && cat.find((e) => e.kind === 'sink');
|
|
705
1092
|
if (!sink) continue;
|
|
706
1093
|
const inS = inStates.get(nid) || new Set();
|
|
707
|
-
if ((node.args || []).some((a) => exprTaint(a, inS))) continue;
|
|
708
|
-
const argRefsImplicit = (node.args || []).some((a) => {
|
|
709
|
-
const ap = accessPathOf(a); return ap && isCoveredBy(implicitState, `implicit:${ap}`);
|
|
710
|
-
});
|
|
1094
|
+
if ((node.args || []).some((a) => exprTaint(a, inS, callContext))) continue;
|
|
711
1095
|
const allConst = (node.args || []).length > 0 && (node.args || []).every((a) => a && a.kind === 'literal');
|
|
712
|
-
if (
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
1096
|
+
if (allConst) reportImplicit(nid, node, sink, ctx.conditionLabel);
|
|
1097
|
+
}
|
|
1098
|
+
// Pass 2 — any sink call anywhere in the function whose argument
|
|
1099
|
+
// reads an implicit-tainted variable, regardless of whether the
|
|
1100
|
+
// call site itself is inside a branch.
|
|
1101
|
+
if (implicitState.size) {
|
|
1102
|
+
for (const [nid, node] of Object.entries(nodes)) {
|
|
1103
|
+
if (!node || node.kind !== 'call') continue;
|
|
1104
|
+
const cat = matchSinkOrSanitizer(node.callee, _currentFile);
|
|
1105
|
+
const sink = cat && cat.find((e) => e.kind === 'sink');
|
|
1106
|
+
if (!sink) continue;
|
|
1107
|
+
const inS = inStates.get(nid) || new Set();
|
|
1108
|
+
if ((node.args || []).some((a) => exprTaint(a, inS, callContext))) continue;
|
|
1109
|
+
const argRefsImplicit = (node.args || []).some((a) => {
|
|
1110
|
+
const ap = accessPathOf(a); return ap && isCoveredBy(implicitState, `implicit:${ap}`);
|
|
717
1111
|
});
|
|
1112
|
+
if (argRefsImplicit) reportImplicit(nid, node, sink, ictx.get(nid)?.conditionLabel || null);
|
|
718
1113
|
}
|
|
719
1114
|
}
|
|
720
1115
|
} catch { /* implicit flow is best-effort + opt-in */ }
|
|
@@ -785,9 +1180,13 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
785
1180
|
// unbounded blowup). v0.66 — the inner ctx now records mutatedParams
|
|
786
1181
|
// via _mutatedParamsOut so cross-function param mutation propagates.
|
|
787
1182
|
const MAX_FP_ITERS = 3;
|
|
788
|
-
let prevCacheSize = -1;
|
|
789
1183
|
for (let it = 0; it < MAX_FP_ITERS; it++) {
|
|
790
1184
|
if (Date.now() > deadlineMs) break;
|
|
1185
|
+
// Tracks whether this iteration actually changed any cached summary's
|
|
1186
|
+
// VALUE — the correct convergence signal (see below). Reset each
|
|
1187
|
+
// iteration; if nothing changed, the fixed point has been reached and
|
|
1188
|
+
// further iterations would recompute byte-identical results.
|
|
1189
|
+
let changedThisIter = false;
|
|
791
1190
|
for (const fn of fnList) {
|
|
792
1191
|
if (Date.now() > deadlineMs) break;
|
|
793
1192
|
const entry = new Set();
|
|
@@ -800,22 +1199,49 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
800
1199
|
_stack: new Set(), deadlineMs,
|
|
801
1200
|
_summaryCache: summaryCache, _callGraph: callGraph,
|
|
802
1201
|
_mutatedParamsOut: new Set(),
|
|
1202
|
+
_currentFnQid: fn.qid,
|
|
1203
|
+
_cha: opts._cha,
|
|
1204
|
+
_pointsTo: opts._pointsTo,
|
|
803
1205
|
};
|
|
804
|
-
try { analyzeFunction(fn, entry, ctx); } catch {}
|
|
1206
|
+
try { analyzeFunction(fn, _unionAnnotationTaint(fn, entry), ctx); } catch {}
|
|
1207
|
+
// Report real findings discovered by this probe rather than letting
|
|
1208
|
+
// them die with `ctx` — see _collectFindings's header comment. Safe to
|
|
1209
|
+
// call every iteration and again from the main loop below: dedup is by
|
|
1210
|
+
// (sinkId, file, line), so re-discovering the same empty-entry finding
|
|
1211
|
+
// multiple times collapses to one reported finding, never a duplicate.
|
|
1212
|
+
_collectFindings(fn, ctx._findings);
|
|
805
1213
|
const next = {
|
|
806
1214
|
returnTainted: !!ctx._returnTainted,
|
|
807
1215
|
mutatedParams: ctx._mutatedParamsOut || new Set(),
|
|
808
1216
|
taintedGlobals: new Set(),
|
|
809
1217
|
findings: [],
|
|
810
1218
|
};
|
|
1219
|
+
// Membership-aware, not size-only — two summaries with the same
|
|
1220
|
+
// mutatedParams CARDINALITY but different MEMBERS (e.g. {'a'} vs
|
|
1221
|
+
// {'b'}) were treated as unchanged, so a real refinement across
|
|
1222
|
+
// iterations (a callee's mutated-field identity settling once its own
|
|
1223
|
+
// callees' summaries became known) was silently never written to the
|
|
1224
|
+
// cache. Same bug class as summaries.js's _summaryEq, fixed alongside
|
|
1225
|
+
// it. Reuses the same access-path-aware setsEqual already imported
|
|
1226
|
+
// for taint-state comparison elsewhere in this file (mutatedParams
|
|
1227
|
+
// entries are access paths too, e.g. '_this_.field').
|
|
811
1228
|
if (!existing
|
|
812
1229
|
|| existing.returnTainted !== next.returnTainted
|
|
813
|
-
|| (existing.mutatedParams
|
|
1230
|
+
|| !accessSetsEqual(existing.mutatedParams, next.mutatedParams)) {
|
|
814
1231
|
summaryCache.set(fn.qid, entry, next);
|
|
1232
|
+
changedThisIter = true;
|
|
815
1233
|
}
|
|
816
1234
|
}
|
|
817
|
-
|
|
818
|
-
|
|
1235
|
+
// NOT `summaryCache.size() === prevCacheSize` (the pre-fix check): every
|
|
1236
|
+
// function gets a cache key on iteration 0 (`!existing` is true for all
|
|
1237
|
+
// of them), so `.size()` — a KEY COUNT — jumps from 0 to N once and then
|
|
1238
|
+
// never changes again, since overwriting an existing Map key never
|
|
1239
|
+
// changes `.size`. That made the loop `break` after iteration 1
|
|
1240
|
+
// regardless of whether iteration 1 itself found real refinements to
|
|
1241
|
+
// write, silently delivering 2 rounds of fixed-point refinement instead
|
|
1242
|
+
// of the MAX_FP_ITERS=3 this code and dataflow/CLAUDE.md both promise.
|
|
1243
|
+
// `changedThisIter` tracks actual value changes instead.
|
|
1244
|
+
if (!changedThisIter) break;
|
|
819
1245
|
}
|
|
820
1246
|
// Class-field cross-taint pass: when a method writes tainted data to _this_.field,
|
|
821
1247
|
// re-analyze other methods of the same class with those fields in the entry state.
|
|
@@ -842,13 +1268,24 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
842
1268
|
_stack: new Set(), deadlineMs,
|
|
843
1269
|
_summaryCache: summaryCache, _callGraph: callGraph,
|
|
844
1270
|
_mutatedParamsOut: new Set(),
|
|
1271
|
+
_currentFnQid: fn.qid,
|
|
1272
|
+
_cha: opts._cha,
|
|
1273
|
+
_pointsTo: opts._pointsTo,
|
|
845
1274
|
};
|
|
846
|
-
try { analyzeFunction(fn, fields, ctx); } catch {}
|
|
1275
|
+
try { analyzeFunction(fn, _unionAnnotationTaint(fn, fields), ctx); } catch {}
|
|
1276
|
+
// `findings` carries the REAL findings from this probe (was hardcoded
|
|
1277
|
+
// `[]`, discarding them) — but this pass is speculative (every field
|
|
1278
|
+
// in `fields` is assumed simultaneously tainted; nothing here confirms
|
|
1279
|
+
// this exact method is ever reached with that state), so it must not
|
|
1280
|
+
// report them itself. They ride on the cached summary and are only
|
|
1281
|
+
// surfaced by _mergeSummaryFindings when a REAL call site (assign,
|
|
1282
|
+
// plain-call, or higher-order) actually consults this qid+entry —
|
|
1283
|
+
// at that point a genuine reachable caller has been established.
|
|
847
1284
|
summaryCache.set(fn.qid, fields, {
|
|
848
1285
|
returnTainted: !!ctx._returnTainted,
|
|
849
1286
|
mutatedParams: ctx._mutatedParamsOut || new Set(),
|
|
850
1287
|
taintedGlobals: new Set(),
|
|
851
|
-
findings:
|
|
1288
|
+
findings: ctx._findings,
|
|
852
1289
|
});
|
|
853
1290
|
}
|
|
854
1291
|
}
|
|
@@ -866,13 +1303,32 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
866
1303
|
_stack: new Set(), deadlineMs,
|
|
867
1304
|
_summaryCache: summaryCache, _callGraph: callGraph,
|
|
868
1305
|
_mutatedParamsOut: new Set(),
|
|
1306
|
+
_currentFnQid: fn.qid,
|
|
1307
|
+
_cha: opts._cha,
|
|
1308
|
+
_pointsTo: opts._pointsTo,
|
|
869
1309
|
};
|
|
870
|
-
try { analyzeFunction(fn, taintedEntry, ctx); } catch {}
|
|
1310
|
+
try { analyzeFunction(fn, _unionAnnotationTaint(fn, taintedEntry), ctx); } catch {}
|
|
1311
|
+
// `findings` carries the real findings from this probe (was hardcoded
|
|
1312
|
+
// `[]`). This pass assumes EVERY param is simultaneously tainted —
|
|
1313
|
+
// there's no check that any real caller ever passes tainted data here
|
|
1314
|
+
// at all (the header comment above claims "AND at least one caller in
|
|
1315
|
+
// the call graph"; the code has never actually enforced that) — so
|
|
1316
|
+
// these findings must not be reported unconditionally, only when a real
|
|
1317
|
+
// call site's own entry state happens to match and consults this cached
|
|
1318
|
+
// summary via _mergeSummaryFindings. This is the exact scenario that
|
|
1319
|
+
// motivated storing them at all: an inline callback
|
|
1320
|
+
// (`arr.forEach(x => sink(x))`) has one param, so it gets probed here
|
|
1321
|
+
// with taintedEntry={param} BEFORE the higher-order invocation loop
|
|
1322
|
+
// below ever runs; without `findings` riding on the cached summary, the
|
|
1323
|
+
// real finding computed right here was thrown away and unrecoverable —
|
|
1324
|
+
// the higher-order loop's own `summaryCache.get()` would hit this
|
|
1325
|
+
// now-cached (finding-less) summary and never call `compute()` (the
|
|
1326
|
+
// only place that used to merge findings) at all.
|
|
871
1327
|
summaryCache.set(fn.qid, taintedEntry, {
|
|
872
1328
|
returnTainted: !!ctx._returnTainted,
|
|
873
1329
|
mutatedParams: ctx._mutatedParamsOut || new Set(),
|
|
874
1330
|
taintedGlobals: new Set(),
|
|
875
|
-
findings:
|
|
1331
|
+
findings: ctx._findings,
|
|
876
1332
|
});
|
|
877
1333
|
}
|
|
878
1334
|
for (const fn of fnList) {
|
|
@@ -888,9 +1344,20 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
888
1344
|
deadlineMs, // honored by the worklist inside analyzeFunction
|
|
889
1345
|
_summaryCache: summaryCache,
|
|
890
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,
|
|
891
1358
|
};
|
|
892
1359
|
try {
|
|
893
|
-
analyzeFunction(fn, new Set(), callContext);
|
|
1360
|
+
analyzeFunction(fn, _unionAnnotationTaint(fn, new Set()), callContext);
|
|
894
1361
|
} catch { continue; }
|
|
895
1362
|
// Process higher-order invocations: resolve callbacks and analyze with
|
|
896
1363
|
// tainted first-param. Feed findings back into the caller's finding set.
|
|
@@ -899,13 +1366,20 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
899
1366
|
for (let hi = 0; hi < Math.min(hoInvocations.length, HO_CAP); hi++) {
|
|
900
1367
|
if (Date.now() > deadlineMs) break;
|
|
901
1368
|
const inv = hoInvocations[hi];
|
|
902
|
-
if (!inv.callee || !inv.taintedParam) continue;
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
//
|
|
906
|
-
//
|
|
907
|
-
|
|
908
|
-
|
|
1369
|
+
if ((!inv.callee && !inv.calleeQid) || !inv.taintedParam) continue;
|
|
1370
|
+
// Two resolution strategies, matching the two shapes the push site can
|
|
1371
|
+
// record: an inline callback (`arr.map(x => ...)`) carries an exact
|
|
1372
|
+
// qid (parser-js.js's exprOf synthesizes it identically to how
|
|
1373
|
+
// enterFn will independently name the same node) — look it up directly
|
|
1374
|
+
// in callGraph.functions, no name resolution involved. A by-reference
|
|
1375
|
+
// callback (`arr.map(processItem)`) carries a bare ident name; resolve
|
|
1376
|
+
// it the same way every other call-graph lookup in this file does.
|
|
1377
|
+
// resolveKnownCallee (never the bare-tail-guessing resolve()) since a
|
|
1378
|
+
// wrong guess here would fabricate a callback relationship that
|
|
1379
|
+
// doesn't exist.
|
|
1380
|
+
const cbFn = inv.calleeQid
|
|
1381
|
+
? functionRecord(callGraph, inv.calleeQid)
|
|
1382
|
+
: functionRecord(callGraph, callGraph.resolveKnownCallee ? callGraph.resolveKnownCallee(inv.callee, fn && fn.file) : null);
|
|
909
1383
|
if (!cbFn || !cbFn.params || !cbFn.params.length) continue;
|
|
910
1384
|
const cbEntry = new Set([cbFn.params[inv.paramIndex || 0]]);
|
|
911
1385
|
let cbSummary = summaryCache.get(cbFn.qid, cbEntry);
|
|
@@ -916,23 +1390,60 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
916
1390
|
_stack: new Set(), deadlineMs,
|
|
917
1391
|
_summaryCache: summaryCache, _callGraph: callGraph,
|
|
918
1392
|
_mutatedParamsOut: new Set(),
|
|
1393
|
+
_cha: callContext._cha,
|
|
919
1394
|
};
|
|
920
|
-
try { analyzeFunction(cbFn, cbEntry, inner); } catch {}
|
|
921
|
-
// Merge any findings from the callback analysis into the caller.
|
|
922
|
-
callContext._findings.push(...inner._findings.map(f => ({ ...f, _funcQid: fn.qid, _via: 'higher-order' })));
|
|
1395
|
+
try { analyzeFunction(cbFn, _unionAnnotationTaint(cbFn, cbEntry), inner); } catch {}
|
|
923
1396
|
return {
|
|
924
1397
|
returnTainted: !!inner._returnTainted,
|
|
925
1398
|
mutatedParams: inner._mutatedParamsOut || new Set(),
|
|
926
1399
|
taintedGlobals: new Set(),
|
|
927
|
-
findings:
|
|
1400
|
+
findings: inner._findings,
|
|
928
1401
|
};
|
|
929
1402
|
});
|
|
930
1403
|
}
|
|
1404
|
+
// Uniform with the assign/plain-call sites: merge whether this was a
|
|
1405
|
+
// fresh compute() (findings from `inner` just above) or a cache HIT —
|
|
1406
|
+
// e.g. the k=2 pass already probed this exact qid+entry (a callback
|
|
1407
|
+
// with one param has taintedEntry===cbEntry) and stashed its own real
|
|
1408
|
+
// findings on the summary rather than reporting them speculatively.
|
|
1409
|
+
_mergeSummaryFindings(callContext, fn.qid, cbSummary, 'higher-order');
|
|
931
1410
|
}
|
|
932
|
-
|
|
933
|
-
|
|
1411
|
+
_collectFindings(fn, callContext._findings);
|
|
1412
|
+
}
|
|
1413
|
+
// v0.69 — expose cache to caller (runDeepAnalysis) for incremental persistence.
|
|
1414
|
+
// Dead code suppression: demote findings in functions with zero callers
|
|
1415
|
+
// (except route handlers which are entry points)
|
|
1416
|
+
const calledQids = new Set();
|
|
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); }
|
|
1419
|
+
for (const f of all) {
|
|
1420
|
+
if (!f._funcQid) continue;
|
|
1421
|
+
const fn = callGraph.functions?.get(f._funcQid);
|
|
1422
|
+
if (!fn) continue;
|
|
1423
|
+
if (calledQids.has(f._funcQid)) continue;
|
|
1424
|
+
if (fn.name === '<module>' || /handler|route|controller|middleware|endpoint/i.test(fn.name || '')) continue;
|
|
1425
|
+
f._inDeadCode = true;
|
|
1426
|
+
const dg = { critical: 'high', high: 'medium', medium: 'low', low: 'info' };
|
|
1427
|
+
if (dg[f.severity]) f.severity = dg[f.severity];
|
|
1428
|
+
}
|
|
1429
|
+
Object.defineProperty(all, '_summaryCache', { value: summaryCache, enumerable: false });
|
|
1430
|
+
return all;
|
|
1431
|
+
|
|
1432
|
+
// Dedup + map a raw findings array (from analyzeFunction's callContext)
|
|
1433
|
+
// into the reported IR-TAINT shape, attributed to `fn`. Used directly by
|
|
1434
|
+
// the main loop and the empty-entry pre-pass (both analyze under an entry
|
|
1435
|
+
// state that is either empty or true-by-construction, so their findings
|
|
1436
|
+
// are unconditionally real). The class-field and k=2 pre-passes are
|
|
1437
|
+
// speculative (they assume fields/params are tainted without confirming
|
|
1438
|
+
// any real caller ever does that) — see _mergeSummaryFindings, which is
|
|
1439
|
+
// the gate that gives their findings a chance to be reported only once a
|
|
1440
|
+
// genuine caller is established.
|
|
1441
|
+
function _collectFindings(attributedFn, srcFindings) {
|
|
1442
|
+
for (const f of srcFindings) {
|
|
1443
|
+
const key = `${f.sinkId}:${attributedFn.file}:${f.line}`;
|
|
934
1444
|
if (seen.has(key)) continue;
|
|
935
1445
|
seen.add(key);
|
|
1446
|
+
const fn = attributedFn;
|
|
936
1447
|
all.push({
|
|
937
1448
|
id: `ir-taint:${fn.file}:${f.line}:${f.sinkId}`,
|
|
938
1449
|
file: fn.file,
|
|
@@ -946,6 +1457,39 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
946
1457
|
// (a control-dependence finding, not an explicit data-flow one).
|
|
947
1458
|
confidence: (f.implicit && typeof f.confidence === 'number') ? f.confidence : 0.75,
|
|
948
1459
|
...(f.implicit === true ? { implicit: true } : {}),
|
|
1460
|
+
// Sanitizer callees observed on the value reaching this sink. This
|
|
1461
|
+
// mapping is an explicit allowlist, so a field absent here is silently
|
|
1462
|
+
// dropped — which is what previously left sanitizer-gate.js inert.
|
|
1463
|
+
...(Array.isArray(f._sanitizersOnPath) && f._sanitizersOnPath.length
|
|
1464
|
+
? { _sanitizersOnPath: f._sanitizersOnPath } : {}),
|
|
1465
|
+
// _funcQid: the enclosing function's qid, set upstream during the walk
|
|
1466
|
+
// but silently dropped by this allowlist before backward.js's
|
|
1467
|
+
// annotateBackwardSlices ever saw it — the same class of omission
|
|
1468
|
+
// _sanitizersOnPath had. Without it, annotateBackwardSlices's very
|
|
1469
|
+
// first check (`if (!f._funcQid) skip`) discarded every finding, so
|
|
1470
|
+
// backward-slice annotation was permanently a no-op regardless of
|
|
1471
|
+
// AGENTIC_SECURITY_BACKWARD_SLICE.
|
|
1472
|
+
...(f._funcQid ? { _funcQid: f._funcQid } : {}),
|
|
1473
|
+
// callee: kept as plain `callee` (not underscore-prefixed) to match
|
|
1474
|
+
// backward.js's own contract, which reads `f.callee` on both real and
|
|
1475
|
+
// fake-fixture findings throughout its module and test suite. It is
|
|
1476
|
+
// the SAME object reference as the CFG call node's own `callee` (set
|
|
1477
|
+
// at `_sinkFindingsForCall`'s call site as `callee: calleeExpr`,
|
|
1478
|
+
// never copied) — backward.js's sink-node lookup matches
|
|
1479
|
+
// `n.callee === f.callee` by reference identity, so dropping this
|
|
1480
|
+
// field (as the allowlist previously did) meant that match could
|
|
1481
|
+
// never succeed regardless of `_funcQid`. It is not read by
|
|
1482
|
+
// report/index.js's normalizeFindings, which is itself an explicit
|
|
1483
|
+
// allowlist that never names `callee`, so this does not reach SARIF/
|
|
1484
|
+
// JSON/HTML report output.
|
|
1485
|
+
...(f.callee !== undefined ? { callee: f.callee } : {}),
|
|
1486
|
+
// sourceProvenance/chain[].provenance: catalog.js's per-source label
|
|
1487
|
+
// (e.g. 'http-body' for req.body) computed earlier at the finding's
|
|
1488
|
+
// creation site — previously dropped by this allowlist, which is
|
|
1489
|
+
// what left posture/exploitability-probability.js's
|
|
1490
|
+
// 'source-from-network' factor permanently dead (it reads
|
|
1491
|
+
// t.provenance off chain/trace entries).
|
|
1492
|
+
sourceProvenance: f.sourceProvenance || null,
|
|
949
1493
|
source: f.trace && f.trace.length ? {
|
|
950
1494
|
file: fn.file,
|
|
951
1495
|
line: f.trace[0].line,
|
|
@@ -957,27 +1501,10 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
957
1501
|
label: f.sinkId,
|
|
958
1502
|
},
|
|
959
1503
|
chain: (f.trace || []).map(t => ({
|
|
960
|
-
file: fn.file, line: t.line, label: t.sourceLabel,
|
|
1504
|
+
file: fn.file, line: t.line, label: t.sourceLabel, provenance: t.provenance || null,
|
|
961
1505
|
})),
|
|
962
1506
|
});
|
|
963
1507
|
}
|
|
964
1508
|
}
|
|
965
|
-
|
|
966
|
-
// Dead code suppression: demote findings in functions with zero callers
|
|
967
|
-
// (except route handlers which are entry points)
|
|
968
|
-
const calledQids = new Set();
|
|
969
|
-
if (callGraph.edges) for (const e of callGraph.edges) calledQids.add(typeof e.to === 'string' ? e.to : e.to?.qid);
|
|
970
|
-
if (callGraph.callersOf) for (const [qid, callers] of callGraph.callersOf) { if (callers && callers.size) calledQids.add(qid); }
|
|
971
|
-
for (const f of all) {
|
|
972
|
-
if (!f._funcQid) continue;
|
|
973
|
-
const fn = callGraph.functions?.get(f._funcQid);
|
|
974
|
-
if (!fn) continue;
|
|
975
|
-
if (calledQids.has(f._funcQid)) continue;
|
|
976
|
-
if (/handler|route|controller|middleware|endpoint/i.test(fn.name || '')) continue;
|
|
977
|
-
f._inDeadCode = true;
|
|
978
|
-
const dg = { critical: 'high', high: 'medium', medium: 'low', low: 'info' };
|
|
979
|
-
if (dg[f.severity]) f.severity = dg[f.severity];
|
|
980
|
-
}
|
|
981
|
-
Object.defineProperty(all, '_summaryCache', { value: summaryCache, enumerable: false });
|
|
982
|
-
return all;
|
|
1509
|
+
|
|
983
1510
|
}
|