@clear-capabilities/agentic-security-scanner 0.151.2 → 0.152.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 +184 -0
- package/dist/1905.index.js +30 -2
- package/dist/3499.index.js +20 -1
- package/dist/agentic-security.mjs +80 -80
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +8 -4
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/catalog.js +842 -17
- package/src/dataflow/engine.js +865 -90
- package/src/dataflow/incremental.js +13 -0
- package/src/dataflow/index.js +18 -5
- package/src/dataflow/path-feasibility.js +86 -9
- package/src/dataflow/string-domain.js +132 -0
- package/src/dataflow/summaries.js +69 -8
- package/src/discovery/THREAT_MODEL.md +122 -0
- package/src/discovery/disprove.js +10 -1
- package/src/discovery/lenses.js +20 -1
- package/src/engine.js +108 -3
- package/src/ir/CLAUDE.md +2 -1
- package/src/ir/balanced-call.js +33 -1
- package/src/ir/callgraph.js +26 -2
- package/src/ir/class-hierarchy.js +119 -7
- package/src/ir/csharp-ir.js +97 -8
- package/src/ir/index.js +21 -1
- package/src/ir/parser-cs.js +706 -24
- package/src/ir/parser-java.js +527 -24
- package/src/ir/parser-js.js +22 -0
- package/src/ir/parser-php.js +446 -43
- package/src/ir/parser-rust.js +1338 -0
- package/src/ir/php-include-merge.js +190 -0
- package/src/lineage/sink-registry.js +18 -0
- package/src/lineage/source-registry.js +23 -0
- package/src/posture/csharp-analysis.js +341 -43
- package/src/posture/finding-defaults.js +5 -1
- package/src/posture/fix-coverage.js +1 -0
- package/src/posture/proof-coverage.js +1 -0
- package/src/sast/CLAUDE.md +1 -1
- package/src/sast/authz.js +144 -1
- package/src/sast/crypto-protocol.js +65 -7
- package/src/sast/csharp-cross-file.js +125 -0
- package/src/sast/csharp-structural.js +78 -2
- package/src/sast/csharp.js +574 -42
- package/src/sast/java-ast-folding.js +268 -11
- package/src/sast/java-bench-extras.js +530 -2
- package/src/sast/java-deserialization.js +31 -2
- package/src/sast/java-structural-cross-file.js +146 -0
- package/src/sast/java-structural.js +499 -6
- package/src/sast/ldap-injection.js +133 -3
- package/src/sast/rust.js +57 -0
- package/src/sast/xss-reflected-multilang.js +252 -3
package/src/dataflow/engine.js
CHANGED
|
@@ -44,19 +44,20 @@
|
|
|
44
44
|
|
|
45
45
|
import { matchSource, matchSinkOrSanitizer, matchMemberWriteSink, matchAnnotationParams } from './catalog.js';
|
|
46
46
|
import { functionRecord } from '../ir/callgraph.js';
|
|
47
|
-
import { accessPathOf, isCoveredBy, addPath, removePathAndDescendants, joinSets as joinAccessSets, setsEqual as accessSetsEqual } from './access-paths.js';
|
|
47
|
+
import { accessPathOf, isCoveredBy, addPath, removePathAndDescendants, joinSets as joinAccessSets, setsEqual as accessSetsEqual, pathIsCoveredByPrefix } from './access-paths.js';
|
|
48
48
|
import { aliasesForVar } from './points-to.js';
|
|
49
49
|
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
|
+
import { isSafeValidationPattern } from './string-domain.js';
|
|
53
54
|
// NOTE: receiver-context.js's receiverTypeAtCall is deliberately NOT imported
|
|
54
55
|
// here. It was the implementation of _receiverTypeFor's old `this.field`
|
|
55
56
|
// branch, whose PascalCase-of-field-name guess is the false-negative bug this
|
|
56
57
|
// file's _receiverTypeFor comment describes. Its other exports are still used
|
|
57
58
|
// (summaries.js imports hashReceiverType for cache keying); only this call
|
|
58
59
|
// site is gone.
|
|
59
|
-
import { resolveMethod, classOfVar } from '../ir/class-hierarchy.js';
|
|
60
|
+
import { resolveMethod, resolveMethodRTA, classOfVar } from '../ir/class-hierarchy.js';
|
|
60
61
|
|
|
61
62
|
// v0.70 #2 — addPath that also taints every alias of the variable.
|
|
62
63
|
// When `target` is a dotted path like "a.x" and the root `a` has aliases
|
|
@@ -101,10 +102,8 @@ function _addPathAliasAware(state, path, callContext) {
|
|
|
101
102
|
return s;
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
let _activeConstantVars = null;
|
|
105
105
|
// The file of the function currently being analyzed. Set at the top of
|
|
106
|
-
// analyzeFunction
|
|
107
|
-
// above) so exprIsSource/exprTaint/step can pass it to matchSource /
|
|
106
|
+
// analyzeFunction so exprIsSource/exprTaint/step can pass it to matchSource /
|
|
108
107
|
// matchSinkOrSanitizer without plumbing it through every call signature.
|
|
109
108
|
// It scopes language-specific catalog entries (currently just `cpp`) to
|
|
110
109
|
// files of that language — see the header comment in catalog.js.
|
|
@@ -240,14 +239,52 @@ function _resolvableCalleeName(calleeExpr) {
|
|
|
240
239
|
// expression) and R11's extra `resolveMethod` step means it can still refuse
|
|
241
240
|
// where R6 does not.
|
|
242
241
|
function _resolveMemberCalleeViaCHA(calleeExpr, callContext) {
|
|
243
|
-
if (!calleeExpr || calleeExpr.kind !== 'member' || typeof calleeExpr.prop !== 'string') return null;
|
|
244
242
|
if (!callContext || !callContext._cha) return null;
|
|
245
|
-
|
|
246
|
-
|
|
243
|
+
let receiverName, methodName;
|
|
244
|
+
if (calleeExpr && calleeExpr.kind === 'member' && typeof calleeExpr.prop === 'string') {
|
|
245
|
+
if (!calleeExpr.object || calleeExpr.object.kind !== 'ident' || calleeExpr.object.name === '_this_') return null;
|
|
246
|
+
receiverName = calleeExpr.object.name;
|
|
247
|
+
methodName = calleeExpr.prop;
|
|
248
|
+
} else if (typeof calleeExpr === 'string') {
|
|
249
|
+
// PRD W2.5 (SARD_80_F1_EXECUTION_PRD.md) — the hand-rolled parsers
|
|
250
|
+
// (Java/C#/Go/PHP/Ruby/Kotlin) emit a FLAT dotted-string callee
|
|
251
|
+
// ("b.action"), never the Babel `{kind:'member'}` shape above. This
|
|
252
|
+
// function previously only ever handled the latter, so this whole
|
|
253
|
+
// CHA-gated path was silently JS-only. Only a simple `receiver.method`
|
|
254
|
+
// (exactly one dot) is handled — a longer chain (`a.b.c`) is a member
|
|
255
|
+
// chain this function has no way to walk without an AST, and guessing
|
|
256
|
+
// would risk exactly the fabricated-edge failure mode this function's
|
|
257
|
+
// own header comment warns about; refuse rather than guess.
|
|
258
|
+
const dot = calleeExpr.indexOf('.');
|
|
259
|
+
if (dot <= 0 || calleeExpr.indexOf('.', dot + 1) !== -1) return null;
|
|
260
|
+
receiverName = calleeExpr.slice(0, dot);
|
|
261
|
+
methodName = calleeExpr.slice(dot + 1);
|
|
262
|
+
if (receiverName === '_this_' || receiverName === 'this') return null;
|
|
263
|
+
} else {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
const className = classOfVar(callContext._cha, _currentFile, callContext._currentFnQid, receiverName);
|
|
247
267
|
if (!className) return null;
|
|
248
|
-
const
|
|
249
|
-
if (
|
|
250
|
-
|
|
268
|
+
const direct = resolveMethod(callContext._cha, className, methodName);
|
|
269
|
+
if (direct) return `${direct.className}.${direct.methodName}`;
|
|
270
|
+
// Direct/inherited resolution failed — this is expected when `className`
|
|
271
|
+
// is an ABSTRACT class or interface with no body of its own (Juliet's
|
|
272
|
+
// dominant idiom: `abstract void action(String d);` is never emitted as a
|
|
273
|
+
// function at all, so it's never added to any class's `methods` set).
|
|
274
|
+
// Fall back to RTA: which LIVE (actually `new`'d somewhere in this scan)
|
|
275
|
+
// subclasses of `className` implement `methodName`. Deliberately
|
|
276
|
+
// single-candidate-only for this landing — refuse rather than guess when
|
|
277
|
+
// more than one live subclass implements the method (e.g. a bad/good
|
|
278
|
+
// variant pair both alive in the same scan), matching this codebase's
|
|
279
|
+
// dominant ambiguity-refusal convention. Known, accepted limitation: this
|
|
280
|
+
// means the fix does nothing when Juliet's own bad+good siblings are
|
|
281
|
+
// scanned together, which is the common case — see the ledger for the
|
|
282
|
+
// measured impact and whether that limitation is worth lifting next.
|
|
283
|
+
const cha = callContext._cha;
|
|
284
|
+
if (!cha.liveClasses) return null;
|
|
285
|
+
const candidates = resolveMethodRTA(cha, methodName, cha.liveClasses, className);
|
|
286
|
+
if (candidates.length !== 1) return null;
|
|
287
|
+
return `${candidates[0].className}.${candidates[0].methodName}`;
|
|
251
288
|
}
|
|
252
289
|
|
|
253
290
|
// Resolve calleeExpr to { qid, fn } via the call graph — the shared
|
|
@@ -261,14 +298,24 @@ function _resolveMemberCalleeViaCHA(calleeExpr, callContext) {
|
|
|
261
298
|
function _resolveCalleeForSummary(calleeExpr, callContext) {
|
|
262
299
|
if (!callContext || !callContext._callGraph || !callContext._callGraph.resolveKnownCallee) return null;
|
|
263
300
|
const _callerFile = (callContext._currentFnQid || '').split('::')[0] || undefined;
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
301
|
+
const _resolvableName = _resolvableCalleeName(calleeExpr);
|
|
302
|
+
if (_resolvableName) {
|
|
303
|
+
const resolved = callContext._callGraph.resolveKnownCallee(_resolvableName, _callerFile);
|
|
304
|
+
const fn = functionRecord(callContext._callGraph, resolved);
|
|
305
|
+
const qid = resolved && (resolved.qid || resolved);
|
|
306
|
+
if (typeof qid === 'string') return { qid, fn };
|
|
307
|
+
}
|
|
308
|
+
// PRD R11 / W2.5: reached either because `_resolvableCalleeName` refused
|
|
309
|
+
// outright (a Babel member-expression callee), or because it handed back
|
|
310
|
+
// a name (a flat dotted string like "b.action" — the hand-rolled parsers'
|
|
311
|
+
// shape) that `resolveKnownCallee` could NOT resolve by name (`b` isn't a
|
|
312
|
+
// class name, it's a local/parameter). Try the CHA-gated path ONLY as a
|
|
313
|
+
// fallback after ordinary name resolution has already failed, so a callee
|
|
314
|
+
// that resolves correctly by name is never second-guessed by a coarser,
|
|
315
|
+
// over-approximating CHA/RTA lookup.
|
|
316
|
+
const _chaName = _resolveMemberCalleeViaCHA(calleeExpr, callContext);
|
|
317
|
+
if (!_chaName) return null;
|
|
318
|
+
const resolved = callContext._callGraph.resolveKnownCallee(_chaName, _callerFile);
|
|
272
319
|
const fn = functionRecord(callContext._callGraph, resolved);
|
|
273
320
|
const qid = resolved && (resolved.qid || resolved);
|
|
274
321
|
return typeof qid === 'string' ? { qid, fn } : null;
|
|
@@ -306,13 +353,18 @@ function _nestedCallReturnTainted(calleeExpr, argExprs, state, callContext) {
|
|
|
306
353
|
return {
|
|
307
354
|
returnTainted: !!inner._returnTainted,
|
|
308
355
|
mutatedParams: inner._mutatedParamsOut || new Set(),
|
|
356
|
+
mutatedThisFields: inner._mutatedThisFieldsOut || new Set(),
|
|
309
357
|
taintedGlobals: new Set(),
|
|
310
358
|
findings: inner._findings,
|
|
311
359
|
};
|
|
312
360
|
});
|
|
313
361
|
}
|
|
314
362
|
_mergeSummaryFindings(callContext, callContext._currentFnQid, sum, 'interproc');
|
|
315
|
-
return
|
|
363
|
+
// Next-gen taint capability #3: a single-field getter's return is tainted
|
|
364
|
+
// whenever THIS call's own receiver already carries that field as
|
|
365
|
+
// tainted, independent of `sum.returnTainted` (which reflects only the
|
|
366
|
+
// EMPTY-args entry state a zero-param getter always computes under).
|
|
367
|
+
return _calleeGetterFieldTainted(calleeExpr, state, callContext) || !!(sum && sum.returnTainted);
|
|
316
368
|
}
|
|
317
369
|
|
|
318
370
|
// Taint-recall PRD (80%): is a call's own RECEIVER tainted? Handles the two
|
|
@@ -339,11 +391,88 @@ function _calleeReceiverTainted(callee, state, callContext) {
|
|
|
339
391
|
return false;
|
|
340
392
|
}
|
|
341
393
|
|
|
394
|
+
// Next-gen taint capability #3 — object-instance field taint via method
|
|
395
|
+
// calls (JS/TS). `classTaintedFields` (below, in the pre-pass) already
|
|
396
|
+
// tracks "does ANY method of this class ever taint field F", but that is a
|
|
397
|
+
// deliberate, CLASS-WIDE over-approximation used only when no direct call-
|
|
398
|
+
// graph link between writer and reader is discoverable. When code instead
|
|
399
|
+
// calls a mutator method directly on a LOCAL receiver variable —
|
|
400
|
+
// `const bad = new Holder(); bad.setData(tainted); ...` — the engine had no
|
|
401
|
+
// mechanism at all to taint `bad`'s own `data` field: `_mutatedParamsOut`
|
|
402
|
+
// (v0.66) only ever tracks the callee's DECLARED PARAMETERS, never the
|
|
403
|
+
// implicit `this` receiver, so a setter's `this.field = v` mutation was
|
|
404
|
+
// invisible to its own caller. Fixed by treating `this` as an implicit
|
|
405
|
+
// mutable parameter (see the `_mutatedThisFieldsOut` population in
|
|
406
|
+
// `analyzeFunction`, mirroring `_mutatedParamsOut` exactly) and, on the read
|
|
407
|
+
// side, recognizing a single-field getter shape (`return this.field;`) so a
|
|
408
|
+
// later `bad.getData()` resolves to whatever was tainted on `bad` SPECIFICALLY
|
|
409
|
+
// — not on every other instance of `Holder`. Because the caller-side effect
|
|
410
|
+
// lands on the RECEIVER'S OWN access path (`bad.data`, not a class-wide
|
|
411
|
+
// bucket), two different local variables holding instances of the SAME
|
|
412
|
+
// class are naturally kept distinct — real object-instance sensitivity,
|
|
413
|
+
// without needing a full allocation-site-keyed heap model.
|
|
414
|
+
|
|
415
|
+
// Field name a getter-shaped function unconditionally returns, or null.
|
|
416
|
+
// Deliberately narrow and structural (no taint-state dependency): only a
|
|
417
|
+
// BARE `this.<field>`/`_this_.<field>`/`$this.<field>` return value counts
|
|
418
|
+
// (not `this.field.sub`, not `this.field + x`) — accessPathOf already
|
|
419
|
+
// rejects anything with further computation. If the function has multiple
|
|
420
|
+
// return statements naming DIFFERENT fields, this is ambiguous and bails to
|
|
421
|
+
// null rather than guess, since guessing the wrong field here couldn't add
|
|
422
|
+
// a false negative (the true field would just stay whatever it already was)
|
|
423
|
+
// but a memoized wrong answer would be needlessly confusing to reason about.
|
|
424
|
+
const _THIS_FIELD_RETURN_RE = /^(?:_this_|this|\$this)\.([^.]+)$/;
|
|
425
|
+
// Same receiver-spelling prefix, WITHOUT the end anchor — matches a deeper
|
|
426
|
+
// path too (`_this_.data.sub`), used to recover just the top-level field
|
|
427
|
+
// name a function's exit state taints on its receiver (mutator side).
|
|
428
|
+
const _THIS_FIELD_PREFIX_RE = /^(?:_this_|this|\$this)\.([^.]+)/;
|
|
429
|
+
const _getterFieldCache = new WeakMap();
|
|
430
|
+
function _thisFieldGetterName(fn) {
|
|
431
|
+
if (!fn || !fn.cfg || !fn.cfg.nodes) return null;
|
|
432
|
+
if (_getterFieldCache.has(fn)) return _getterFieldCache.get(fn);
|
|
433
|
+
let field = null;
|
|
434
|
+
for (const node of Object.values(fn.cfg.nodes)) {
|
|
435
|
+
if (node.kind !== 'return' || !node.value) continue;
|
|
436
|
+
const path = accessPathOf(node.value);
|
|
437
|
+
if (!path) continue;
|
|
438
|
+
const m = _THIS_FIELD_RETURN_RE.exec(path);
|
|
439
|
+
if (!m) continue;
|
|
440
|
+
if (field === null) field = m[1];
|
|
441
|
+
else if (field !== m[1]) { field = null; break; }
|
|
442
|
+
}
|
|
443
|
+
_getterFieldCache.set(fn, field);
|
|
444
|
+
return field;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// The receiver's OWN access path for a receiver-qualified callee, in
|
|
448
|
+
// EITHER IR shape this codebase's frontends produce — same dual-shape
|
|
449
|
+
// handling as `_calleeReceiverTainted` just above, but returning the path
|
|
450
|
+
// itself (to taint/query it) rather than a boolean.
|
|
451
|
+
function _receiverAccessPathOf(callee) {
|
|
452
|
+
if (!callee) return null;
|
|
453
|
+
if (typeof callee === 'string') {
|
|
454
|
+
const idx = callee.lastIndexOf('.');
|
|
455
|
+
return idx > 0 ? callee.slice(0, idx) : null;
|
|
456
|
+
}
|
|
457
|
+
if (callee.kind === 'member' && callee.object) return accessPathOf(callee.object);
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Does calling `calleeExpr` return a receiver-specific field that is
|
|
462
|
+
// ALREADY tainted on THIS SPECIFIC receiver (per `_thisFieldGetterName`)?
|
|
463
|
+
// Read-side counterpart of the `_mutatedThisFieldsOut` write-side fix.
|
|
464
|
+
function _calleeGetterFieldTainted(calleeExpr, state, callContext) {
|
|
465
|
+
if (!callContext || !callContext._summaryCache) return false;
|
|
466
|
+
const target = _resolveCalleeForSummary(calleeExpr, callContext);
|
|
467
|
+
const field = target && _thisFieldGetterName(target.fn);
|
|
468
|
+
if (!field) return false;
|
|
469
|
+
const receiverPath = _receiverAccessPathOf(calleeExpr);
|
|
470
|
+
return !!(receiverPath && isCoveredBy(state, `${receiverPath}.${field}`));
|
|
471
|
+
}
|
|
472
|
+
|
|
342
473
|
function exprTaint(expr, state, callContext) {
|
|
343
|
-
if (expr && (expr.kind === 'member' || expr.kind === 'call' || expr.kind === 'ident') && exprIsSource(expr)) return true;
|
|
474
|
+
if (expr && (expr.kind === 'member' || expr.kind === 'call' || expr.kind === 'ident') && exprIsSource(expr, callContext)) return true;
|
|
344
475
|
if (!expr) return false;
|
|
345
|
-
// Constant propagation: variables assigned from literals are never tainted
|
|
346
|
-
if (expr.kind === 'ident' && _activeConstantVars && _activeConstantVars.has(expr.name)) return false;
|
|
347
476
|
// P1.1 — field-sensitive access path: if the expression is a pure
|
|
348
477
|
// ident/member chain ("x.y.z"), ask the access-path lattice whether any
|
|
349
478
|
// shorter prefix in the state covers it. This is what makes
|
|
@@ -359,6 +488,17 @@ function exprTaint(expr, state, callContext) {
|
|
|
359
488
|
case 'object': return (expr.props || []).some(p => exprTaint(p.value, state, callContext));
|
|
360
489
|
case 'array': return (expr.elements || []).some(e => exprTaint(e, state, callContext));
|
|
361
490
|
case 'call': {
|
|
491
|
+
// Sound taint-kill for a coercion sanitizer used INLINE (not assigned
|
|
492
|
+
// to a variable first) — `sink("x=" . intval($_GET['id']))`,
|
|
493
|
+
// `if (sink(intval($x)))`. See _isCoercionCall's header comment for why
|
|
494
|
+
// this specific class of sanitizer is the one exception to "never kill
|
|
495
|
+
// taint on a sanitizer call": a genuine type coercion makes the VALUE
|
|
496
|
+
// itself non-injectable, independent of which sink it reaches, so its
|
|
497
|
+
// own return value is clean regardless of whether its argument was
|
|
498
|
+
// tainted. Checked first and returns immediately — a coerced value
|
|
499
|
+
// cannot un-clean itself via its own (nonexistent, since this exits
|
|
500
|
+
// early) receiver/nested-return taint.
|
|
501
|
+
if (_isCoercionCall(expr)) return false;
|
|
362
502
|
// The call's own arguments OR — PRD R10 — the resolved callee's own
|
|
363
503
|
// return-taint summary. Taint-recall PRD (80%): this used to
|
|
364
504
|
// short-circuit on args-tainted and SKIP _nestedCallReturnTainted
|
|
@@ -378,7 +518,7 @@ function exprTaint(expr, state, callContext) {
|
|
|
378
518
|
// direction) so the merge always runs when a call expression is
|
|
379
519
|
// visited; _resolveCalleeForSummary + SummaryCache make repeat
|
|
380
520
|
// resolution/computation for the same (qid, entry-state) cheap.
|
|
381
|
-
const argsTainted = (expr.args || []).some(a => exprTaint(a, state, callContext));
|
|
521
|
+
const argsTainted = (expr.args || []).some((a, i) => !_isSprintfSafeArg(expr, i) && exprTaint(a, state, callContext));
|
|
382
522
|
const nestedTainted = _nestedCallReturnTainted(expr.callee, expr.args, state, callContext);
|
|
383
523
|
// Taint-recall PRD (80%): a call's RECEIVER can itself be tainted
|
|
384
524
|
// independent of its arguments — `tainted.toString()`, `tainted.trim()`,
|
|
@@ -460,7 +600,16 @@ function _sourcesReachingExpr(expr, _state, taintSources) {
|
|
|
460
600
|
}
|
|
461
601
|
|
|
462
602
|
// Heuristic: does this expression read a registered source?
|
|
463
|
-
|
|
603
|
+
// `callContext` (SARD_80_F1 W4.C39, optional) lets the CALL-shaped branch
|
|
604
|
+
// resolve a receiver's CHA-declared type (mirroring `_receiverTypeFor`'s use
|
|
605
|
+
// for sinks) and pass it to `matchSource` so a `receiverTypeIn`-bearing
|
|
606
|
+
// source entry can confirm a match even when the receiver's own NAME fails
|
|
607
|
+
// (`dr.GetString(1)` — Juliet's idiomatic short SqlDataReader variable name,
|
|
608
|
+
// no "reader" substring). Every caller that has a callContext in scope now
|
|
609
|
+
// passes it through; callers that don't (none currently) simply get the
|
|
610
|
+
// prior, name-only behavior — `_receiverTypeFor` itself degrades to null
|
|
611
|
+
// when callContext/CHA is absent, so this is purely additive.
|
|
612
|
+
function exprIsSource(expr, callContext) {
|
|
464
613
|
if (!expr) return null;
|
|
465
614
|
if (expr.kind === 'member') {
|
|
466
615
|
const hit = matchSource(expr, _currentFile);
|
|
@@ -470,7 +619,8 @@ function exprIsSource(expr) {
|
|
|
470
619
|
// Previously only member reads were recognized, so Go's call-style sources
|
|
471
620
|
// never tainted the assignment target. matchSource now resolves call sources.
|
|
472
621
|
if (expr.kind === 'call') {
|
|
473
|
-
const
|
|
622
|
+
const receiverType = _receiverTypeFor(expr.callee, callContext);
|
|
623
|
+
const hit = matchSource(expr, _currentFile, receiverType);
|
|
474
624
|
if (hit) return hit;
|
|
475
625
|
}
|
|
476
626
|
// Taint-recall PRD (80%): bare-identifier GLOBAL sources — PHP's $_GET/
|
|
@@ -492,7 +642,7 @@ function exprIsSource(expr) {
|
|
|
492
642
|
if (hit) return hit;
|
|
493
643
|
}
|
|
494
644
|
if (expr.kind === 'member' && expr.object) {
|
|
495
|
-
return exprIsSource(expr.object);
|
|
645
|
+
return exprIsSource(expr.object, callContext);
|
|
496
646
|
}
|
|
497
647
|
return null;
|
|
498
648
|
}
|
|
@@ -608,6 +758,93 @@ function _calleeNames(callee) {
|
|
|
608
758
|
return [];
|
|
609
759
|
}
|
|
610
760
|
|
|
761
|
+
// SARD_80_F1 W3.2 — guard-predicate narrowing. `_calleeNames` only handles
|
|
762
|
+
// an OBJECT-shaped callee (Babel's `{kind:'ident'|'member',...}`); the five
|
|
763
|
+
// hand-rolled parsers (C#/Go/Kotlin/PHP/Ruby) plus Java/Python instead emit
|
|
764
|
+
// a flat, dot-joined STRING callee (per ir/CLAUDE.md) — `is_numeric` or
|
|
765
|
+
// `int.TryParse`, never a structured node. This normalizes both shapes to
|
|
766
|
+
// the same {full, bare-leaf} name pair `GUARD_PREDICATES` matches against.
|
|
767
|
+
function _bareCalleeNames(callee) {
|
|
768
|
+
if (typeof callee === 'string') {
|
|
769
|
+
const parts = callee.split('.');
|
|
770
|
+
return [callee, parts[parts.length - 1]];
|
|
771
|
+
}
|
|
772
|
+
return _calleeNames(callee);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// A hand-picked, deliberately CONSERVATIVE set of validating predicates
|
|
776
|
+
// that, when used as an if-statement's CONDITION and observed true, prove
|
|
777
|
+
// the checked variable cannot carry injection metacharacters on the TRUE
|
|
778
|
+
// branch specifically — never on the false branch (a guard that FAILS says
|
|
779
|
+
// nothing about the value's safety, so that edge, and any code after an
|
|
780
|
+
// if-with-no-else, keeps the original tainted state unchanged). Each entry
|
|
781
|
+
// names the callees that count as this guard and which argument holds the
|
|
782
|
+
// checked variable. Deliberately narrow for this first landing: a regex-
|
|
783
|
+
// based guard (`preg_match('/^\d+$/', $x)`) needs the PATTERN itself
|
|
784
|
+
// validated as a fully-anchored numeric literal to be sound, and an
|
|
785
|
+
// allow-list guard (`in_array($x, $allow, true)`) needs `$allow` resolved
|
|
786
|
+
// to a literal array — both are real, documented follow-ups, not attempted
|
|
787
|
+
// here to keep this landing's soundness easy to verify.
|
|
788
|
+
const GUARD_PREDICATES = [
|
|
789
|
+
// PHP: is_numeric/ctype_* — a string that PASSES is provably digits-only
|
|
790
|
+
// (or alnum), which cannot carry SQL/XPath/shell/LDAP metacharacters.
|
|
791
|
+
{ names: ['is_numeric', 'ctype_digit', 'ctype_alnum'], argIndex: 0 },
|
|
792
|
+
// Java: Apache Commons / Spring's StringUtils.isNumeric(x).
|
|
793
|
+
{ names: ['StringUtils.isNumeric', 'isNumeric'], argIndex: 0 },
|
|
794
|
+
// C#: `if (int.TryParse(x, out n))` and its numeric-type siblings. Each
|
|
795
|
+
// full dotted name is listed explicitly (not a bare 'TryParse' catch-all)
|
|
796
|
+
// to avoid over-matching an unrelated same-named helper method.
|
|
797
|
+
{
|
|
798
|
+
names: [
|
|
799
|
+
'int.TryParse', 'Int32.TryParse', 'long.TryParse', 'Int64.TryParse',
|
|
800
|
+
'double.TryParse', 'decimal.TryParse', 'float.TryParse',
|
|
801
|
+
'uint.TryParse', 'short.TryParse', 'byte.TryParse',
|
|
802
|
+
],
|
|
803
|
+
argIndex: 0,
|
|
804
|
+
},
|
|
805
|
+
];
|
|
806
|
+
|
|
807
|
+
// Does `cond` (an if-statement's condition expression) recognizably guard a
|
|
808
|
+
// currently-tainted variable per `GUARD_PREDICATES`? Returns that variable's
|
|
809
|
+
// access path (to narrow on the true-branch edge only) or null.
|
|
810
|
+
function _guardNarrowsVar(cond, state) {
|
|
811
|
+
if (!cond || cond.kind !== 'call') return null;
|
|
812
|
+
const names = _bareCalleeNames(cond.callee);
|
|
813
|
+
for (const g of GUARD_PREDICATES) {
|
|
814
|
+
if (!g.names.some((n) => names.includes(n))) continue;
|
|
815
|
+
const arg = cond.args && cond.args[g.argIndex];
|
|
816
|
+
const path = arg && accessPathOf(arg);
|
|
817
|
+
if (path && isCoveredBy(state, path)) return path;
|
|
818
|
+
}
|
|
819
|
+
return null;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Next-gen taint capability #2 — regex-validation guard narrowing (JS/TS).
|
|
823
|
+
// Recognizes the idiom `if (<regex-literal>.test(x)) { ... }` — `parser-js.js`
|
|
824
|
+
// lowers a regex literal's own `.test(...)` call to `{kind:'call',
|
|
825
|
+
// callee:{kind:'member', object:{kind:'literal', value:RegExp, isRegex:true},
|
|
826
|
+
// prop:'test'}, args:[x]}`. When the literal's pattern is
|
|
827
|
+
// `isSafeValidationPattern`-safe, an OBSERVED true return proves `x` cannot
|
|
828
|
+
// carry injection metacharacters, same soundness contract and TRUE-branch-
|
|
829
|
+
// only narrowing as `GUARD_PREDICATES`/`_guardNarrowsVar` above. Deliberately
|
|
830
|
+
// narrow for this landing, same "documented follow-up" precedent as that
|
|
831
|
+
// function's own header comment: a regex bound to a variable first
|
|
832
|
+
// (`const re = /.../; if (re.test(x))`) is not resolved back to its literal —
|
|
833
|
+
// this engine has no constant-propagation map for local variable bindings to
|
|
834
|
+
// reuse here, and adding one is a real, separate piece of work.
|
|
835
|
+
function _regexTestGuardNarrowsVar(cond, state) {
|
|
836
|
+
if (!cond || cond.kind !== 'call') return null;
|
|
837
|
+
const callee = cond.callee;
|
|
838
|
+
if (!callee || typeof callee !== 'object' || callee.kind !== 'member' || callee.prop !== 'test') return null;
|
|
839
|
+
const obj = callee.object;
|
|
840
|
+
if (!obj || obj.kind !== 'literal' || !obj.isRegex || !(obj.value instanceof RegExp)) return null;
|
|
841
|
+
if (!isSafeValidationPattern(obj.value)) return null;
|
|
842
|
+
const arg = cond.args && cond.args[0];
|
|
843
|
+
const path = arg && accessPathOf(arg);
|
|
844
|
+
if (path && isCoveredBy(state, path)) return path;
|
|
845
|
+
return null;
|
|
846
|
+
}
|
|
847
|
+
|
|
611
848
|
function _unsanitizersInExprTree(expr, out) {
|
|
612
849
|
if (!expr || typeof expr !== 'object') return;
|
|
613
850
|
if (expr.kind === 'call') {
|
|
@@ -684,6 +921,131 @@ function _sanitizersForExpr(expr, callContext) {
|
|
|
684
921
|
return out;
|
|
685
922
|
}
|
|
686
923
|
|
|
924
|
+
// Sound taint-kill for TYPE-COERCION sanitizers (SARD_80_F1_SCANNER_PRD.md).
|
|
925
|
+
//
|
|
926
|
+
// Every OTHER sanitizer in this engine only ever DEMOTES (sanitizer-gate.js /
|
|
927
|
+
// proof-gate.js), never kills taint outright — the well-documented reason is
|
|
928
|
+
// that a sanitizer's effect is family-specific (`htmlspecialchars` does
|
|
929
|
+
// nothing for SQLi), so killing taint on ANY sanitizer call would silently
|
|
930
|
+
// drop a real vulnerability whenever an unrelated escaper happened to sit on
|
|
931
|
+
// the path. A catalog entry tagged `appliesTo: ['*']` is categorically
|
|
932
|
+
// different: it is registered specifically because it changes the VALUE'S
|
|
933
|
+
// TYPE (intval, filter_var(..., FILTER_VALIDATE_INT), parseInt, Integer.parseInt,
|
|
934
|
+
// strconv.Atoi, ...), not because it neutralizes one syntax. A value that has
|
|
935
|
+
// gone through a genuine type coercion cannot carry injection syntax for ANY
|
|
936
|
+
// string-based family at once — the same reasoning that forbids a blanket
|
|
937
|
+
// kill for family-specific sanitizers affirmatively REQUIRES one here, or
|
|
938
|
+
// every one of these 17+ catalog entries does nothing but cost confidence
|
|
939
|
+
// point.
|
|
940
|
+
//
|
|
941
|
+
// Scoped tightly: only fires when `expr` IS ITSELF a direct call whose own
|
|
942
|
+
// callee catalog-matches a universal sanitizer — never a guess, never
|
|
943
|
+
// something buried deeper in an unrelated expression tree (that's what
|
|
944
|
+
// `_sanitizersForExpr` already recall-preservingly handles for the DEMOTE
|
|
945
|
+
// path). `data = intval($_GET['x']);` fires this; `data = "x=" . intval($y);`
|
|
946
|
+
// (the coercion is only PART of the value) does not, and correctly still
|
|
947
|
+
// gets full taint tracking on the concatenation.
|
|
948
|
+
// Shared with _sinkFindingsForCall's identical sink-side check (below): a
|
|
949
|
+
// `requireLiteralArg` gate applies equally to a SANITIZER entry whose
|
|
950
|
+
// effectiveness depends on which literal was passed (`filter_var($x,
|
|
951
|
+
// FILTER_VALIDATE_INT)` genuinely coerces; `filter_var($x,
|
|
952
|
+
// FILTER_SANITIZE_STRING)` or a bare `filter_var($x)` does not — both are the
|
|
953
|
+
// SAME callee, distinguished only by this argument). Fails CLOSED: a missing
|
|
954
|
+
// or non-literal arg does not satisfy the requirement, same direction every
|
|
955
|
+
// other precision gate in this file takes when evidence is unavailable.
|
|
956
|
+
function _literalArgSatisfied(argExprs, requireLiteralArg) {
|
|
957
|
+
const { index, pattern } = requireLiteralArg;
|
|
958
|
+
const checkArg = (argExprs || [])[index];
|
|
959
|
+
return !!checkArg && checkArg.kind === 'literal' && new RegExp(pattern).test(String(checkArg.value));
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// SARD_80_F1 W5.49 — PHP's `sprintf("...%d...", $tainted)` is a PER-ARGUMENT
|
|
963
|
+
// coercion sanitizer, distinct from `_isCoercionCall`'s whole-call model
|
|
964
|
+
// (intval/floatval/etc, where the ENTIRE call's return value is the coerced
|
|
965
|
+
// scalar). `sprintf` returns a TEMPLATE string; only the specific argument(s)
|
|
966
|
+
// bound to a purely-numeric conversion (%d/%u/%f/%x/%o/%b/...) are coerced —
|
|
967
|
+
// an argument bound to %s passes through raw and is NOT coerced, and %c
|
|
968
|
+
// converts an integer to a single arbitrary BYTE (an attacker-controlled
|
|
969
|
+
// code point can still be a quote/semicolon/backslash), so it is
|
|
970
|
+
// deliberately excluded from the safe set even though it looks numeric-ish.
|
|
971
|
+
//
|
|
972
|
+
// Confirmed as a REAL, previously-unrecognized PHP SQL-injection false-
|
|
973
|
+
// positive source via the public generator source this session already has
|
|
974
|
+
// an established exception for (stivalet/PHP-Vuln-test-suite-generator's own
|
|
975
|
+
// `bin/XML/construction.xml`): its CWE-89 "safe" construction sample is
|
|
976
|
+
// LITERALLY `$query = sprintf("SELECT * FROM student where id=%d",
|
|
977
|
+
// $tainted);` (`safety flawType="CWE_89_Injection" ... safe="1"`) — a type-
|
|
978
|
+
// coercion mitigation this engine had no mechanism for at all before this
|
|
979
|
+
// fix, confirmed via a direct, from-scratch `runScan()` reproduction of this
|
|
980
|
+
// exact real-generator shape (not corpus/gold access).
|
|
981
|
+
//
|
|
982
|
+
// Positional-vs-explicit argnum handling matches PHP's own sprintf spec
|
|
983
|
+
// (`%2$d` explicitly names argument 2, 1-based); an unrecognized/malformed
|
|
984
|
+
// conversion is conservatively treated as unsafe (not added to the safe
|
|
985
|
+
// set) rather than guessed.
|
|
986
|
+
const _SPRINTF_SAFE_NUMERIC_SPECS = new Set(['d', 'u', 'f', 'F', 'e', 'E', 'g', 'G', 'x', 'X', 'o', 'b']);
|
|
987
|
+
const _SPRINTF_CONVERSION_RE = /%(\d+\$)?[-+ 0']*(\d+)?(\.\d+)?([bcdeEfFgGosuxX%])/g;
|
|
988
|
+
function _sprintfSafeArgIndices(fmt) {
|
|
989
|
+
if (typeof fmt !== 'string') return null;
|
|
990
|
+
const safe = new Set();
|
|
991
|
+
let positional = 0;
|
|
992
|
+
const re = new RegExp(_SPRINTF_CONVERSION_RE.source, 'g');
|
|
993
|
+
let m;
|
|
994
|
+
while ((m = re.exec(fmt))) {
|
|
995
|
+
const spec = m[4];
|
|
996
|
+
if (spec === '%') continue; // literal %% consumes no argument
|
|
997
|
+
const idx = m[1] ? (parseInt(m[1], 10) - 1) : positional++;
|
|
998
|
+
if (_SPRINTF_SAFE_NUMERIC_SPECS.has(spec)) safe.add(idx);
|
|
999
|
+
}
|
|
1000
|
+
return safe;
|
|
1001
|
+
}
|
|
1002
|
+
// Numeric coercion defeats SYNTAX-ESCAPING injection (SQL/XPath/LDAP/
|
|
1003
|
+
// command/eval all require the attacker to inject a quote/semicolon/
|
|
1004
|
+
// metacharacter to break out of a string literal — impossible once the
|
|
1005
|
+
// value is pure digits) but does NOT defeat RESOURCE-SELECTION vulnerabilities
|
|
1006
|
+
// (file inclusion, path traversal): a purely numeric value can still select
|
|
1007
|
+
// an unintended resource among many (`pages/5.php` vs `pages/999.php`), so
|
|
1008
|
+
// coercion alone does not make it safe. Confirmed via the SAME public
|
|
1009
|
+
// generator source this fix already relies on: `bin/XML/construction.xml`
|
|
1010
|
+
// marks `$var = include(sprintf("pages/'%d'.php", $tainted));` UNSAFE
|
|
1011
|
+
// (`safe="0"`) even though the value is %d-coerced — found via a real,
|
|
1012
|
+
// reproducible regression (CWE-98 tp -1) during this fix's own real-corpus
|
|
1013
|
+
// verification. `_isCoercionCall`'s existing intval/floatval mechanism has
|
|
1014
|
+
// the IDENTICAL blind spot already (confirmed via direct reproduction:
|
|
1015
|
+
// `include("pages/" . intval($_GET['page']) . ".php")` already produces
|
|
1016
|
+
// zero findings before this change) — this fix does not introduce a new
|
|
1017
|
+
// risk class, but it's common enough via sprintf specifically to be worth
|
|
1018
|
+
// closing here: skip the coercion whenever the format string's STATIC
|
|
1019
|
+
// (non-conversion) text looks like a file-path template, a narrow,
|
|
1020
|
+
// principled heuristic (a `/` path separator, or a trailing `.ext`-shaped
|
|
1021
|
+
// suffix) rather than a benchmark-fitted exclusion.
|
|
1022
|
+
function _sprintfLooksLikeFilePath(fmt) {
|
|
1023
|
+
const stripped = fmt.replace(new RegExp(_SPRINTF_CONVERSION_RE.source, 'g'), '');
|
|
1024
|
+
if (stripped.includes('/')) return true;
|
|
1025
|
+
return /\.[A-Za-z0-9]{1,5}['")\]]*\s*$/.test(stripped);
|
|
1026
|
+
}
|
|
1027
|
+
// `argExprIndex` is the index within `expr.args` (0 = the format string
|
|
1028
|
+
// itself, 1 = the first value argument) — matches how callers already index
|
|
1029
|
+
// `expr.args` elsewhere in this file (e.g. `_literalArgSatisfied`).
|
|
1030
|
+
function _isSprintfSafeArg(expr, argExprIndex) {
|
|
1031
|
+
if (!expr || expr.kind !== 'call' || argExprIndex < 1) return false;
|
|
1032
|
+
const name = typeof expr.callee === 'string' ? expr.callee.split('.').pop() : null;
|
|
1033
|
+
if (name !== 'sprintf') return false;
|
|
1034
|
+
const fmtArg = (expr.args || [])[0];
|
|
1035
|
+
if (!fmtArg || fmtArg.kind !== 'literal' || typeof fmtArg.value !== 'string') return false;
|
|
1036
|
+
if (_sprintfLooksLikeFilePath(fmtArg.value)) return false;
|
|
1037
|
+
const safe = _sprintfSafeArgIndices(fmtArg.value);
|
|
1038
|
+
return !!safe && safe.has(argExprIndex - 1);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function _isCoercionCall(expr) {
|
|
1042
|
+
if (!expr || expr.kind !== 'call' || !expr.callee) return false;
|
|
1043
|
+
const cat = matchSinkOrSanitizer(expr.callee, _currentFile);
|
|
1044
|
+
if (!Array.isArray(cat)) return false;
|
|
1045
|
+
return cat.some(e => e.kind === 'sanitizer' && Array.isArray(e.appliesTo) && e.appliesTo.includes('*')
|
|
1046
|
+
&& (!e.match || !e.match.requireLiteralArg || _literalArgSatisfied(expr.args, e.match.requireLiteralArg)));
|
|
1047
|
+
}
|
|
1048
|
+
|
|
687
1049
|
// cat / argTaints: the result of _matchCallCatalog (computed by the caller,
|
|
688
1050
|
// at whatever point in its case is appropriate for its own state-mutation
|
|
689
1051
|
// ordering).
|
|
@@ -720,11 +1082,7 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
|
|
|
720
1082
|
// is NOT satisfied, same direction proof-gate.js and every other
|
|
721
1083
|
// precision annotator in this codebase take when evidence is
|
|
722
1084
|
// simply unavailable rather than affirmatively clean.
|
|
723
|
-
if (e.match && e.match.requireLiteralArg)
|
|
724
|
-
const { index, pattern } = e.match.requireLiteralArg;
|
|
725
|
-
const checkArg = (argExprs || [])[index];
|
|
726
|
-
if (!checkArg || checkArg.kind !== 'literal' || !new RegExp(pattern).test(String(checkArg.value))) continue;
|
|
727
|
-
}
|
|
1085
|
+
if (e.match && e.match.requireLiteralArg && !_literalArgSatisfied(argExprs, e.match.requireLiteralArg)) continue;
|
|
728
1086
|
// `match.requireKeyword` — the KEYWORD-argument analogue of the
|
|
729
1087
|
// positional gate above, for languages where the dangerous form is
|
|
730
1088
|
// selected by a named argument rather than a positional literal.
|
|
@@ -897,7 +1255,7 @@ function step(node, stateIn, callContext) {
|
|
|
897
1255
|
return { state, findings };
|
|
898
1256
|
|
|
899
1257
|
case 'assign': {
|
|
900
|
-
const src = exprIsSource(node.source);
|
|
1258
|
+
const src = exprIsSource(node.source, callContext);
|
|
901
1259
|
const target = typeof node.target === 'string' ? node.target : null;
|
|
902
1260
|
// Sink matching is additive to this case's existing source/target/taint
|
|
903
1261
|
// handling below: an assignment's RHS can itself be a sink call (e.g.
|
|
@@ -922,7 +1280,19 @@ function step(node, stateIn, callContext) {
|
|
|
922
1280
|
// (lhsPath in parser-js.js); a bare identifier target ("x") has no
|
|
923
1281
|
// dot and _matchMemberWriteSink correctly returns null for it.
|
|
924
1282
|
if (target && target.includes('.')) {
|
|
925
|
-
|
|
1283
|
+
// PRD SARD_80_F1 §9/§15.1: a bare, non-dotted receiver segment
|
|
1284
|
+
// (`searcher` in `searcher.Filter`) can be CHA-typed exactly like
|
|
1285
|
+
// `_receiverTypeFor` types a call receiver — same classOfVar lookup,
|
|
1286
|
+
// same "only a plain local var-name assigned `new X()`" scope. This
|
|
1287
|
+
// lets a write-sink entry's `receiverTypeIn` fire on the real
|
|
1288
|
+
// allocation type when the variable's NAME doesn't match its
|
|
1289
|
+
// `receiver` regex (renamed identifiers, or just a different naming
|
|
1290
|
+
// convention than the one the regex was written against).
|
|
1291
|
+
const _receiverVar = target.slice(0, target.lastIndexOf('.'));
|
|
1292
|
+
const _memberReceiverType = (_receiverVar && !_receiverVar.includes('.') && callContext && callContext._cha)
|
|
1293
|
+
? classOfVar(callContext._cha, _currentFile, callContext._currentFnQid, _receiverVar)
|
|
1294
|
+
: null;
|
|
1295
|
+
const _memberHits = matchMemberWriteSink(target, _currentFile, _memberReceiverType);
|
|
926
1296
|
if (_memberHits && exprTaint(node.source, state, callContext)) {
|
|
927
1297
|
findings.push(..._memberWriteSinkFindings(
|
|
928
1298
|
_memberHits, node.source, state, callContext, node.line, target));
|
|
@@ -947,11 +1317,6 @@ function step(node, stateIn, callContext) {
|
|
|
947
1317
|
if (_unsan.size) _unByVar.set(target, _unsan);
|
|
948
1318
|
else _unByVar.delete(target);
|
|
949
1319
|
}
|
|
950
|
-
// Constant propagation: track variables assigned from literals
|
|
951
|
-
if (target && _activeConstantVars) {
|
|
952
|
-
if (node.source && node.source.kind === 'literal') _activeConstantVars.set(target, node.source.value);
|
|
953
|
-
else _activeConstantVars.delete(target);
|
|
954
|
-
}
|
|
955
1320
|
let newState = state;
|
|
956
1321
|
// Premortem #7: interprocedural return-taint via SummaryCache. If the
|
|
957
1322
|
// RHS is a call to a known callee whose empty-entry-state summary says
|
|
@@ -995,6 +1360,7 @@ function step(node, stateIn, callContext) {
|
|
|
995
1360
|
return {
|
|
996
1361
|
returnTainted: !!inner._returnTainted,
|
|
997
1362
|
mutatedParams: inner._mutatedParamsOut || new Set(),
|
|
1363
|
+
mutatedThisFields: inner._mutatedThisFieldsOut || new Set(),
|
|
998
1364
|
taintedGlobals: new Set(),
|
|
999
1365
|
// Real findings from the callee's own body — e.g.
|
|
1000
1366
|
// `function makeQuery(id){ db.query(...id) } ... makeQuery(uid)`
|
|
@@ -1009,7 +1375,12 @@ function step(node, stateIn, callContext) {
|
|
|
1009
1375
|
});
|
|
1010
1376
|
}
|
|
1011
1377
|
_mergeSummaryFindings(callContext, callContext._currentFnQid, sum, 'interproc');
|
|
1012
|
-
|
|
1378
|
+
// Next-gen taint capability #3: `x = bad.getData()` — a single-
|
|
1379
|
+
// field getter's return is tainted whenever THIS call's own
|
|
1380
|
+
// receiver already carries that field, independent of `sum`
|
|
1381
|
+
// (a zero-param getter's entry-state summary can never see it).
|
|
1382
|
+
const _getterTainted = _calleeGetterFieldTainted(node.source.callee, newState, callContext);
|
|
1383
|
+
if ((sum && sum.returnTainted) || _getterTainted) {
|
|
1013
1384
|
newState = _addPathAliasAware(newState, target, callContext);
|
|
1014
1385
|
callContext._taintSources.push({
|
|
1015
1386
|
varName: target,
|
|
@@ -1019,13 +1390,17 @@ function step(node, stateIn, callContext) {
|
|
|
1019
1390
|
line: node.line,
|
|
1020
1391
|
});
|
|
1021
1392
|
}
|
|
1022
|
-
// applyAtCallSite — mutated params
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1393
|
+
// applyAtCallSite — mutated params (and, next-gen capability #3,
|
|
1394
|
+
// the implicit `this` receiver's own mutated fields) propagate to
|
|
1395
|
+
// the caller's state.
|
|
1396
|
+
if (sum && ((sum.mutatedParams && sum.mutatedParams.size && paramNames.length) || (sum.mutatedThisFields && sum.mutatedThisFields.size))) {
|
|
1397
|
+
const receiverPath = _receiverAccessPathOf(node.source.callee);
|
|
1398
|
+
const applied = callContext._summaryCache.applyAtCallSite(
|
|
1399
|
+
sum, paramNames, callArgs, callerTainted, receiverPath);
|
|
1400
|
+
for (const v of applied.mutated) newState = addPath(newState, v);
|
|
1401
|
+
for (const p of applied.mutatedThisPaths) newState = addPath(newState, p);
|
|
1027
1402
|
}
|
|
1028
|
-
if (sum && sum.returnTainted) return { state: newState, findings };
|
|
1403
|
+
if ((sum && sum.returnTainted) || _getterTainted) return { state: newState, findings };
|
|
1029
1404
|
} else if (target && calleeName) {
|
|
1030
1405
|
// Fallback: check builtin summaries for unresolved external calls
|
|
1031
1406
|
const builtin = lookupBuiltinSummary(calleeName);
|
|
@@ -1066,7 +1441,12 @@ function step(node, stateIn, callContext) {
|
|
|
1066
1441
|
}
|
|
1067
1442
|
}
|
|
1068
1443
|
}
|
|
1069
|
-
if (
|
|
1444
|
+
if (target && _isCoercionCall(node.source)) {
|
|
1445
|
+
// See _isCoercionCall's header comment: a real type coercion kills
|
|
1446
|
+
// taint outright, unconditionally of sink family — the one case in
|
|
1447
|
+
// this engine where a sanitizer does more than demote.
|
|
1448
|
+
newState = removePathAndDescendants(newState, target);
|
|
1449
|
+
} else if (src && target) {
|
|
1070
1450
|
newState = _addPathAliasAware(newState, target, callContext);
|
|
1071
1451
|
const sourcePath = accessPathOf(node.source);
|
|
1072
1452
|
if (sourcePath) newState = addPath(newState, sourcePath);
|
|
@@ -1129,6 +1509,7 @@ function step(node, stateIn, callContext) {
|
|
|
1129
1509
|
return {
|
|
1130
1510
|
returnTainted: !!inner._returnTainted,
|
|
1131
1511
|
mutatedParams: inner._mutatedParamsOut || new Set(),
|
|
1512
|
+
mutatedThisFields: inner._mutatedThisFieldsOut || new Set(),
|
|
1132
1513
|
taintedGlobals: new Set(),
|
|
1133
1514
|
// See the sibling assign-call-site compute() above — same
|
|
1134
1515
|
// fix, same reason: this callee's own findings were
|
|
@@ -1138,10 +1519,17 @@ function step(node, stateIn, callContext) {
|
|
|
1138
1519
|
});
|
|
1139
1520
|
}
|
|
1140
1521
|
_mergeSummaryFindings(callContext, callContext._currentFnQid, sum, 'interproc');
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1522
|
+
// Next-gen taint capability #3: the plain-statement-position
|
|
1523
|
+
// counterpart of the assign-from-call site above — `bad.setData(
|
|
1524
|
+
// tainted);` never assigns anything, but the receiver's OWN field
|
|
1525
|
+
// still needs tainting so a LATER `bad.getData()`/`bad.data` read
|
|
1526
|
+
// sees it.
|
|
1527
|
+
if (sum && ((sum.mutatedParams && sum.mutatedParams.size) || (sum.mutatedThisFields && sum.mutatedThisFields.size))) {
|
|
1528
|
+
const receiverPath = _receiverAccessPathOf(node.callee);
|
|
1529
|
+
const applied = callContext._summaryCache.applyAtCallSite(
|
|
1530
|
+
sum, paramNames, node.args || [], state, receiverPath);
|
|
1531
|
+
for (const v of applied.mutated) state = addPath(state, v);
|
|
1532
|
+
for (const p of applied.mutatedThisPaths) state = addPath(state, p);
|
|
1145
1533
|
}
|
|
1146
1534
|
}
|
|
1147
1535
|
}
|
|
@@ -1194,7 +1582,28 @@ function step(node, stateIn, callContext) {
|
|
|
1194
1582
|
// target, so it reaches this rule instead of the assign path. The
|
|
1195
1583
|
// matching read lowers to `bag.[]`, already covered by the tainted
|
|
1196
1584
|
// receiver prefix.
|
|
1197
|
-
|
|
1585
|
+
// Case-insensitive: C#/Java collection APIs use PascalCase (`List.Add`,
|
|
1586
|
+
// `Dictionary.Insert`, `Stack.Push`) while JS/Python/Ruby use lowerCamel
|
|
1587
|
+
// (`push`, `append`, `add`) — a case-sensitive regex silently matched
|
|
1588
|
+
// only the latter group, so C#'s `list.Add(data)` (Juliet flow variants
|
|
1589
|
+
// 71-74, container-element taint) never registered as a mutator at all.
|
|
1590
|
+
// Widening to case-insensitive only ADDS matches (recall-preserving);
|
|
1591
|
+
// no existing lowercase mutator name collides with an unrelated,
|
|
1592
|
+
// dangerous PascalCase method by accident (checked against the C#/Java
|
|
1593
|
+
// catalog sink list). `push_str`/`extend_from_slice` are Rust's
|
|
1594
|
+
// String/Vec appenders (`push`/`extend`/`insert` already cover the
|
|
1595
|
+
// other languages and apply to Rust unchanged).
|
|
1596
|
+
// SARD_80_F1 W2.3: `addElement` (java.util.Vector's pre-Collections-
|
|
1597
|
+
// Framework method, still idiomatic in older code — Juliet's own age)
|
|
1598
|
+
// and `offer`/`offerFirst`/`offerLast` (the Queue/Deque interface's
|
|
1599
|
+
// mutator, implemented by ArrayDeque/LinkedList/PriorityQueue) were
|
|
1600
|
+
// both missing — found via direct probes showing `Vector.addElement`/
|
|
1601
|
+
// `ArrayDeque.offer` silently failed to taint their receiver while
|
|
1602
|
+
// every sibling API (`Vector`'s modern `add`, `Stack.push`,
|
|
1603
|
+
// `Hashtable.put`, `Properties.setProperty`, `TreeMap.put`) already
|
|
1604
|
+
// worked, an inconsistency with no principled reason — all six are
|
|
1605
|
+
// the same "write one element into a collection" shape.
|
|
1606
|
+
const _MUTATORS = /^(?:push|unshift|splice|fill|copyWithin|set|add|addElement|append|extend|insert|update|addAll|putAll|put|addrange|enqueue|offer|offerFirst|offerLast|__setitem__|push_str|extend_from_slice)$/i;
|
|
1198
1607
|
// Mutate the state Set IN PLACE (the binding is const; the call case
|
|
1199
1608
|
// returns this same Set ref). Avoids touching the unrelated
|
|
1200
1609
|
// mutated-param paths in this case, keeping the blast radius to
|
|
@@ -1204,9 +1613,28 @@ function step(node, stateIn, callContext) {
|
|
|
1204
1613
|
&& _MUTATORS.test(node.callee.prop)) {
|
|
1205
1614
|
_recv = accessPathOf(node.callee.object);
|
|
1206
1615
|
} else if (typeof _plainCallCalleeName === 'string') {
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1616
|
+
// SARD_80_F1 W2.3 follow-up: a REPEATED-method fluent chain
|
|
1617
|
+
// (`sb.append(a).append(b).append(c)`, StringBuilder/StringBuffer's
|
|
1618
|
+
// own idiomatic multi-line-SQL-building shape) dot-joins to
|
|
1619
|
+
// "StringBuilder.append.append.append" via the hand-rolled/CST
|
|
1620
|
+
// chain-flattening convention every frontend in this codebase
|
|
1621
|
+
// uses (correct for a chain ending in a DIFFERENT terminal method,
|
|
1622
|
+
// e.g. `DocumentBuilderFactory.newInstance().newDocumentBuilder()`
|
|
1623
|
+
// — wrong here, since `.append()` returning `this` means every
|
|
1624
|
+
// segment is really the SAME receiver, not a new one). Naively
|
|
1625
|
+
// taking everything before the LAST dot recovers
|
|
1626
|
+
// "StringBuilder.append.append" as the "receiver" — an
|
|
1627
|
+
// ever-growing, never-matching key that silently drops the
|
|
1628
|
+
// mutator's own taint tracking for every chained-append call.
|
|
1629
|
+
// Strip ALL trailing segments that repeat the SAME mutator method
|
|
1630
|
+
// name first, so `X.append.append.append` and plain `X.append`
|
|
1631
|
+
// resolve to the identical, real receiver "X".
|
|
1632
|
+
const _segs = _plainCallCalleeName.split('.');
|
|
1633
|
+
let _end = _segs.length - 1;
|
|
1634
|
+
const _lastSeg = _segs[_end];
|
|
1635
|
+
if (_end > 0 && _MUTATORS.test(_lastSeg)) {
|
|
1636
|
+
while (_end > 0 && _segs[_end] === _lastSeg) _end--;
|
|
1637
|
+
if (_end >= 0) _recv = _segs.slice(0, _end + 1).join('.');
|
|
1210
1638
|
}
|
|
1211
1639
|
}
|
|
1212
1640
|
if (_recv) state.add(_recv);
|
|
@@ -1271,7 +1699,19 @@ function step(node, stateIn, callContext) {
|
|
|
1271
1699
|
case 'if': {
|
|
1272
1700
|
// Path-feasibility lite: if the condition is a literal false / unreachable,
|
|
1273
1701
|
// mark the node so the CFG walker can skip the consequent edge.
|
|
1274
|
-
// For now we simply propagate state to both branches
|
|
1702
|
+
// For now we simply propagate state to both branches — EXCEPT for a
|
|
1703
|
+
// recognized guard-predicate condition (SARD_80_F1 W3.2), where the
|
|
1704
|
+
// TRUE branch specifically gets a narrowed (un-tainted-on-the-guarded-
|
|
1705
|
+
// var) state via `succOverride`; the false branch and any code after
|
|
1706
|
+
// an if-with-no-else still see the original, unnarrowed `state`. Per
|
|
1707
|
+
// this codebase's established CFG convention (path-feasibility.js),
|
|
1708
|
+
// `succ[0]` is the THEN edge and `succ[1+]` are the ELSE/fall-through
|
|
1709
|
+
// edge(s).
|
|
1710
|
+
const narrowedVar = _guardNarrowsVar(node.cond, state) || _regexTestGuardNarrowsVar(node.cond, state);
|
|
1711
|
+
if (narrowedVar && node.succ && node.succ.length > 0) {
|
|
1712
|
+
const thenState = removePathAndDescendants(state, narrowedVar);
|
|
1713
|
+
return { state, findings, succOverride: new Map([[node.succ[0], thenState]]) };
|
|
1714
|
+
}
|
|
1275
1715
|
return { state, findings };
|
|
1276
1716
|
}
|
|
1277
1717
|
|
|
@@ -1337,12 +1777,28 @@ function analyzeFunction(fn, entryState, callContext) {
|
|
|
1337
1777
|
// No-op for the key unless AGENTIC_SECURITY_KCFA_CALLSTRING=1. Restored below.
|
|
1338
1778
|
const _prevCallerCtx = (callContext && callContext._summaryCache && callContext._summaryCache.setCallerContext)
|
|
1339
1779
|
? callContext._summaryCache.setCallerContext(fn.qid) : undefined;
|
|
1780
|
+
// SARD_80_F1 W4.C21/C22 — `_currentFile` (module-level, set just below) was
|
|
1781
|
+
// never saved/restored around a NESTED analyzeFunction call the way
|
|
1782
|
+
// `_prevCallerCtx` already is on the line above. A cross-FILE call site
|
|
1783
|
+
// (e.g. Bad() in file A calling a summary-computed callee in file B)
|
|
1784
|
+
// recurses into analyzeFunction(calleeFn, ..., inner) to compute the
|
|
1785
|
+
// callee's summary — that inner call sets `_currentFile = calleeFn.file`
|
|
1786
|
+
// (file B) and, until this fix, never set it back, so every subsequent
|
|
1787
|
+
// catalog lookup (`matchSinkOrSanitizer`/`matchSource`/`classOfVar`) in
|
|
1788
|
+
// the OUTER function's own remaining CFG nodes ran against the WRONG
|
|
1789
|
+
// file's language scoping for the rest of that outer function's walk.
|
|
1790
|
+
// Confirmed via a same-file-vs-cross-file A/B reproduction of a real
|
|
1791
|
+
// corpus false positive (see W4.C20/C21/C22) that isolated this leak as
|
|
1792
|
+
// one of two compounding defects. Saving/restoring here — the exact
|
|
1793
|
+
// pattern `_prevCallerCtx` already uses — makes `_currentFile` correctly
|
|
1794
|
+
// scoped to this function's own analysis regardless of what any callee's
|
|
1795
|
+
// nested summary computation does to it in between.
|
|
1796
|
+
const _prevFile = _currentFile;
|
|
1340
1797
|
const work = [];
|
|
1341
1798
|
const inStates = new Map();
|
|
1342
1799
|
const outStates = new Map();
|
|
1343
1800
|
inStates.set(fn.cfg.entry, new Set(entryState));
|
|
1344
1801
|
work.push(fn.cfg.entry);
|
|
1345
|
-
_activeConstantVars = new Map();
|
|
1346
1802
|
_currentFile = fn.file || null;
|
|
1347
1803
|
// v0.70 #2 — points-to context for the step() transfer. Setting it here
|
|
1348
1804
|
// (instead of plumbing through step's signature) keeps the worklist loop
|
|
@@ -1363,15 +1819,32 @@ function analyzeFunction(fn, entryState, callContext) {
|
|
|
1363
1819
|
const node = nodes[nid];
|
|
1364
1820
|
if (!node) continue;
|
|
1365
1821
|
const incoming = inStates.get(nid) || new Set();
|
|
1366
|
-
const { state: out, findings } = step(node, incoming, callContext);
|
|
1367
|
-
|
|
1822
|
+
const { state: out, findings, succOverride } = step(node, incoming, callContext);
|
|
1823
|
+
// SARD_80_F1 W4.C21/C22: stamp the file THIS finding actually belongs to
|
|
1824
|
+
// (fn's own file) at the moment of discovery — see `_collectFindings`'s
|
|
1825
|
+
// header comment for why this matters. `step()`/`_sinkFindingsForCall`
|
|
1826
|
+
// never set `file` themselves (only `line`), so every finding reaching
|
|
1827
|
+
// this line was, until now, implicitly reattributed later to whichever
|
|
1828
|
+
// function `_collectFindings` happened to be called with — correct only
|
|
1829
|
+
// when that's the SAME function, which a cross-file interprocedural
|
|
1830
|
+
// merge (`_mergeSummaryFindings`) is specifically the case where it is
|
|
1831
|
+
// NOT.
|
|
1832
|
+
callContext._findings.push(...findings.map(f => ({ ...f, _funcQid: fn.qid, file: f.file || fn.file })));
|
|
1368
1833
|
const prevOut = outStates.get(nid);
|
|
1369
1834
|
const merged = mergeStates(prevOut, out);
|
|
1370
1835
|
if (!prevOut || !stateEq(prevOut, merged)) {
|
|
1371
1836
|
outStates.set(nid, merged);
|
|
1372
1837
|
for (const s of (node.succ || [])) {
|
|
1838
|
+
// SARD_80_F1 W3.2 — guard-predicate narrowing (case 'if' above): a
|
|
1839
|
+
// specific successor edge (the guard's TRUE branch) can carry a
|
|
1840
|
+
// NARROWER state than every other edge out of this node. `out` (not
|
|
1841
|
+
// `merged`) is what `succOverride` was derived from, and `out` is a
|
|
1842
|
+
// pure function of `incoming`, so gating this on `merged`'s own
|
|
1843
|
+
// convergence check above is sound — an unchanged `merged` means an
|
|
1844
|
+
// unchanged `incoming` means an unchanged override too.
|
|
1845
|
+
const edgeState = (succOverride && succOverride.has(s)) ? succOverride.get(s) : merged;
|
|
1373
1846
|
const succIn = inStates.get(s);
|
|
1374
|
-
const newIn = mergeStates(succIn,
|
|
1847
|
+
const newIn = mergeStates(succIn, edgeState);
|
|
1375
1848
|
if (!succIn || !stateEq(succIn, newIn)) {
|
|
1376
1849
|
inStates.set(s, newIn);
|
|
1377
1850
|
work.push(s);
|
|
@@ -1414,7 +1887,7 @@ function analyzeFunction(fn, entryState, callContext) {
|
|
|
1414
1887
|
reportedNids.add(nid);
|
|
1415
1888
|
callContext._findings.push({
|
|
1416
1889
|
...createImplicitFinding(node, conditionLabel),
|
|
1417
|
-
_funcQid: fn.qid, sinkId: sink.id,
|
|
1890
|
+
_funcQid: fn.qid, sinkId: sink.id, file: fn.file,
|
|
1418
1891
|
cwe: (sink.vuln && sink.vuln.cwe) || 'CWE-200',
|
|
1419
1892
|
});
|
|
1420
1893
|
};
|
|
@@ -1451,6 +1924,13 @@ function analyzeFunction(fn, entryState, callContext) {
|
|
|
1451
1924
|
}
|
|
1452
1925
|
|
|
1453
1926
|
const exit = outStates.get(fn.cfg.exit) || new Set();
|
|
1927
|
+
// Real cross-method field taint (SARD_80_F1_SCANNER_PRD.md, Juliet flow
|
|
1928
|
+
// variants 45/65-68): the runTaintEngine pre-pass needs the RAW exit state
|
|
1929
|
+
// (every tainted access path, not just the ones matching a declared param)
|
|
1930
|
+
// to tell whether THIS function's own body genuinely taints a class field —
|
|
1931
|
+
// see the pre-pass loop below for why this must be the real exit state, not
|
|
1932
|
+
// a guess.
|
|
1933
|
+
if (callContext) callContext._exitState = exit;
|
|
1454
1934
|
// v0.66 — record which params are tainted at function exit so the
|
|
1455
1935
|
// caller's applyAtCallSite can propagate that mutated taint back. We
|
|
1456
1936
|
// intersect the exit-state with the function's declared params (only
|
|
@@ -1461,10 +1941,26 @@ function analyzeFunction(fn, entryState, callContext) {
|
|
|
1461
1941
|
if (isCoveredBy(exit, p)) callContext._mutatedParamsOut.add(p);
|
|
1462
1942
|
}
|
|
1463
1943
|
}
|
|
1944
|
+
// Next-gen taint capability #3 — treat `this` as an implicit mutable
|
|
1945
|
+
// parameter, mirroring the block just above exactly: extract every field
|
|
1946
|
+
// name this function's own exit state taints on its receiver (any of the
|
|
1947
|
+
// three `this` spellings this codebase's frontends use), so the caller can
|
|
1948
|
+
// propagate that mutation onto ITS OWN receiver variable's access path
|
|
1949
|
+
// (`bad.data`), not a class-wide bucket. See this file's own header
|
|
1950
|
+
// comment above `_thisFieldGetterName` for the full rationale.
|
|
1951
|
+
if (callContext) {
|
|
1952
|
+
if (!callContext._mutatedThisFieldsOut) callContext._mutatedThisFieldsOut = new Set();
|
|
1953
|
+
for (const p of exit) {
|
|
1954
|
+
const m = _THIS_FIELD_PREFIX_RE.exec(p);
|
|
1955
|
+
if (m) callContext._mutatedThisFieldsOut.add(m[1]);
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1464
1958
|
// R2: restore the caller context for the enclosing function's analysis.
|
|
1465
1959
|
if (_prevCallerCtx !== undefined && callContext && callContext._summaryCache && callContext._summaryCache.setCallerContext) {
|
|
1466
1960
|
callContext._summaryCache.setCallerContext(_prevCallerCtx);
|
|
1467
1961
|
}
|
|
1962
|
+
// W4.C21/C22: restore _currentFile — see the save at this function's start.
|
|
1963
|
+
_currentFile = _prevFile;
|
|
1468
1964
|
return exit;
|
|
1469
1965
|
}
|
|
1470
1966
|
|
|
@@ -1521,12 +2017,40 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
1521
2017
|
const fnList = [...callGraph.functions.values()].sort((a, b) =>
|
|
1522
2018
|
a.qid < b.qid ? -1 : a.qid > b.qid ? 1 : 0
|
|
1523
2019
|
);
|
|
2020
|
+
// PRD W0.1 (SARD_80_F1_EXECUTION_PRD.md) — the main per-function loop below
|
|
2021
|
+
// silently `break`s once `n > fnLimit` (default 5000), which for a single
|
|
2022
|
+
// large SARD CWE directory (Java CWE-89 alone: 3668 files, 17604
|
|
2023
|
+
// functions) means roughly 70% of the corpus is never analyzed at all, no
|
|
2024
|
+
// matter how correct the resolution logic is for the functions it DOES
|
|
2025
|
+
// reach. This was completely invisible to every existing truncation
|
|
2026
|
+
// signal (wall-clock budget, per-file timeout/skip counts) — found while
|
|
2027
|
+
// investigating why a real, verified class-resolution fix (W1) showed
|
|
2028
|
+
// almost no aggregate SARD recall movement despite fixing individual
|
|
2029
|
+
// fixtures in isolation. Surfaced the same way the wall-clock budget
|
|
2030
|
+
// already is: a single info finding, not a return-shape change (this
|
|
2031
|
+
// function's return type — a flat array — is a wide, load-bearing
|
|
2032
|
+
// contract; changing it would touch every caller).
|
|
2033
|
+
if (fnList.length > fnLimit) {
|
|
2034
|
+
all.push({
|
|
2035
|
+
id: `ir-taint-fn-limit:${fnList[0] ? fnList[0].qid.split('::')[0] : ''}`,
|
|
2036
|
+
file: '(deep-engine)', line: 0,
|
|
2037
|
+
vuln: `IR-TAINT deep mode analyzed only ${fnLimit}/${fnList.length} functions (AGENTIC_SECURITY_DEEP_FN_LIMIT) — results are incomplete`,
|
|
2038
|
+
severity: 'info',
|
|
2039
|
+
parser: 'IR-TAINT',
|
|
2040
|
+
confidence: 0.5,
|
|
2041
|
+
});
|
|
2042
|
+
}
|
|
1524
2043
|
// Pre-pass + fixed-point: compute empty-entry-state summaries for every
|
|
1525
2044
|
// function, then re-run the pre-pass until the summary cache stabilizes
|
|
1526
2045
|
// (capped at MAX_FP_ITERS so recursion and chains converge without
|
|
1527
2046
|
// unbounded blowup). v0.66 — the inner ctx now records mutatedParams
|
|
1528
2047
|
// via _mutatedParamsOut so cross-function param mutation propagates.
|
|
1529
2048
|
const MAX_FP_ITERS = 3;
|
|
2049
|
+
// qid -> this function's own exit-state (every tainted access path at
|
|
2050
|
+
// return, from the LAST pre-pass iteration only — see the class-field
|
|
2051
|
+
// cross-taint pass below for why this must be a real, already-observed
|
|
2052
|
+
// exit state rather than a re-derived guess).
|
|
2053
|
+
const exitStateByQid = new Map();
|
|
1530
2054
|
for (let it = 0; it < MAX_FP_ITERS; it++) {
|
|
1531
2055
|
if (Date.now() > deadlineMs) break;
|
|
1532
2056
|
// Tracks whether this iteration actually changed any cached summary's
|
|
@@ -1557,9 +2081,11 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
1557
2081
|
// (sinkId, file, line), so re-discovering the same empty-entry finding
|
|
1558
2082
|
// multiple times collapses to one reported finding, never a duplicate.
|
|
1559
2083
|
_collectFindings(fn, ctx._findings);
|
|
2084
|
+
exitStateByQid.set(fn.qid, ctx._exitState || new Set());
|
|
1560
2085
|
const next = {
|
|
1561
2086
|
returnTainted: !!ctx._returnTainted,
|
|
1562
2087
|
mutatedParams: ctx._mutatedParamsOut || new Set(),
|
|
2088
|
+
mutatedThisFields: ctx._mutatedThisFieldsOut || new Set(),
|
|
1563
2089
|
taintedGlobals: new Set(),
|
|
1564
2090
|
findings: [],
|
|
1565
2091
|
};
|
|
@@ -1590,26 +2116,143 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
1590
2116
|
// `changedThisIter` tracks actual value changes instead.
|
|
1591
2117
|
if (!changedThisIter) break;
|
|
1592
2118
|
}
|
|
1593
|
-
// Class-field cross-taint pass
|
|
1594
|
-
//
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
2119
|
+
// Class-field cross-taint pass (SARD_80_F1_SCANNER_PRD.md — Juliet flow
|
|
2120
|
+
// variants 45/65-68: a method writes a tainted value to a static or
|
|
2121
|
+
// instance field and a SIBLING method of the same class reads it into a
|
|
2122
|
+
// sink). The mechanism this replaced kept only the JS/Babel `_this_.field`
|
|
2123
|
+
// mutated-PARAM shape — but `_mutatedParamsOut` (above) only ever
|
|
2124
|
+
// intersects a function's own EXIT state against its declared PARAMS, and
|
|
2125
|
+
// a field is never a param, so that map was permanently empty for every
|
|
2126
|
+
// hand-rolled parser (C#, Java, PHP, Go, Ruby, Kotlin) — confirmed by
|
|
2127
|
+
// direct probing, not assumed: none of `sf = data;`, `this.inst = data;`
|
|
2128
|
+
// ever populated it, for any language.
|
|
2129
|
+
//
|
|
2130
|
+
// Real fix: `callContext._exitState` (stamped just above) is EVERY tainted
|
|
2131
|
+
// access path at a function's exit, not just the ones matching a param
|
|
2132
|
+
// name. Cross-reference it against the class's DECLARED FIELD NAMES (from
|
|
2133
|
+
// `ir.classes[].fields`, via `buildClassHierarchy` — only populated by a
|
|
2134
|
+
// parser that emits it; classes with no field list simply contribute
|
|
2135
|
+
// nothing here, which is a missed opportunity, never a wrong edge) to ask
|
|
2136
|
+
// "did this method's own body, taken alone, genuinely taint one of its
|
|
2137
|
+
// class's fields?" — a REAL, observed fact from the empty-entry pre-pass
|
|
2138
|
+
// above, not a guess. Both the bare form (`sf`) and the `this.`-qualified
|
|
2139
|
+
// form (`this.inst`, `_this_.inst`) are checked, since different parsers
|
|
2140
|
+
// lower a field write differently.
|
|
2141
|
+
const cha = opts._cha;
|
|
2142
|
+
const classTaintedFields = new Map(); // className -> Set(bareFieldName)
|
|
2143
|
+
// SARD_80_F1 W5.48 — per-WRITER record alongside the per-class union just
|
|
2144
|
+
// above: className -> field -> Set(qid of a method whose OWN exit state
|
|
2145
|
+
// taints this field). `classTaintedFields` only ever answers "is this
|
|
2146
|
+
// field EVER tainted somewhere in the class" (unioned across every
|
|
2147
|
+
// method), which is what made the cross-class pass below seed a reader
|
|
2148
|
+
// like `goodG2BSink()` as tainted even though its own real caller chain
|
|
2149
|
+
// (`goodG2B()`) only ever writes this field a LITERAL — Juliet's Flow
|
|
2150
|
+
// Variant 65-68 idiom (`_68a`/`_68b`) has bad()/goodG2B()/goodB2G() each
|
|
2151
|
+
// write the SAME static field then immediately call their OWN uniquely-
|
|
2152
|
+
// named sink (badSink/goodG2BSink/goodB2GSink), so "ever tainted
|
|
2153
|
+
// anywhere in the class" is true (via bad()) but says nothing about
|
|
2154
|
+
// which SPECIFIC caller reaches a SPECIFIC reader. This map lets the
|
|
2155
|
+
// cross-class pass ask the narrower, call-string-sensitive question
|
|
2156
|
+
// instead: "is EVERY discoverable direct caller of this reader a
|
|
2157
|
+
// confirmed NON-tainting writer of this field" — see its own use site.
|
|
2158
|
+
const fieldWriterQids = new Map();
|
|
2159
|
+
if (cha && cha.methodOwners && cha.classes) {
|
|
2160
|
+
for (const fn of fnList) {
|
|
2161
|
+
if (Date.now() > deadlineMs) break;
|
|
2162
|
+
const className = cha.methodOwners.get(fn.qid);
|
|
2163
|
+
if (!className) continue;
|
|
2164
|
+
const cls = cha.classes.get(className);
|
|
2165
|
+
const declaredFields = cls && cls.fields;
|
|
2166
|
+
if (!declaredFields || !declaredFields.size) continue;
|
|
2167
|
+
const exitState = exitStateByQid.get(fn.qid);
|
|
2168
|
+
if (!exitState || !exitState.size) continue;
|
|
2169
|
+
for (const field of declaredFields) {
|
|
2170
|
+
// SARD_80_F1 W5.10 — PHP's own `$this` reference keeps its `$`
|
|
2171
|
+
// sigil in every access path (`$this.field`, never bare `this.` —
|
|
2172
|
+
// parser-php.js's own `->`-to-`.` normalization, W5.9), a fourth
|
|
2173
|
+
// spelling alongside JS's `_this_.`/C#'s and Java's bare `this.`.
|
|
2174
|
+
//
|
|
2175
|
+
// SARD_80_F1 W5.17 — `isCoveredBy` deliberately never propagates UP
|
|
2176
|
+
// (its own doc: `set={"x.y.z"}` does NOT cover "x.y"), which is
|
|
2177
|
+
// correct for ordinary field reads but wrong here: an ARRAY-INDEXED
|
|
2178
|
+
// field write (`$this->input[1] = $_GET[...]`) taints the access
|
|
2179
|
+
// path `this.input.1`, not the bare `this.input`/`input` this loop
|
|
2180
|
+
// queries — so a getter method returning `$this->input[1]` (the
|
|
2181
|
+
// PHP-Vuln-test-suite-generator's own "object/Array" input-
|
|
2182
|
+
// indirection sample, confirmed via direct reproduction, not corpus
|
|
2183
|
+
// access) was invisible to this whole class-field pass, while the
|
|
2184
|
+
// scalar-field form one line below (`$this->input = $_GET[...]`)
|
|
2185
|
+
// already worked. `pathIsCoveredByPrefix(entry, field)` checks the
|
|
2186
|
+
// OPPOSITE direction — does `entry` sit AT OR BELOW `field` — which
|
|
2187
|
+
// is exactly "did the constructor taint SOME sub-path of this
|
|
2188
|
+
// field", independent of which specific index or nesting depth.
|
|
2189
|
+
const hasTaintedSubpath = (prefix) => { for (const p of exitState) { if (pathIsCoveredByPrefix(p, prefix)) return true; } return false; };
|
|
2190
|
+
if (isCoveredBy(exitState, field) || isCoveredBy(exitState, `this.${field}`) || isCoveredBy(exitState, `_this_.${field}`) || isCoveredBy(exitState, `$this.${field}`)
|
|
2191
|
+
|| hasTaintedSubpath(field) || hasTaintedSubpath(`this.${field}`) || hasTaintedSubpath(`_this_.${field}`) || hasTaintedSubpath(`$this.${field}`)) {
|
|
2192
|
+
if (!classTaintedFields.has(className)) classTaintedFields.set(className, new Set());
|
|
2193
|
+
classTaintedFields.get(className).add(field);
|
|
2194
|
+
if (!fieldWriterQids.has(className)) fieldWriterQids.set(className, new Map());
|
|
2195
|
+
const fw = fieldWriterQids.get(className);
|
|
2196
|
+
if (!fw.has(field)) fw.set(field, new Set());
|
|
2197
|
+
fw.get(field).add(fn.qid);
|
|
2198
|
+
}
|
|
1605
2199
|
}
|
|
1606
2200
|
}
|
|
1607
2201
|
}
|
|
1608
|
-
|
|
2202
|
+
// SARD_80_F1 W5.50 — same reverse-call-graph lookup the cross-class pass
|
|
2203
|
+
// below uses (`callGraph.callersOf`, already built once by `buildCallGraph`
|
|
2204
|
+
// — see that pass's own header comment for why this reads the existing
|
|
2205
|
+
// index rather than re-deriving one), hoisted up so the SAME-class pass
|
|
2206
|
+
// immediately below can ALSO apply call-string precision. This pass had
|
|
2207
|
+
// the IDENTICAL over-approximation `classTaintedFields`'s own header
|
|
2208
|
+
// comment describes for the cross-class case — a sibling method reached
|
|
2209
|
+
// ONLY via a confirmed non-tainting writer was still blanket-seeded as
|
|
2210
|
+
// tainted, just scoped to siblings of the SAME class instead of a
|
|
2211
|
+
// different one. Genuinely a rarer shape here (per this pass's OWN
|
|
2212
|
+
// original comment, most sibling methods have no discoverable caller/
|
|
2213
|
+
// callee edge to each other at all — that remains true and still fails
|
|
2214
|
+
// closed correctly), but real whenever a sibling reader is directly
|
|
2215
|
+
// called from a KNOWN writer method (mirrors the cross-class idiom, just
|
|
2216
|
+
// without the class boundary).
|
|
2217
|
+
const _callerQidSetCache = new Map(); // fn.qid -> Set(callerQid)|null, memoized
|
|
2218
|
+
const callersOfQid = (qid) => {
|
|
2219
|
+
if (_callerQidSetCache.has(qid)) return _callerQidSetCache.get(qid);
|
|
2220
|
+
const edges = callGraph && callGraph.callersOf && callGraph.callersOf.get(qid);
|
|
2221
|
+
const set = edges && edges.length ? new Set(edges.map((e) => e.caller).filter(Boolean)) : null;
|
|
2222
|
+
_callerQidSetCache.set(qid, set);
|
|
2223
|
+
return set;
|
|
2224
|
+
};
|
|
2225
|
+
for (const [className, fields] of classTaintedFields) {
|
|
1609
2226
|
if (Date.now() > deadlineMs) break;
|
|
1610
2227
|
for (const fn of fnList) {
|
|
1611
|
-
if (
|
|
1612
|
-
|
|
2228
|
+
if (cha.methodOwners.get(fn.qid) !== className) continue;
|
|
2229
|
+
|
|
2230
|
+
// Call-string precision (mirrors the cross-class pass below): narrow
|
|
2231
|
+
// `fields` to only the ones fn's own discoverable callers can
|
|
2232
|
+
// actually supply as tainted. Fails closed (keeps the field seeded)
|
|
2233
|
+
// whenever fn has no discoverable caller, a caller outside this
|
|
2234
|
+
// class, or any caller not positively confirmed as a non-tainting
|
|
2235
|
+
// writer — this can ONLY ever narrow what was already being seeded,
|
|
2236
|
+
// never widen it, so it cannot introduce a new false negative beyond
|
|
2237
|
+
// what the pre-existing blanket behavior already accepted.
|
|
2238
|
+
const callers = callersOfQid(fn.qid);
|
|
2239
|
+
let seedFields = fields;
|
|
2240
|
+
if (callers && callers.size) {
|
|
2241
|
+
const effectiveFields = new Set();
|
|
2242
|
+
for (const f of fields) {
|
|
2243
|
+
const writers = fieldWriterQids.get(className) && fieldWriterQids.get(className).get(f);
|
|
2244
|
+
let keep = false;
|
|
2245
|
+
for (const callerQid of callers) {
|
|
2246
|
+
if (writers && writers.has(callerQid)) { keep = true; break; }
|
|
2247
|
+
if (cha.methodOwners.get(callerQid) !== className) { keep = true; break; }
|
|
2248
|
+
}
|
|
2249
|
+
if (keep) effectiveFields.add(f);
|
|
2250
|
+
}
|
|
2251
|
+
seedFields = effectiveFields;
|
|
2252
|
+
}
|
|
2253
|
+
if (!seedFields.size) continue; // every discoverable caller of fn provably never taints any of these fields
|
|
2254
|
+
|
|
2255
|
+
if (summaryCache.has(fn.qid, seedFields)) continue;
|
|
1613
2256
|
const ctx = {
|
|
1614
2257
|
_findings: [], _taintSources: [], _returnTainted: false,
|
|
1615
2258
|
_stack: new Set(), deadlineMs,
|
|
@@ -1619,21 +2262,138 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
1619
2262
|
_cha: opts._cha,
|
|
1620
2263
|
_pointsTo: opts._pointsTo,
|
|
1621
2264
|
};
|
|
1622
|
-
|
|
1623
|
-
//
|
|
1624
|
-
//
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
//
|
|
1629
|
-
//
|
|
1630
|
-
//
|
|
1631
|
-
|
|
2265
|
+
// Seed every spelling of every tainted field so a reading method sees
|
|
2266
|
+
// the taint regardless of which form it uses to reference the field
|
|
2267
|
+
// — mirrors the write-side check above, `$this.` (PHP, W5.10) included.
|
|
2268
|
+
const seeded = new Set();
|
|
2269
|
+
for (const f of seedFields) { seeded.add(f); seeded.add(`this.${f}`); seeded.add(`_this_.${f}`); seeded.add(`$this.${f}`); }
|
|
2270
|
+
try { analyzeFunction(fn, _unionAnnotationTaint(fn, seeded), ctx); } catch {}
|
|
2271
|
+
// Unlike the k=2 pass below, this is NOT speculative: `seedFields` was
|
|
2272
|
+
// derived from a REAL, already-observed write elsewhere in this exact
|
|
2273
|
+
// class (the loop above), so a sink this re-analysis finds is a real,
|
|
2274
|
+
// reachable flow — report it directly instead of waiting for a call
|
|
2275
|
+
// site that will never come (most sibling methods still have no
|
|
2276
|
+
// caller/callee relationship for the taint engine to discover; the
|
|
2277
|
+
// call-string narrowing just above only ever removes fields when one
|
|
2278
|
+
// WAS discoverable).
|
|
2279
|
+
_collectFindings(fn, ctx._findings);
|
|
2280
|
+
// `findings` still rides on the cached summary too, for the (rarer)
|
|
2281
|
+
// case where a real caller ALSO consults this exact qid+entry pair via
|
|
2282
|
+
// _mergeSummaryFindings — dedup in _collectFindings makes this safe.
|
|
2283
|
+
const fieldSummary = {
|
|
1632
2284
|
returnTainted: !!ctx._returnTainted,
|
|
1633
2285
|
mutatedParams: ctx._mutatedParamsOut || new Set(),
|
|
2286
|
+
mutatedThisFields: ctx._mutatedThisFieldsOut || new Set(),
|
|
1634
2287
|
taintedGlobals: new Set(),
|
|
1635
2288
|
findings: ctx._findings,
|
|
1636
|
-
}
|
|
2289
|
+
};
|
|
2290
|
+
summaryCache.set(fn.qid, seedFields, fieldSummary);
|
|
2291
|
+
// SARD_80_F1 W3.x — the getter+return interprocedural-composition gap
|
|
2292
|
+
// documented at PHP W5.10: an EXTERNAL, no-argument caller of this
|
|
2293
|
+
// method (`$tainted = $obj->getInput();`) never consults the
|
|
2294
|
+
// `fields`-keyed entry above — it looks up the ORDINARY empty-context
|
|
2295
|
+
// summary (`summaryCache.get(fn.qid, new Set())`), which the earlier
|
|
2296
|
+
// fixed-point pre-pass computed WITHOUT knowing the field was tainted
|
|
2297
|
+
// (that fact is only established by the class-scan just above, which
|
|
2298
|
+
// runs once, after the pre-pass). `entryContext = new Set()` means
|
|
2299
|
+
// "no CALLER-supplied parameter is tainted" — true here regardless of
|
|
2300
|
+
// how many params `fn` declares, since this re-analysis seeded ONLY
|
|
2301
|
+
// the class's own already-tainted fields, no parameters — so this
|
|
2302
|
+
// result is exactly the correct value for that key, not a guess.
|
|
2303
|
+
// Safe to overwrite unconditionally: this model has no un-taint step,
|
|
2304
|
+
// so a field-seeded re-analysis can only find a superset of whatever
|
|
2305
|
+
// the plain empty-context pre-pass already found. The final per-
|
|
2306
|
+
// function reporting pass (the `for (const fn of fnList)` loop that
|
|
2307
|
+
// does the REAL, non-speculative analysis of every function,
|
|
2308
|
+
// including whatever caller invokes this getter) runs strictly AFTER
|
|
2309
|
+
// this whole class-field block, so it is guaranteed to see the
|
|
2310
|
+
// corrected summary — no fixed-point re-iteration needed for THIS
|
|
2311
|
+
// specific external-caller shape.
|
|
2312
|
+
summaryCache.set(fn.qid, new Set(), fieldSummary);
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
// Cross-class static-field taint (SARD_80_F1_EXECUTION_PRD.md — Juliet flow
|
|
2317
|
+
// variant 68: a driver class writes a tainted value to its OWN static
|
|
2318
|
+
// field, then a COMPLETELY DIFFERENT class reads it back via a qualified
|
|
2319
|
+
// reference — `String data = OtherClass.field;` — with no parameter
|
|
2320
|
+
// passed at all. The same-class pass just above is deliberately scoped to
|
|
2321
|
+
// "a sibling method of the SAME class" (see its own header comment); this
|
|
2322
|
+
// is the same mechanism widened one hop further, to any function anywhere
|
|
2323
|
+
// that references `<taintedClassName>.<taintedField>` by name.
|
|
2324
|
+
//
|
|
2325
|
+
// Bounded by a cheap TEXT pre-filter rather than re-analyzing every
|
|
2326
|
+
// function in the project for every tainted class: only a function whose
|
|
2327
|
+
// OWN source text contains the literal `<ClassName>.` substring is a
|
|
2328
|
+
// candidate at all (Juliet's own per-test-case files reference at most a
|
|
2329
|
+
// couple of sibling classes each, so this stays cheap even across a
|
|
2330
|
+
// 17000+ function corpus). `opts.fileContents` is the same map already
|
|
2331
|
+
// threaded through for incremental-cache hashing; falls back to
|
|
2332
|
+
// no-op when absent (in-process callers that don't pass it), same
|
|
2333
|
+
// graceful-degradation convention as this file's other opt-in passes.
|
|
2334
|
+
const fileContents = opts.fileContents;
|
|
2335
|
+
if (cha && fileContents && classTaintedFields.size) {
|
|
2336
|
+
// `callersOfQid` (SARD_80_F1 W5.48) is hoisted above the same-class pass
|
|
2337
|
+
// now that pass ALSO consults it (W5.50) — reused here rather than
|
|
2338
|
+
// re-derived, same memoized map, zero extra cost.
|
|
2339
|
+
for (const [className, fields] of classTaintedFields) {
|
|
2340
|
+
if (Date.now() > deadlineMs) break;
|
|
2341
|
+
const needle = `${className}.`;
|
|
2342
|
+
for (const fn of fnList) {
|
|
2343
|
+
if (Date.now() > deadlineMs) break;
|
|
2344
|
+
if (cha.methodOwners.get(fn.qid) === className) continue; // already covered above
|
|
2345
|
+
const src = fileContents[fn.file];
|
|
2346
|
+
if (typeof src !== 'string' || !src.includes(needle)) continue;
|
|
2347
|
+
|
|
2348
|
+
// Call-string precision: narrow `fields` to only the ones fn should
|
|
2349
|
+
// actually be seeded with. A field drops out only when EVERY direct
|
|
2350
|
+
// caller of fn is a same-class method we POSITIVELY confirmed does
|
|
2351
|
+
// NOT taint that field (absent from `fieldWriterQids`) — never when
|
|
2352
|
+
// a caller is unresolved, ambiguous, or outside this class, since
|
|
2353
|
+
// those cases carry no evidence either way. This is deliberately a
|
|
2354
|
+
// narrow, local instance of call-string sensitivity (Juliet's own
|
|
2355
|
+
// "write-then-immediately-call-a-uniquely-named-sink" idiom, one
|
|
2356
|
+
// direct hop) rather than a general k>1 CFA — see this file's own
|
|
2357
|
+
// CLAUDE.md "Scope — what we still do NOT model" for why the general
|
|
2358
|
+
// form is out of scope. Fails closed (keeps the field seeded) the
|
|
2359
|
+
// instant precision can't be established, same direction every
|
|
2360
|
+
// other heuristic in this pass already takes.
|
|
2361
|
+
const callers = callersOfQid(fn.qid);
|
|
2362
|
+
const effectiveFields = new Set();
|
|
2363
|
+
for (const f of fields) {
|
|
2364
|
+
if (!callers || !callers.size) { effectiveFields.add(f); continue; }
|
|
2365
|
+
const writers = fieldWriterQids.get(className) && fieldWriterQids.get(className).get(f);
|
|
2366
|
+
let keep = false;
|
|
2367
|
+
for (const callerQid of callers) {
|
|
2368
|
+
if (writers && writers.has(callerQid)) { keep = true; break; }
|
|
2369
|
+
if (cha.methodOwners.get(callerQid) !== className) { keep = true; break; }
|
|
2370
|
+
}
|
|
2371
|
+
if (keep) effectiveFields.add(f);
|
|
2372
|
+
}
|
|
2373
|
+
if (!effectiveFields.size) continue; // every caller of fn provably never taints any of these fields
|
|
2374
|
+
|
|
2375
|
+
const qualifiedSeeds = new Set();
|
|
2376
|
+
for (const f of effectiveFields) qualifiedSeeds.add(`${className}.${f}`);
|
|
2377
|
+
if (summaryCache.has(fn.qid, qualifiedSeeds)) continue;
|
|
2378
|
+
const ctx = {
|
|
2379
|
+
_findings: [], _taintSources: [], _returnTainted: false,
|
|
2380
|
+
_stack: new Set(), deadlineMs,
|
|
2381
|
+
_summaryCache: summaryCache, _callGraph: callGraph,
|
|
2382
|
+
_mutatedParamsOut: new Set(),
|
|
2383
|
+
_currentFnQid: fn.qid,
|
|
2384
|
+
_cha: opts._cha,
|
|
2385
|
+
_pointsTo: opts._pointsTo,
|
|
2386
|
+
};
|
|
2387
|
+
try { analyzeFunction(fn, _unionAnnotationTaint(fn, qualifiedSeeds), ctx); } catch {}
|
|
2388
|
+
_collectFindings(fn, ctx._findings);
|
|
2389
|
+
summaryCache.set(fn.qid, qualifiedSeeds, {
|
|
2390
|
+
returnTainted: !!ctx._returnTainted,
|
|
2391
|
+
mutatedParams: ctx._mutatedParamsOut || new Set(),
|
|
2392
|
+
mutatedThisFields: ctx._mutatedThisFieldsOut || new Set(),
|
|
2393
|
+
taintedGlobals: new Set(),
|
|
2394
|
+
findings: ctx._findings,
|
|
2395
|
+
});
|
|
2396
|
+
}
|
|
1637
2397
|
}
|
|
1638
2398
|
}
|
|
1639
2399
|
|
|
@@ -1674,6 +2434,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
1674
2434
|
summaryCache.set(fn.qid, taintedEntry, {
|
|
1675
2435
|
returnTainted: !!ctx._returnTainted,
|
|
1676
2436
|
mutatedParams: ctx._mutatedParamsOut || new Set(),
|
|
2437
|
+
mutatedThisFields: ctx._mutatedThisFieldsOut || new Set(),
|
|
1677
2438
|
taintedGlobals: new Set(),
|
|
1678
2439
|
findings: ctx._findings,
|
|
1679
2440
|
});
|
|
@@ -1745,6 +2506,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
1745
2506
|
return {
|
|
1746
2507
|
returnTainted: !!inner._returnTainted,
|
|
1747
2508
|
mutatedParams: inner._mutatedParamsOut || new Set(),
|
|
2509
|
+
mutatedThisFields: inner._mutatedThisFieldsOut || new Set(),
|
|
1748
2510
|
taintedGlobals: new Set(),
|
|
1749
2511
|
findings: inner._findings,
|
|
1750
2512
|
};
|
|
@@ -1789,13 +2551,26 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
1789
2551
|
// genuine caller is established.
|
|
1790
2552
|
function _collectFindings(attributedFn, srcFindings) {
|
|
1791
2553
|
for (const f of srcFindings) {
|
|
1792
|
-
|
|
2554
|
+
// SARD_80_F1 W4.C21/C22 — prefer the finding's OWN `file` (now stamped
|
|
2555
|
+
// at discovery time inside analyzeFunction, using the function that
|
|
2556
|
+
// ACTUALLY produced it) over `attributedFn.file`. `attributedFn` here
|
|
2557
|
+
// is whichever function the CALLER of `_collectFindings` happens to
|
|
2558
|
+
// be processing — correct for a finding discovered directly in that
|
|
2559
|
+
// function's own CFG walk, but WRONG for one merged in from a
|
|
2560
|
+
// callee's summary via `_mergeSummaryFindings` (most commonly a
|
|
2561
|
+
// cross-file interprocedural call): that finding genuinely belongs to
|
|
2562
|
+
// the callee's own file/line, which `f.file` now carries correctly.
|
|
2563
|
+
// The `attributedFn.file` fallback stays for defense-in-depth only —
|
|
2564
|
+
// every path pushing into `_findings` sets `file` explicitly as of
|
|
2565
|
+
// this fix.
|
|
2566
|
+
const file = f.file || attributedFn.file;
|
|
2567
|
+
const key = `${f.sinkId}:${file}:${f.line}`;
|
|
1793
2568
|
if (seen.has(key)) continue;
|
|
1794
2569
|
seen.add(key);
|
|
1795
2570
|
const fn = attributedFn;
|
|
1796
2571
|
all.push({
|
|
1797
|
-
id: `ir-taint:${
|
|
1798
|
-
file
|
|
2572
|
+
id: `ir-taint:${file}:${f.line}:${f.sinkId}`,
|
|
2573
|
+
file,
|
|
1799
2574
|
line: f.line,
|
|
1800
2575
|
vuln: f.vuln,
|
|
1801
2576
|
severity: f.severity,
|