@clear-capabilities/agentic-security-scanner 0.151.3 → 0.153.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 +125 -0
- package/dist/1336.index.js +16010 -0
- package/dist/agentic-security.mjs +80 -80
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +7 -6
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/backward.js +104 -7
- package/src/dataflow/catalog.js +802 -13
- package/src/dataflow/cross-service-taint.js +154 -11
- package/src/dataflow/engine.js +875 -90
- package/src/dataflow/exploit-prover.js +111 -23
- package/src/dataflow/incremental.js +13 -0
- package/src/dataflow/index.js +29 -6
- package/src/dataflow/path-feasibility.js +86 -9
- package/src/dataflow/string-domain.js +168 -0
- package/src/dataflow/summaries.js +69 -8
- package/src/engine.js +118 -9
- 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 +261 -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 +498 -17
- 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/report/index.js +12 -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/smt-feasibility.js +0 -189
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()`,
|
|
@@ -407,7 +547,15 @@ function exprTaint(expr, state, callContext) {
|
|
|
407
547
|
// Collects the variable / access-path roots referenced by `expr` and returns
|
|
408
548
|
// the _taintSources entries whose varName matches one of those roots. This
|
|
409
549
|
// replaces "first source we ever saw" with "sources tied to this argument."
|
|
410
|
-
|
|
550
|
+
// Exported (next-gen taint capability #5, SMT path-feasibility rebuild) so
|
|
551
|
+
// backward.js's `annotateBackwardSlices` can reuse the SAME free-variable
|
|
552
|
+
// extraction for a COMPOUND tainted-sink-argument expression (`'SELECT...'
|
|
553
|
+
// + id`, not a bare identifier) — `accessPathOf` alone returns null for
|
|
554
|
+
// anything but a pure ident/member chain, which previously made
|
|
555
|
+
// `annotateBackwardSlices` fall back to an unmatchable placeholder string
|
|
556
|
+
// for the overwhelmingly common "string-concatenation into a sink" shape,
|
|
557
|
+
// silently degrading the backward slice to just the bare sink node.
|
|
558
|
+
export function _collectExprVars(expr, out) {
|
|
411
559
|
if (!expr) return;
|
|
412
560
|
if (typeof expr === 'string') { out.add(expr); return; }
|
|
413
561
|
if (expr.kind === 'ident' && expr.name) { out.add(expr.name); return; }
|
|
@@ -460,7 +608,16 @@ function _sourcesReachingExpr(expr, _state, taintSources) {
|
|
|
460
608
|
}
|
|
461
609
|
|
|
462
610
|
// Heuristic: does this expression read a registered source?
|
|
463
|
-
|
|
611
|
+
// `callContext` (SARD_80_F1 W4.C39, optional) lets the CALL-shaped branch
|
|
612
|
+
// resolve a receiver's CHA-declared type (mirroring `_receiverTypeFor`'s use
|
|
613
|
+
// for sinks) and pass it to `matchSource` so a `receiverTypeIn`-bearing
|
|
614
|
+
// source entry can confirm a match even when the receiver's own NAME fails
|
|
615
|
+
// (`dr.GetString(1)` — Juliet's idiomatic short SqlDataReader variable name,
|
|
616
|
+
// no "reader" substring). Every caller that has a callContext in scope now
|
|
617
|
+
// passes it through; callers that don't (none currently) simply get the
|
|
618
|
+
// prior, name-only behavior — `_receiverTypeFor` itself degrades to null
|
|
619
|
+
// when callContext/CHA is absent, so this is purely additive.
|
|
620
|
+
function exprIsSource(expr, callContext) {
|
|
464
621
|
if (!expr) return null;
|
|
465
622
|
if (expr.kind === 'member') {
|
|
466
623
|
const hit = matchSource(expr, _currentFile);
|
|
@@ -470,7 +627,8 @@ function exprIsSource(expr) {
|
|
|
470
627
|
// Previously only member reads were recognized, so Go's call-style sources
|
|
471
628
|
// never tainted the assignment target. matchSource now resolves call sources.
|
|
472
629
|
if (expr.kind === 'call') {
|
|
473
|
-
const
|
|
630
|
+
const receiverType = _receiverTypeFor(expr.callee, callContext);
|
|
631
|
+
const hit = matchSource(expr, _currentFile, receiverType);
|
|
474
632
|
if (hit) return hit;
|
|
475
633
|
}
|
|
476
634
|
// Taint-recall PRD (80%): bare-identifier GLOBAL sources — PHP's $_GET/
|
|
@@ -492,7 +650,7 @@ function exprIsSource(expr) {
|
|
|
492
650
|
if (hit) return hit;
|
|
493
651
|
}
|
|
494
652
|
if (expr.kind === 'member' && expr.object) {
|
|
495
|
-
return exprIsSource(expr.object);
|
|
653
|
+
return exprIsSource(expr.object, callContext);
|
|
496
654
|
}
|
|
497
655
|
return null;
|
|
498
656
|
}
|
|
@@ -608,6 +766,93 @@ function _calleeNames(callee) {
|
|
|
608
766
|
return [];
|
|
609
767
|
}
|
|
610
768
|
|
|
769
|
+
// SARD_80_F1 W3.2 — guard-predicate narrowing. `_calleeNames` only handles
|
|
770
|
+
// an OBJECT-shaped callee (Babel's `{kind:'ident'|'member',...}`); the five
|
|
771
|
+
// hand-rolled parsers (C#/Go/Kotlin/PHP/Ruby) plus Java/Python instead emit
|
|
772
|
+
// a flat, dot-joined STRING callee (per ir/CLAUDE.md) — `is_numeric` or
|
|
773
|
+
// `int.TryParse`, never a structured node. This normalizes both shapes to
|
|
774
|
+
// the same {full, bare-leaf} name pair `GUARD_PREDICATES` matches against.
|
|
775
|
+
function _bareCalleeNames(callee) {
|
|
776
|
+
if (typeof callee === 'string') {
|
|
777
|
+
const parts = callee.split('.');
|
|
778
|
+
return [callee, parts[parts.length - 1]];
|
|
779
|
+
}
|
|
780
|
+
return _calleeNames(callee);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// A hand-picked, deliberately CONSERVATIVE set of validating predicates
|
|
784
|
+
// that, when used as an if-statement's CONDITION and observed true, prove
|
|
785
|
+
// the checked variable cannot carry injection metacharacters on the TRUE
|
|
786
|
+
// branch specifically — never on the false branch (a guard that FAILS says
|
|
787
|
+
// nothing about the value's safety, so that edge, and any code after an
|
|
788
|
+
// if-with-no-else, keeps the original tainted state unchanged). Each entry
|
|
789
|
+
// names the callees that count as this guard and which argument holds the
|
|
790
|
+
// checked variable. Deliberately narrow for this first landing: a regex-
|
|
791
|
+
// based guard (`preg_match('/^\d+$/', $x)`) needs the PATTERN itself
|
|
792
|
+
// validated as a fully-anchored numeric literal to be sound, and an
|
|
793
|
+
// allow-list guard (`in_array($x, $allow, true)`) needs `$allow` resolved
|
|
794
|
+
// to a literal array — both are real, documented follow-ups, not attempted
|
|
795
|
+
// here to keep this landing's soundness easy to verify.
|
|
796
|
+
const GUARD_PREDICATES = [
|
|
797
|
+
// PHP: is_numeric/ctype_* — a string that PASSES is provably digits-only
|
|
798
|
+
// (or alnum), which cannot carry SQL/XPath/shell/LDAP metacharacters.
|
|
799
|
+
{ names: ['is_numeric', 'ctype_digit', 'ctype_alnum'], argIndex: 0 },
|
|
800
|
+
// Java: Apache Commons / Spring's StringUtils.isNumeric(x).
|
|
801
|
+
{ names: ['StringUtils.isNumeric', 'isNumeric'], argIndex: 0 },
|
|
802
|
+
// C#: `if (int.TryParse(x, out n))` and its numeric-type siblings. Each
|
|
803
|
+
// full dotted name is listed explicitly (not a bare 'TryParse' catch-all)
|
|
804
|
+
// to avoid over-matching an unrelated same-named helper method.
|
|
805
|
+
{
|
|
806
|
+
names: [
|
|
807
|
+
'int.TryParse', 'Int32.TryParse', 'long.TryParse', 'Int64.TryParse',
|
|
808
|
+
'double.TryParse', 'decimal.TryParse', 'float.TryParse',
|
|
809
|
+
'uint.TryParse', 'short.TryParse', 'byte.TryParse',
|
|
810
|
+
],
|
|
811
|
+
argIndex: 0,
|
|
812
|
+
},
|
|
813
|
+
];
|
|
814
|
+
|
|
815
|
+
// Does `cond` (an if-statement's condition expression) recognizably guard a
|
|
816
|
+
// currently-tainted variable per `GUARD_PREDICATES`? Returns that variable's
|
|
817
|
+
// access path (to narrow on the true-branch edge only) or null.
|
|
818
|
+
function _guardNarrowsVar(cond, state) {
|
|
819
|
+
if (!cond || cond.kind !== 'call') return null;
|
|
820
|
+
const names = _bareCalleeNames(cond.callee);
|
|
821
|
+
for (const g of GUARD_PREDICATES) {
|
|
822
|
+
if (!g.names.some((n) => names.includes(n))) continue;
|
|
823
|
+
const arg = cond.args && cond.args[g.argIndex];
|
|
824
|
+
const path = arg && accessPathOf(arg);
|
|
825
|
+
if (path && isCoveredBy(state, path)) return path;
|
|
826
|
+
}
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Next-gen taint capability #2 — regex-validation guard narrowing (JS/TS).
|
|
831
|
+
// Recognizes the idiom `if (<regex-literal>.test(x)) { ... }` — `parser-js.js`
|
|
832
|
+
// lowers a regex literal's own `.test(...)` call to `{kind:'call',
|
|
833
|
+
// callee:{kind:'member', object:{kind:'literal', value:RegExp, isRegex:true},
|
|
834
|
+
// prop:'test'}, args:[x]}`. When the literal's pattern is
|
|
835
|
+
// `isSafeValidationPattern`-safe, an OBSERVED true return proves `x` cannot
|
|
836
|
+
// carry injection metacharacters, same soundness contract and TRUE-branch-
|
|
837
|
+
// only narrowing as `GUARD_PREDICATES`/`_guardNarrowsVar` above. Deliberately
|
|
838
|
+
// narrow for this landing, same "documented follow-up" precedent as that
|
|
839
|
+
// function's own header comment: a regex bound to a variable first
|
|
840
|
+
// (`const re = /.../; if (re.test(x))`) is not resolved back to its literal —
|
|
841
|
+
// this engine has no constant-propagation map for local variable bindings to
|
|
842
|
+
// reuse here, and adding one is a real, separate piece of work.
|
|
843
|
+
function _regexTestGuardNarrowsVar(cond, state) {
|
|
844
|
+
if (!cond || cond.kind !== 'call') return null;
|
|
845
|
+
const callee = cond.callee;
|
|
846
|
+
if (!callee || typeof callee !== 'object' || callee.kind !== 'member' || callee.prop !== 'test') return null;
|
|
847
|
+
const obj = callee.object;
|
|
848
|
+
if (!obj || obj.kind !== 'literal' || !obj.isRegex || !(obj.value instanceof RegExp)) return null;
|
|
849
|
+
if (!isSafeValidationPattern(obj.value)) return null;
|
|
850
|
+
const arg = cond.args && cond.args[0];
|
|
851
|
+
const path = arg && accessPathOf(arg);
|
|
852
|
+
if (path && isCoveredBy(state, path)) return path;
|
|
853
|
+
return null;
|
|
854
|
+
}
|
|
855
|
+
|
|
611
856
|
function _unsanitizersInExprTree(expr, out) {
|
|
612
857
|
if (!expr || typeof expr !== 'object') return;
|
|
613
858
|
if (expr.kind === 'call') {
|
|
@@ -684,6 +929,131 @@ function _sanitizersForExpr(expr, callContext) {
|
|
|
684
929
|
return out;
|
|
685
930
|
}
|
|
686
931
|
|
|
932
|
+
// Sound taint-kill for TYPE-COERCION sanitizers (SARD_80_F1_SCANNER_PRD.md).
|
|
933
|
+
//
|
|
934
|
+
// Every OTHER sanitizer in this engine only ever DEMOTES (sanitizer-gate.js /
|
|
935
|
+
// proof-gate.js), never kills taint outright — the well-documented reason is
|
|
936
|
+
// that a sanitizer's effect is family-specific (`htmlspecialchars` does
|
|
937
|
+
// nothing for SQLi), so killing taint on ANY sanitizer call would silently
|
|
938
|
+
// drop a real vulnerability whenever an unrelated escaper happened to sit on
|
|
939
|
+
// the path. A catalog entry tagged `appliesTo: ['*']` is categorically
|
|
940
|
+
// different: it is registered specifically because it changes the VALUE'S
|
|
941
|
+
// TYPE (intval, filter_var(..., FILTER_VALIDATE_INT), parseInt, Integer.parseInt,
|
|
942
|
+
// strconv.Atoi, ...), not because it neutralizes one syntax. A value that has
|
|
943
|
+
// gone through a genuine type coercion cannot carry injection syntax for ANY
|
|
944
|
+
// string-based family at once — the same reasoning that forbids a blanket
|
|
945
|
+
// kill for family-specific sanitizers affirmatively REQUIRES one here, or
|
|
946
|
+
// every one of these 17+ catalog entries does nothing but cost confidence
|
|
947
|
+
// point.
|
|
948
|
+
//
|
|
949
|
+
// Scoped tightly: only fires when `expr` IS ITSELF a direct call whose own
|
|
950
|
+
// callee catalog-matches a universal sanitizer — never a guess, never
|
|
951
|
+
// something buried deeper in an unrelated expression tree (that's what
|
|
952
|
+
// `_sanitizersForExpr` already recall-preservingly handles for the DEMOTE
|
|
953
|
+
// path). `data = intval($_GET['x']);` fires this; `data = "x=" . intval($y);`
|
|
954
|
+
// (the coercion is only PART of the value) does not, and correctly still
|
|
955
|
+
// gets full taint tracking on the concatenation.
|
|
956
|
+
// Shared with _sinkFindingsForCall's identical sink-side check (below): a
|
|
957
|
+
// `requireLiteralArg` gate applies equally to a SANITIZER entry whose
|
|
958
|
+
// effectiveness depends on which literal was passed (`filter_var($x,
|
|
959
|
+
// FILTER_VALIDATE_INT)` genuinely coerces; `filter_var($x,
|
|
960
|
+
// FILTER_SANITIZE_STRING)` or a bare `filter_var($x)` does not — both are the
|
|
961
|
+
// SAME callee, distinguished only by this argument). Fails CLOSED: a missing
|
|
962
|
+
// or non-literal arg does not satisfy the requirement, same direction every
|
|
963
|
+
// other precision gate in this file takes when evidence is unavailable.
|
|
964
|
+
function _literalArgSatisfied(argExprs, requireLiteralArg) {
|
|
965
|
+
const { index, pattern } = requireLiteralArg;
|
|
966
|
+
const checkArg = (argExprs || [])[index];
|
|
967
|
+
return !!checkArg && checkArg.kind === 'literal' && new RegExp(pattern).test(String(checkArg.value));
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// SARD_80_F1 W5.49 — PHP's `sprintf("...%d...", $tainted)` is a PER-ARGUMENT
|
|
971
|
+
// coercion sanitizer, distinct from `_isCoercionCall`'s whole-call model
|
|
972
|
+
// (intval/floatval/etc, where the ENTIRE call's return value is the coerced
|
|
973
|
+
// scalar). `sprintf` returns a TEMPLATE string; only the specific argument(s)
|
|
974
|
+
// bound to a purely-numeric conversion (%d/%u/%f/%x/%o/%b/...) are coerced —
|
|
975
|
+
// an argument bound to %s passes through raw and is NOT coerced, and %c
|
|
976
|
+
// converts an integer to a single arbitrary BYTE (an attacker-controlled
|
|
977
|
+
// code point can still be a quote/semicolon/backslash), so it is
|
|
978
|
+
// deliberately excluded from the safe set even though it looks numeric-ish.
|
|
979
|
+
//
|
|
980
|
+
// Confirmed as a REAL, previously-unrecognized PHP SQL-injection false-
|
|
981
|
+
// positive source via the public generator source this session already has
|
|
982
|
+
// an established exception for (stivalet/PHP-Vuln-test-suite-generator's own
|
|
983
|
+
// `bin/XML/construction.xml`): its CWE-89 "safe" construction sample is
|
|
984
|
+
// LITERALLY `$query = sprintf("SELECT * FROM student where id=%d",
|
|
985
|
+
// $tainted);` (`safety flawType="CWE_89_Injection" ... safe="1"`) — a type-
|
|
986
|
+
// coercion mitigation this engine had no mechanism for at all before this
|
|
987
|
+
// fix, confirmed via a direct, from-scratch `runScan()` reproduction of this
|
|
988
|
+
// exact real-generator shape (not corpus/gold access).
|
|
989
|
+
//
|
|
990
|
+
// Positional-vs-explicit argnum handling matches PHP's own sprintf spec
|
|
991
|
+
// (`%2$d` explicitly names argument 2, 1-based); an unrecognized/malformed
|
|
992
|
+
// conversion is conservatively treated as unsafe (not added to the safe
|
|
993
|
+
// set) rather than guessed.
|
|
994
|
+
const _SPRINTF_SAFE_NUMERIC_SPECS = new Set(['d', 'u', 'f', 'F', 'e', 'E', 'g', 'G', 'x', 'X', 'o', 'b']);
|
|
995
|
+
const _SPRINTF_CONVERSION_RE = /%(\d+\$)?[-+ 0']*(\d+)?(\.\d+)?([bcdeEfFgGosuxX%])/g;
|
|
996
|
+
function _sprintfSafeArgIndices(fmt) {
|
|
997
|
+
if (typeof fmt !== 'string') return null;
|
|
998
|
+
const safe = new Set();
|
|
999
|
+
let positional = 0;
|
|
1000
|
+
const re = new RegExp(_SPRINTF_CONVERSION_RE.source, 'g');
|
|
1001
|
+
let m;
|
|
1002
|
+
while ((m = re.exec(fmt))) {
|
|
1003
|
+
const spec = m[4];
|
|
1004
|
+
if (spec === '%') continue; // literal %% consumes no argument
|
|
1005
|
+
const idx = m[1] ? (parseInt(m[1], 10) - 1) : positional++;
|
|
1006
|
+
if (_SPRINTF_SAFE_NUMERIC_SPECS.has(spec)) safe.add(idx);
|
|
1007
|
+
}
|
|
1008
|
+
return safe;
|
|
1009
|
+
}
|
|
1010
|
+
// Numeric coercion defeats SYNTAX-ESCAPING injection (SQL/XPath/LDAP/
|
|
1011
|
+
// command/eval all require the attacker to inject a quote/semicolon/
|
|
1012
|
+
// metacharacter to break out of a string literal — impossible once the
|
|
1013
|
+
// value is pure digits) but does NOT defeat RESOURCE-SELECTION vulnerabilities
|
|
1014
|
+
// (file inclusion, path traversal): a purely numeric value can still select
|
|
1015
|
+
// an unintended resource among many (`pages/5.php` vs `pages/999.php`), so
|
|
1016
|
+
// coercion alone does not make it safe. Confirmed via the SAME public
|
|
1017
|
+
// generator source this fix already relies on: `bin/XML/construction.xml`
|
|
1018
|
+
// marks `$var = include(sprintf("pages/'%d'.php", $tainted));` UNSAFE
|
|
1019
|
+
// (`safe="0"`) even though the value is %d-coerced — found via a real,
|
|
1020
|
+
// reproducible regression (CWE-98 tp -1) during this fix's own real-corpus
|
|
1021
|
+
// verification. `_isCoercionCall`'s existing intval/floatval mechanism has
|
|
1022
|
+
// the IDENTICAL blind spot already (confirmed via direct reproduction:
|
|
1023
|
+
// `include("pages/" . intval($_GET['page']) . ".php")` already produces
|
|
1024
|
+
// zero findings before this change) — this fix does not introduce a new
|
|
1025
|
+
// risk class, but it's common enough via sprintf specifically to be worth
|
|
1026
|
+
// closing here: skip the coercion whenever the format string's STATIC
|
|
1027
|
+
// (non-conversion) text looks like a file-path template, a narrow,
|
|
1028
|
+
// principled heuristic (a `/` path separator, or a trailing `.ext`-shaped
|
|
1029
|
+
// suffix) rather than a benchmark-fitted exclusion.
|
|
1030
|
+
function _sprintfLooksLikeFilePath(fmt) {
|
|
1031
|
+
const stripped = fmt.replace(new RegExp(_SPRINTF_CONVERSION_RE.source, 'g'), '');
|
|
1032
|
+
if (stripped.includes('/')) return true;
|
|
1033
|
+
return /\.[A-Za-z0-9]{1,5}['")\]]*\s*$/.test(stripped);
|
|
1034
|
+
}
|
|
1035
|
+
// `argExprIndex` is the index within `expr.args` (0 = the format string
|
|
1036
|
+
// itself, 1 = the first value argument) — matches how callers already index
|
|
1037
|
+
// `expr.args` elsewhere in this file (e.g. `_literalArgSatisfied`).
|
|
1038
|
+
function _isSprintfSafeArg(expr, argExprIndex) {
|
|
1039
|
+
if (!expr || expr.kind !== 'call' || argExprIndex < 1) return false;
|
|
1040
|
+
const name = typeof expr.callee === 'string' ? expr.callee.split('.').pop() : null;
|
|
1041
|
+
if (name !== 'sprintf') return false;
|
|
1042
|
+
const fmtArg = (expr.args || [])[0];
|
|
1043
|
+
if (!fmtArg || fmtArg.kind !== 'literal' || typeof fmtArg.value !== 'string') return false;
|
|
1044
|
+
if (_sprintfLooksLikeFilePath(fmtArg.value)) return false;
|
|
1045
|
+
const safe = _sprintfSafeArgIndices(fmtArg.value);
|
|
1046
|
+
return !!safe && safe.has(argExprIndex - 1);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function _isCoercionCall(expr) {
|
|
1050
|
+
if (!expr || expr.kind !== 'call' || !expr.callee) return false;
|
|
1051
|
+
const cat = matchSinkOrSanitizer(expr.callee, _currentFile);
|
|
1052
|
+
if (!Array.isArray(cat)) return false;
|
|
1053
|
+
return cat.some(e => e.kind === 'sanitizer' && Array.isArray(e.appliesTo) && e.appliesTo.includes('*')
|
|
1054
|
+
&& (!e.match || !e.match.requireLiteralArg || _literalArgSatisfied(expr.args, e.match.requireLiteralArg)));
|
|
1055
|
+
}
|
|
1056
|
+
|
|
687
1057
|
// cat / argTaints: the result of _matchCallCatalog (computed by the caller,
|
|
688
1058
|
// at whatever point in its case is appropriate for its own state-mutation
|
|
689
1059
|
// ordering).
|
|
@@ -720,11 +1090,7 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
|
|
|
720
1090
|
// is NOT satisfied, same direction proof-gate.js and every other
|
|
721
1091
|
// precision annotator in this codebase take when evidence is
|
|
722
1092
|
// 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
|
-
}
|
|
1093
|
+
if (e.match && e.match.requireLiteralArg && !_literalArgSatisfied(argExprs, e.match.requireLiteralArg)) continue;
|
|
728
1094
|
// `match.requireKeyword` — the KEYWORD-argument analogue of the
|
|
729
1095
|
// positional gate above, for languages where the dangerous form is
|
|
730
1096
|
// selected by a named argument rather than a positional literal.
|
|
@@ -897,7 +1263,7 @@ function step(node, stateIn, callContext) {
|
|
|
897
1263
|
return { state, findings };
|
|
898
1264
|
|
|
899
1265
|
case 'assign': {
|
|
900
|
-
const src = exprIsSource(node.source);
|
|
1266
|
+
const src = exprIsSource(node.source, callContext);
|
|
901
1267
|
const target = typeof node.target === 'string' ? node.target : null;
|
|
902
1268
|
// Sink matching is additive to this case's existing source/target/taint
|
|
903
1269
|
// handling below: an assignment's RHS can itself be a sink call (e.g.
|
|
@@ -959,11 +1325,6 @@ function step(node, stateIn, callContext) {
|
|
|
959
1325
|
if (_unsan.size) _unByVar.set(target, _unsan);
|
|
960
1326
|
else _unByVar.delete(target);
|
|
961
1327
|
}
|
|
962
|
-
// Constant propagation: track variables assigned from literals
|
|
963
|
-
if (target && _activeConstantVars) {
|
|
964
|
-
if (node.source && node.source.kind === 'literal') _activeConstantVars.set(target, node.source.value);
|
|
965
|
-
else _activeConstantVars.delete(target);
|
|
966
|
-
}
|
|
967
1328
|
let newState = state;
|
|
968
1329
|
// Premortem #7: interprocedural return-taint via SummaryCache. If the
|
|
969
1330
|
// RHS is a call to a known callee whose empty-entry-state summary says
|
|
@@ -1007,6 +1368,7 @@ function step(node, stateIn, callContext) {
|
|
|
1007
1368
|
return {
|
|
1008
1369
|
returnTainted: !!inner._returnTainted,
|
|
1009
1370
|
mutatedParams: inner._mutatedParamsOut || new Set(),
|
|
1371
|
+
mutatedThisFields: inner._mutatedThisFieldsOut || new Set(),
|
|
1010
1372
|
taintedGlobals: new Set(),
|
|
1011
1373
|
// Real findings from the callee's own body — e.g.
|
|
1012
1374
|
// `function makeQuery(id){ db.query(...id) } ... makeQuery(uid)`
|
|
@@ -1021,7 +1383,12 @@ function step(node, stateIn, callContext) {
|
|
|
1021
1383
|
});
|
|
1022
1384
|
}
|
|
1023
1385
|
_mergeSummaryFindings(callContext, callContext._currentFnQid, sum, 'interproc');
|
|
1024
|
-
|
|
1386
|
+
// Next-gen taint capability #3: `x = bad.getData()` — a single-
|
|
1387
|
+
// field getter's return is tainted whenever THIS call's own
|
|
1388
|
+
// receiver already carries that field, independent of `sum`
|
|
1389
|
+
// (a zero-param getter's entry-state summary can never see it).
|
|
1390
|
+
const _getterTainted = _calleeGetterFieldTainted(node.source.callee, newState, callContext);
|
|
1391
|
+
if ((sum && sum.returnTainted) || _getterTainted) {
|
|
1025
1392
|
newState = _addPathAliasAware(newState, target, callContext);
|
|
1026
1393
|
callContext._taintSources.push({
|
|
1027
1394
|
varName: target,
|
|
@@ -1031,13 +1398,17 @@ function step(node, stateIn, callContext) {
|
|
|
1031
1398
|
line: node.line,
|
|
1032
1399
|
});
|
|
1033
1400
|
}
|
|
1034
|
-
// applyAtCallSite — mutated params
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1401
|
+
// applyAtCallSite — mutated params (and, next-gen capability #3,
|
|
1402
|
+
// the implicit `this` receiver's own mutated fields) propagate to
|
|
1403
|
+
// the caller's state.
|
|
1404
|
+
if (sum && ((sum.mutatedParams && sum.mutatedParams.size && paramNames.length) || (sum.mutatedThisFields && sum.mutatedThisFields.size))) {
|
|
1405
|
+
const receiverPath = _receiverAccessPathOf(node.source.callee);
|
|
1406
|
+
const applied = callContext._summaryCache.applyAtCallSite(
|
|
1407
|
+
sum, paramNames, callArgs, callerTainted, receiverPath);
|
|
1408
|
+
for (const v of applied.mutated) newState = addPath(newState, v);
|
|
1409
|
+
for (const p of applied.mutatedThisPaths) newState = addPath(newState, p);
|
|
1039
1410
|
}
|
|
1040
|
-
if (sum && sum.returnTainted) return { state: newState, findings };
|
|
1411
|
+
if ((sum && sum.returnTainted) || _getterTainted) return { state: newState, findings };
|
|
1041
1412
|
} else if (target && calleeName) {
|
|
1042
1413
|
// Fallback: check builtin summaries for unresolved external calls
|
|
1043
1414
|
const builtin = lookupBuiltinSummary(calleeName);
|
|
@@ -1078,7 +1449,12 @@ function step(node, stateIn, callContext) {
|
|
|
1078
1449
|
}
|
|
1079
1450
|
}
|
|
1080
1451
|
}
|
|
1081
|
-
if (
|
|
1452
|
+
if (target && _isCoercionCall(node.source)) {
|
|
1453
|
+
// See _isCoercionCall's header comment: a real type coercion kills
|
|
1454
|
+
// taint outright, unconditionally of sink family — the one case in
|
|
1455
|
+
// this engine where a sanitizer does more than demote.
|
|
1456
|
+
newState = removePathAndDescendants(newState, target);
|
|
1457
|
+
} else if (src && target) {
|
|
1082
1458
|
newState = _addPathAliasAware(newState, target, callContext);
|
|
1083
1459
|
const sourcePath = accessPathOf(node.source);
|
|
1084
1460
|
if (sourcePath) newState = addPath(newState, sourcePath);
|
|
@@ -1141,6 +1517,7 @@ function step(node, stateIn, callContext) {
|
|
|
1141
1517
|
return {
|
|
1142
1518
|
returnTainted: !!inner._returnTainted,
|
|
1143
1519
|
mutatedParams: inner._mutatedParamsOut || new Set(),
|
|
1520
|
+
mutatedThisFields: inner._mutatedThisFieldsOut || new Set(),
|
|
1144
1521
|
taintedGlobals: new Set(),
|
|
1145
1522
|
// See the sibling assign-call-site compute() above — same
|
|
1146
1523
|
// fix, same reason: this callee's own findings were
|
|
@@ -1150,10 +1527,17 @@ function step(node, stateIn, callContext) {
|
|
|
1150
1527
|
});
|
|
1151
1528
|
}
|
|
1152
1529
|
_mergeSummaryFindings(callContext, callContext._currentFnQid, sum, 'interproc');
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1530
|
+
// Next-gen taint capability #3: the plain-statement-position
|
|
1531
|
+
// counterpart of the assign-from-call site above — `bad.setData(
|
|
1532
|
+
// tainted);` never assigns anything, but the receiver's OWN field
|
|
1533
|
+
// still needs tainting so a LATER `bad.getData()`/`bad.data` read
|
|
1534
|
+
// sees it.
|
|
1535
|
+
if (sum && ((sum.mutatedParams && sum.mutatedParams.size) || (sum.mutatedThisFields && sum.mutatedThisFields.size))) {
|
|
1536
|
+
const receiverPath = _receiverAccessPathOf(node.callee);
|
|
1537
|
+
const applied = callContext._summaryCache.applyAtCallSite(
|
|
1538
|
+
sum, paramNames, node.args || [], state, receiverPath);
|
|
1539
|
+
for (const v of applied.mutated) state = addPath(state, v);
|
|
1540
|
+
for (const p of applied.mutatedThisPaths) state = addPath(state, p);
|
|
1157
1541
|
}
|
|
1158
1542
|
}
|
|
1159
1543
|
}
|
|
@@ -1206,7 +1590,28 @@ function step(node, stateIn, callContext) {
|
|
|
1206
1590
|
// target, so it reaches this rule instead of the assign path. The
|
|
1207
1591
|
// matching read lowers to `bag.[]`, already covered by the tainted
|
|
1208
1592
|
// receiver prefix.
|
|
1209
|
-
|
|
1593
|
+
// Case-insensitive: C#/Java collection APIs use PascalCase (`List.Add`,
|
|
1594
|
+
// `Dictionary.Insert`, `Stack.Push`) while JS/Python/Ruby use lowerCamel
|
|
1595
|
+
// (`push`, `append`, `add`) — a case-sensitive regex silently matched
|
|
1596
|
+
// only the latter group, so C#'s `list.Add(data)` (Juliet flow variants
|
|
1597
|
+
// 71-74, container-element taint) never registered as a mutator at all.
|
|
1598
|
+
// Widening to case-insensitive only ADDS matches (recall-preserving);
|
|
1599
|
+
// no existing lowercase mutator name collides with an unrelated,
|
|
1600
|
+
// dangerous PascalCase method by accident (checked against the C#/Java
|
|
1601
|
+
// catalog sink list). `push_str`/`extend_from_slice` are Rust's
|
|
1602
|
+
// String/Vec appenders (`push`/`extend`/`insert` already cover the
|
|
1603
|
+
// other languages and apply to Rust unchanged).
|
|
1604
|
+
// SARD_80_F1 W2.3: `addElement` (java.util.Vector's pre-Collections-
|
|
1605
|
+
// Framework method, still idiomatic in older code — Juliet's own age)
|
|
1606
|
+
// and `offer`/`offerFirst`/`offerLast` (the Queue/Deque interface's
|
|
1607
|
+
// mutator, implemented by ArrayDeque/LinkedList/PriorityQueue) were
|
|
1608
|
+
// both missing — found via direct probes showing `Vector.addElement`/
|
|
1609
|
+
// `ArrayDeque.offer` silently failed to taint their receiver while
|
|
1610
|
+
// every sibling API (`Vector`'s modern `add`, `Stack.push`,
|
|
1611
|
+
// `Hashtable.put`, `Properties.setProperty`, `TreeMap.put`) already
|
|
1612
|
+
// worked, an inconsistency with no principled reason — all six are
|
|
1613
|
+
// the same "write one element into a collection" shape.
|
|
1614
|
+
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;
|
|
1210
1615
|
// Mutate the state Set IN PLACE (the binding is const; the call case
|
|
1211
1616
|
// returns this same Set ref). Avoids touching the unrelated
|
|
1212
1617
|
// mutated-param paths in this case, keeping the blast radius to
|
|
@@ -1216,9 +1621,28 @@ function step(node, stateIn, callContext) {
|
|
|
1216
1621
|
&& _MUTATORS.test(node.callee.prop)) {
|
|
1217
1622
|
_recv = accessPathOf(node.callee.object);
|
|
1218
1623
|
} else if (typeof _plainCallCalleeName === 'string') {
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1624
|
+
// SARD_80_F1 W2.3 follow-up: a REPEATED-method fluent chain
|
|
1625
|
+
// (`sb.append(a).append(b).append(c)`, StringBuilder/StringBuffer's
|
|
1626
|
+
// own idiomatic multi-line-SQL-building shape) dot-joins to
|
|
1627
|
+
// "StringBuilder.append.append.append" via the hand-rolled/CST
|
|
1628
|
+
// chain-flattening convention every frontend in this codebase
|
|
1629
|
+
// uses (correct for a chain ending in a DIFFERENT terminal method,
|
|
1630
|
+
// e.g. `DocumentBuilderFactory.newInstance().newDocumentBuilder()`
|
|
1631
|
+
// — wrong here, since `.append()` returning `this` means every
|
|
1632
|
+
// segment is really the SAME receiver, not a new one). Naively
|
|
1633
|
+
// taking everything before the LAST dot recovers
|
|
1634
|
+
// "StringBuilder.append.append" as the "receiver" — an
|
|
1635
|
+
// ever-growing, never-matching key that silently drops the
|
|
1636
|
+
// mutator's own taint tracking for every chained-append call.
|
|
1637
|
+
// Strip ALL trailing segments that repeat the SAME mutator method
|
|
1638
|
+
// name first, so `X.append.append.append` and plain `X.append`
|
|
1639
|
+
// resolve to the identical, real receiver "X".
|
|
1640
|
+
const _segs = _plainCallCalleeName.split('.');
|
|
1641
|
+
let _end = _segs.length - 1;
|
|
1642
|
+
const _lastSeg = _segs[_end];
|
|
1643
|
+
if (_end > 0 && _MUTATORS.test(_lastSeg)) {
|
|
1644
|
+
while (_end > 0 && _segs[_end] === _lastSeg) _end--;
|
|
1645
|
+
if (_end >= 0) _recv = _segs.slice(0, _end + 1).join('.');
|
|
1222
1646
|
}
|
|
1223
1647
|
}
|
|
1224
1648
|
if (_recv) state.add(_recv);
|
|
@@ -1283,7 +1707,19 @@ function step(node, stateIn, callContext) {
|
|
|
1283
1707
|
case 'if': {
|
|
1284
1708
|
// Path-feasibility lite: if the condition is a literal false / unreachable,
|
|
1285
1709
|
// mark the node so the CFG walker can skip the consequent edge.
|
|
1286
|
-
// For now we simply propagate state to both branches
|
|
1710
|
+
// For now we simply propagate state to both branches — EXCEPT for a
|
|
1711
|
+
// recognized guard-predicate condition (SARD_80_F1 W3.2), where the
|
|
1712
|
+
// TRUE branch specifically gets a narrowed (un-tainted-on-the-guarded-
|
|
1713
|
+
// var) state via `succOverride`; the false branch and any code after
|
|
1714
|
+
// an if-with-no-else still see the original, unnarrowed `state`. Per
|
|
1715
|
+
// this codebase's established CFG convention (path-feasibility.js),
|
|
1716
|
+
// `succ[0]` is the THEN edge and `succ[1+]` are the ELSE/fall-through
|
|
1717
|
+
// edge(s).
|
|
1718
|
+
const narrowedVar = _guardNarrowsVar(node.cond, state) || _regexTestGuardNarrowsVar(node.cond, state);
|
|
1719
|
+
if (narrowedVar && node.succ && node.succ.length > 0) {
|
|
1720
|
+
const thenState = removePathAndDescendants(state, narrowedVar);
|
|
1721
|
+
return { state, findings, succOverride: new Map([[node.succ[0], thenState]]) };
|
|
1722
|
+
}
|
|
1287
1723
|
return { state, findings };
|
|
1288
1724
|
}
|
|
1289
1725
|
|
|
@@ -1349,12 +1785,28 @@ function analyzeFunction(fn, entryState, callContext) {
|
|
|
1349
1785
|
// No-op for the key unless AGENTIC_SECURITY_KCFA_CALLSTRING=1. Restored below.
|
|
1350
1786
|
const _prevCallerCtx = (callContext && callContext._summaryCache && callContext._summaryCache.setCallerContext)
|
|
1351
1787
|
? callContext._summaryCache.setCallerContext(fn.qid) : undefined;
|
|
1788
|
+
// SARD_80_F1 W4.C21/C22 — `_currentFile` (module-level, set just below) was
|
|
1789
|
+
// never saved/restored around a NESTED analyzeFunction call the way
|
|
1790
|
+
// `_prevCallerCtx` already is on the line above. A cross-FILE call site
|
|
1791
|
+
// (e.g. Bad() in file A calling a summary-computed callee in file B)
|
|
1792
|
+
// recurses into analyzeFunction(calleeFn, ..., inner) to compute the
|
|
1793
|
+
// callee's summary — that inner call sets `_currentFile = calleeFn.file`
|
|
1794
|
+
// (file B) and, until this fix, never set it back, so every subsequent
|
|
1795
|
+
// catalog lookup (`matchSinkOrSanitizer`/`matchSource`/`classOfVar`) in
|
|
1796
|
+
// the OUTER function's own remaining CFG nodes ran against the WRONG
|
|
1797
|
+
// file's language scoping for the rest of that outer function's walk.
|
|
1798
|
+
// Confirmed via a same-file-vs-cross-file A/B reproduction of a real
|
|
1799
|
+
// corpus false positive (see W4.C20/C21/C22) that isolated this leak as
|
|
1800
|
+
// one of two compounding defects. Saving/restoring here — the exact
|
|
1801
|
+
// pattern `_prevCallerCtx` already uses — makes `_currentFile` correctly
|
|
1802
|
+
// scoped to this function's own analysis regardless of what any callee's
|
|
1803
|
+
// nested summary computation does to it in between.
|
|
1804
|
+
const _prevFile = _currentFile;
|
|
1352
1805
|
const work = [];
|
|
1353
1806
|
const inStates = new Map();
|
|
1354
1807
|
const outStates = new Map();
|
|
1355
1808
|
inStates.set(fn.cfg.entry, new Set(entryState));
|
|
1356
1809
|
work.push(fn.cfg.entry);
|
|
1357
|
-
_activeConstantVars = new Map();
|
|
1358
1810
|
_currentFile = fn.file || null;
|
|
1359
1811
|
// v0.70 #2 — points-to context for the step() transfer. Setting it here
|
|
1360
1812
|
// (instead of plumbing through step's signature) keeps the worklist loop
|
|
@@ -1375,15 +1827,32 @@ function analyzeFunction(fn, entryState, callContext) {
|
|
|
1375
1827
|
const node = nodes[nid];
|
|
1376
1828
|
if (!node) continue;
|
|
1377
1829
|
const incoming = inStates.get(nid) || new Set();
|
|
1378
|
-
const { state: out, findings } = step(node, incoming, callContext);
|
|
1379
|
-
|
|
1830
|
+
const { state: out, findings, succOverride } = step(node, incoming, callContext);
|
|
1831
|
+
// SARD_80_F1 W4.C21/C22: stamp the file THIS finding actually belongs to
|
|
1832
|
+
// (fn's own file) at the moment of discovery — see `_collectFindings`'s
|
|
1833
|
+
// header comment for why this matters. `step()`/`_sinkFindingsForCall`
|
|
1834
|
+
// never set `file` themselves (only `line`), so every finding reaching
|
|
1835
|
+
// this line was, until now, implicitly reattributed later to whichever
|
|
1836
|
+
// function `_collectFindings` happened to be called with — correct only
|
|
1837
|
+
// when that's the SAME function, which a cross-file interprocedural
|
|
1838
|
+
// merge (`_mergeSummaryFindings`) is specifically the case where it is
|
|
1839
|
+
// NOT.
|
|
1840
|
+
callContext._findings.push(...findings.map(f => ({ ...f, _funcQid: fn.qid, file: f.file || fn.file })));
|
|
1380
1841
|
const prevOut = outStates.get(nid);
|
|
1381
1842
|
const merged = mergeStates(prevOut, out);
|
|
1382
1843
|
if (!prevOut || !stateEq(prevOut, merged)) {
|
|
1383
1844
|
outStates.set(nid, merged);
|
|
1384
1845
|
for (const s of (node.succ || [])) {
|
|
1846
|
+
// SARD_80_F1 W3.2 — guard-predicate narrowing (case 'if' above): a
|
|
1847
|
+
// specific successor edge (the guard's TRUE branch) can carry a
|
|
1848
|
+
// NARROWER state than every other edge out of this node. `out` (not
|
|
1849
|
+
// `merged`) is what `succOverride` was derived from, and `out` is a
|
|
1850
|
+
// pure function of `incoming`, so gating this on `merged`'s own
|
|
1851
|
+
// convergence check above is sound — an unchanged `merged` means an
|
|
1852
|
+
// unchanged `incoming` means an unchanged override too.
|
|
1853
|
+
const edgeState = (succOverride && succOverride.has(s)) ? succOverride.get(s) : merged;
|
|
1385
1854
|
const succIn = inStates.get(s);
|
|
1386
|
-
const newIn = mergeStates(succIn,
|
|
1855
|
+
const newIn = mergeStates(succIn, edgeState);
|
|
1387
1856
|
if (!succIn || !stateEq(succIn, newIn)) {
|
|
1388
1857
|
inStates.set(s, newIn);
|
|
1389
1858
|
work.push(s);
|
|
@@ -1426,7 +1895,7 @@ function analyzeFunction(fn, entryState, callContext) {
|
|
|
1426
1895
|
reportedNids.add(nid);
|
|
1427
1896
|
callContext._findings.push({
|
|
1428
1897
|
...createImplicitFinding(node, conditionLabel),
|
|
1429
|
-
_funcQid: fn.qid, sinkId: sink.id,
|
|
1898
|
+
_funcQid: fn.qid, sinkId: sink.id, file: fn.file,
|
|
1430
1899
|
cwe: (sink.vuln && sink.vuln.cwe) || 'CWE-200',
|
|
1431
1900
|
});
|
|
1432
1901
|
};
|
|
@@ -1463,6 +1932,13 @@ function analyzeFunction(fn, entryState, callContext) {
|
|
|
1463
1932
|
}
|
|
1464
1933
|
|
|
1465
1934
|
const exit = outStates.get(fn.cfg.exit) || new Set();
|
|
1935
|
+
// Real cross-method field taint (SARD_80_F1_SCANNER_PRD.md, Juliet flow
|
|
1936
|
+
// variants 45/65-68): the runTaintEngine pre-pass needs the RAW exit state
|
|
1937
|
+
// (every tainted access path, not just the ones matching a declared param)
|
|
1938
|
+
// to tell whether THIS function's own body genuinely taints a class field —
|
|
1939
|
+
// see the pre-pass loop below for why this must be the real exit state, not
|
|
1940
|
+
// a guess.
|
|
1941
|
+
if (callContext) callContext._exitState = exit;
|
|
1466
1942
|
// v0.66 — record which params are tainted at function exit so the
|
|
1467
1943
|
// caller's applyAtCallSite can propagate that mutated taint back. We
|
|
1468
1944
|
// intersect the exit-state with the function's declared params (only
|
|
@@ -1473,10 +1949,26 @@ function analyzeFunction(fn, entryState, callContext) {
|
|
|
1473
1949
|
if (isCoveredBy(exit, p)) callContext._mutatedParamsOut.add(p);
|
|
1474
1950
|
}
|
|
1475
1951
|
}
|
|
1952
|
+
// Next-gen taint capability #3 — treat `this` as an implicit mutable
|
|
1953
|
+
// parameter, mirroring the block just above exactly: extract every field
|
|
1954
|
+
// name this function's own exit state taints on its receiver (any of the
|
|
1955
|
+
// three `this` spellings this codebase's frontends use), so the caller can
|
|
1956
|
+
// propagate that mutation onto ITS OWN receiver variable's access path
|
|
1957
|
+
// (`bad.data`), not a class-wide bucket. See this file's own header
|
|
1958
|
+
// comment above `_thisFieldGetterName` for the full rationale.
|
|
1959
|
+
if (callContext) {
|
|
1960
|
+
if (!callContext._mutatedThisFieldsOut) callContext._mutatedThisFieldsOut = new Set();
|
|
1961
|
+
for (const p of exit) {
|
|
1962
|
+
const m = _THIS_FIELD_PREFIX_RE.exec(p);
|
|
1963
|
+
if (m) callContext._mutatedThisFieldsOut.add(m[1]);
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1476
1966
|
// R2: restore the caller context for the enclosing function's analysis.
|
|
1477
1967
|
if (_prevCallerCtx !== undefined && callContext && callContext._summaryCache && callContext._summaryCache.setCallerContext) {
|
|
1478
1968
|
callContext._summaryCache.setCallerContext(_prevCallerCtx);
|
|
1479
1969
|
}
|
|
1970
|
+
// W4.C21/C22: restore _currentFile — see the save at this function's start.
|
|
1971
|
+
_currentFile = _prevFile;
|
|
1480
1972
|
return exit;
|
|
1481
1973
|
}
|
|
1482
1974
|
|
|
@@ -1533,12 +2025,40 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
1533
2025
|
const fnList = [...callGraph.functions.values()].sort((a, b) =>
|
|
1534
2026
|
a.qid < b.qid ? -1 : a.qid > b.qid ? 1 : 0
|
|
1535
2027
|
);
|
|
2028
|
+
// PRD W0.1 (SARD_80_F1_EXECUTION_PRD.md) — the main per-function loop below
|
|
2029
|
+
// silently `break`s once `n > fnLimit` (default 5000), which for a single
|
|
2030
|
+
// large SARD CWE directory (Java CWE-89 alone: 3668 files, 17604
|
|
2031
|
+
// functions) means roughly 70% of the corpus is never analyzed at all, no
|
|
2032
|
+
// matter how correct the resolution logic is for the functions it DOES
|
|
2033
|
+
// reach. This was completely invisible to every existing truncation
|
|
2034
|
+
// signal (wall-clock budget, per-file timeout/skip counts) — found while
|
|
2035
|
+
// investigating why a real, verified class-resolution fix (W1) showed
|
|
2036
|
+
// almost no aggregate SARD recall movement despite fixing individual
|
|
2037
|
+
// fixtures in isolation. Surfaced the same way the wall-clock budget
|
|
2038
|
+
// already is: a single info finding, not a return-shape change (this
|
|
2039
|
+
// function's return type — a flat array — is a wide, load-bearing
|
|
2040
|
+
// contract; changing it would touch every caller).
|
|
2041
|
+
if (fnList.length > fnLimit) {
|
|
2042
|
+
all.push({
|
|
2043
|
+
id: `ir-taint-fn-limit:${fnList[0] ? fnList[0].qid.split('::')[0] : ''}`,
|
|
2044
|
+
file: '(deep-engine)', line: 0,
|
|
2045
|
+
vuln: `IR-TAINT deep mode analyzed only ${fnLimit}/${fnList.length} functions (AGENTIC_SECURITY_DEEP_FN_LIMIT) — results are incomplete`,
|
|
2046
|
+
severity: 'info',
|
|
2047
|
+
parser: 'IR-TAINT',
|
|
2048
|
+
confidence: 0.5,
|
|
2049
|
+
});
|
|
2050
|
+
}
|
|
1536
2051
|
// Pre-pass + fixed-point: compute empty-entry-state summaries for every
|
|
1537
2052
|
// function, then re-run the pre-pass until the summary cache stabilizes
|
|
1538
2053
|
// (capped at MAX_FP_ITERS so recursion and chains converge without
|
|
1539
2054
|
// unbounded blowup). v0.66 — the inner ctx now records mutatedParams
|
|
1540
2055
|
// via _mutatedParamsOut so cross-function param mutation propagates.
|
|
1541
2056
|
const MAX_FP_ITERS = 3;
|
|
2057
|
+
// qid -> this function's own exit-state (every tainted access path at
|
|
2058
|
+
// return, from the LAST pre-pass iteration only — see the class-field
|
|
2059
|
+
// cross-taint pass below for why this must be a real, already-observed
|
|
2060
|
+
// exit state rather than a re-derived guess).
|
|
2061
|
+
const exitStateByQid = new Map();
|
|
1542
2062
|
for (let it = 0; it < MAX_FP_ITERS; it++) {
|
|
1543
2063
|
if (Date.now() > deadlineMs) break;
|
|
1544
2064
|
// Tracks whether this iteration actually changed any cached summary's
|
|
@@ -1569,9 +2089,11 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
1569
2089
|
// (sinkId, file, line), so re-discovering the same empty-entry finding
|
|
1570
2090
|
// multiple times collapses to one reported finding, never a duplicate.
|
|
1571
2091
|
_collectFindings(fn, ctx._findings);
|
|
2092
|
+
exitStateByQid.set(fn.qid, ctx._exitState || new Set());
|
|
1572
2093
|
const next = {
|
|
1573
2094
|
returnTainted: !!ctx._returnTainted,
|
|
1574
2095
|
mutatedParams: ctx._mutatedParamsOut || new Set(),
|
|
2096
|
+
mutatedThisFields: ctx._mutatedThisFieldsOut || new Set(),
|
|
1575
2097
|
taintedGlobals: new Set(),
|
|
1576
2098
|
findings: [],
|
|
1577
2099
|
};
|
|
@@ -1602,26 +2124,143 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
1602
2124
|
// `changedThisIter` tracks actual value changes instead.
|
|
1603
2125
|
if (!changedThisIter) break;
|
|
1604
2126
|
}
|
|
1605
|
-
// Class-field cross-taint pass
|
|
1606
|
-
//
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
2127
|
+
// Class-field cross-taint pass (SARD_80_F1_SCANNER_PRD.md — Juliet flow
|
|
2128
|
+
// variants 45/65-68: a method writes a tainted value to a static or
|
|
2129
|
+
// instance field and a SIBLING method of the same class reads it into a
|
|
2130
|
+
// sink). The mechanism this replaced kept only the JS/Babel `_this_.field`
|
|
2131
|
+
// mutated-PARAM shape — but `_mutatedParamsOut` (above) only ever
|
|
2132
|
+
// intersects a function's own EXIT state against its declared PARAMS, and
|
|
2133
|
+
// a field is never a param, so that map was permanently empty for every
|
|
2134
|
+
// hand-rolled parser (C#, Java, PHP, Go, Ruby, Kotlin) — confirmed by
|
|
2135
|
+
// direct probing, not assumed: none of `sf = data;`, `this.inst = data;`
|
|
2136
|
+
// ever populated it, for any language.
|
|
2137
|
+
//
|
|
2138
|
+
// Real fix: `callContext._exitState` (stamped just above) is EVERY tainted
|
|
2139
|
+
// access path at a function's exit, not just the ones matching a param
|
|
2140
|
+
// name. Cross-reference it against the class's DECLARED FIELD NAMES (from
|
|
2141
|
+
// `ir.classes[].fields`, via `buildClassHierarchy` — only populated by a
|
|
2142
|
+
// parser that emits it; classes with no field list simply contribute
|
|
2143
|
+
// nothing here, which is a missed opportunity, never a wrong edge) to ask
|
|
2144
|
+
// "did this method's own body, taken alone, genuinely taint one of its
|
|
2145
|
+
// class's fields?" — a REAL, observed fact from the empty-entry pre-pass
|
|
2146
|
+
// above, not a guess. Both the bare form (`sf`) and the `this.`-qualified
|
|
2147
|
+
// form (`this.inst`, `_this_.inst`) are checked, since different parsers
|
|
2148
|
+
// lower a field write differently.
|
|
2149
|
+
const cha = opts._cha;
|
|
2150
|
+
const classTaintedFields = new Map(); // className -> Set(bareFieldName)
|
|
2151
|
+
// SARD_80_F1 W5.48 — per-WRITER record alongside the per-class union just
|
|
2152
|
+
// above: className -> field -> Set(qid of a method whose OWN exit state
|
|
2153
|
+
// taints this field). `classTaintedFields` only ever answers "is this
|
|
2154
|
+
// field EVER tainted somewhere in the class" (unioned across every
|
|
2155
|
+
// method), which is what made the cross-class pass below seed a reader
|
|
2156
|
+
// like `goodG2BSink()` as tainted even though its own real caller chain
|
|
2157
|
+
// (`goodG2B()`) only ever writes this field a LITERAL — Juliet's Flow
|
|
2158
|
+
// Variant 65-68 idiom (`_68a`/`_68b`) has bad()/goodG2B()/goodB2G() each
|
|
2159
|
+
// write the SAME static field then immediately call their OWN uniquely-
|
|
2160
|
+
// named sink (badSink/goodG2BSink/goodB2GSink), so "ever tainted
|
|
2161
|
+
// anywhere in the class" is true (via bad()) but says nothing about
|
|
2162
|
+
// which SPECIFIC caller reaches a SPECIFIC reader. This map lets the
|
|
2163
|
+
// cross-class pass ask the narrower, call-string-sensitive question
|
|
2164
|
+
// instead: "is EVERY discoverable direct caller of this reader a
|
|
2165
|
+
// confirmed NON-tainting writer of this field" — see its own use site.
|
|
2166
|
+
const fieldWriterQids = new Map();
|
|
2167
|
+
if (cha && cha.methodOwners && cha.classes) {
|
|
2168
|
+
for (const fn of fnList) {
|
|
2169
|
+
if (Date.now() > deadlineMs) break;
|
|
2170
|
+
const className = cha.methodOwners.get(fn.qid);
|
|
2171
|
+
if (!className) continue;
|
|
2172
|
+
const cls = cha.classes.get(className);
|
|
2173
|
+
const declaredFields = cls && cls.fields;
|
|
2174
|
+
if (!declaredFields || !declaredFields.size) continue;
|
|
2175
|
+
const exitState = exitStateByQid.get(fn.qid);
|
|
2176
|
+
if (!exitState || !exitState.size) continue;
|
|
2177
|
+
for (const field of declaredFields) {
|
|
2178
|
+
// SARD_80_F1 W5.10 — PHP's own `$this` reference keeps its `$`
|
|
2179
|
+
// sigil in every access path (`$this.field`, never bare `this.` —
|
|
2180
|
+
// parser-php.js's own `->`-to-`.` normalization, W5.9), a fourth
|
|
2181
|
+
// spelling alongside JS's `_this_.`/C#'s and Java's bare `this.`.
|
|
2182
|
+
//
|
|
2183
|
+
// SARD_80_F1 W5.17 — `isCoveredBy` deliberately never propagates UP
|
|
2184
|
+
// (its own doc: `set={"x.y.z"}` does NOT cover "x.y"), which is
|
|
2185
|
+
// correct for ordinary field reads but wrong here: an ARRAY-INDEXED
|
|
2186
|
+
// field write (`$this->input[1] = $_GET[...]`) taints the access
|
|
2187
|
+
// path `this.input.1`, not the bare `this.input`/`input` this loop
|
|
2188
|
+
// queries — so a getter method returning `$this->input[1]` (the
|
|
2189
|
+
// PHP-Vuln-test-suite-generator's own "object/Array" input-
|
|
2190
|
+
// indirection sample, confirmed via direct reproduction, not corpus
|
|
2191
|
+
// access) was invisible to this whole class-field pass, while the
|
|
2192
|
+
// scalar-field form one line below (`$this->input = $_GET[...]`)
|
|
2193
|
+
// already worked. `pathIsCoveredByPrefix(entry, field)` checks the
|
|
2194
|
+
// OPPOSITE direction — does `entry` sit AT OR BELOW `field` — which
|
|
2195
|
+
// is exactly "did the constructor taint SOME sub-path of this
|
|
2196
|
+
// field", independent of which specific index or nesting depth.
|
|
2197
|
+
const hasTaintedSubpath = (prefix) => { for (const p of exitState) { if (pathIsCoveredByPrefix(p, prefix)) return true; } return false; };
|
|
2198
|
+
if (isCoveredBy(exitState, field) || isCoveredBy(exitState, `this.${field}`) || isCoveredBy(exitState, `_this_.${field}`) || isCoveredBy(exitState, `$this.${field}`)
|
|
2199
|
+
|| hasTaintedSubpath(field) || hasTaintedSubpath(`this.${field}`) || hasTaintedSubpath(`_this_.${field}`) || hasTaintedSubpath(`$this.${field}`)) {
|
|
2200
|
+
if (!classTaintedFields.has(className)) classTaintedFields.set(className, new Set());
|
|
2201
|
+
classTaintedFields.get(className).add(field);
|
|
2202
|
+
if (!fieldWriterQids.has(className)) fieldWriterQids.set(className, new Map());
|
|
2203
|
+
const fw = fieldWriterQids.get(className);
|
|
2204
|
+
if (!fw.has(field)) fw.set(field, new Set());
|
|
2205
|
+
fw.get(field).add(fn.qid);
|
|
2206
|
+
}
|
|
1617
2207
|
}
|
|
1618
2208
|
}
|
|
1619
2209
|
}
|
|
1620
|
-
|
|
2210
|
+
// SARD_80_F1 W5.50 — same reverse-call-graph lookup the cross-class pass
|
|
2211
|
+
// below uses (`callGraph.callersOf`, already built once by `buildCallGraph`
|
|
2212
|
+
// — see that pass's own header comment for why this reads the existing
|
|
2213
|
+
// index rather than re-deriving one), hoisted up so the SAME-class pass
|
|
2214
|
+
// immediately below can ALSO apply call-string precision. This pass had
|
|
2215
|
+
// the IDENTICAL over-approximation `classTaintedFields`'s own header
|
|
2216
|
+
// comment describes for the cross-class case — a sibling method reached
|
|
2217
|
+
// ONLY via a confirmed non-tainting writer was still blanket-seeded as
|
|
2218
|
+
// tainted, just scoped to siblings of the SAME class instead of a
|
|
2219
|
+
// different one. Genuinely a rarer shape here (per this pass's OWN
|
|
2220
|
+
// original comment, most sibling methods have no discoverable caller/
|
|
2221
|
+
// callee edge to each other at all — that remains true and still fails
|
|
2222
|
+
// closed correctly), but real whenever a sibling reader is directly
|
|
2223
|
+
// called from a KNOWN writer method (mirrors the cross-class idiom, just
|
|
2224
|
+
// without the class boundary).
|
|
2225
|
+
const _callerQidSetCache = new Map(); // fn.qid -> Set(callerQid)|null, memoized
|
|
2226
|
+
const callersOfQid = (qid) => {
|
|
2227
|
+
if (_callerQidSetCache.has(qid)) return _callerQidSetCache.get(qid);
|
|
2228
|
+
const edges = callGraph && callGraph.callersOf && callGraph.callersOf.get(qid);
|
|
2229
|
+
const set = edges && edges.length ? new Set(edges.map((e) => e.caller).filter(Boolean)) : null;
|
|
2230
|
+
_callerQidSetCache.set(qid, set);
|
|
2231
|
+
return set;
|
|
2232
|
+
};
|
|
2233
|
+
for (const [className, fields] of classTaintedFields) {
|
|
1621
2234
|
if (Date.now() > deadlineMs) break;
|
|
1622
2235
|
for (const fn of fnList) {
|
|
1623
|
-
if (
|
|
1624
|
-
|
|
2236
|
+
if (cha.methodOwners.get(fn.qid) !== className) continue;
|
|
2237
|
+
|
|
2238
|
+
// Call-string precision (mirrors the cross-class pass below): narrow
|
|
2239
|
+
// `fields` to only the ones fn's own discoverable callers can
|
|
2240
|
+
// actually supply as tainted. Fails closed (keeps the field seeded)
|
|
2241
|
+
// whenever fn has no discoverable caller, a caller outside this
|
|
2242
|
+
// class, or any caller not positively confirmed as a non-tainting
|
|
2243
|
+
// writer — this can ONLY ever narrow what was already being seeded,
|
|
2244
|
+
// never widen it, so it cannot introduce a new false negative beyond
|
|
2245
|
+
// what the pre-existing blanket behavior already accepted.
|
|
2246
|
+
const callers = callersOfQid(fn.qid);
|
|
2247
|
+
let seedFields = fields;
|
|
2248
|
+
if (callers && callers.size) {
|
|
2249
|
+
const effectiveFields = new Set();
|
|
2250
|
+
for (const f of fields) {
|
|
2251
|
+
const writers = fieldWriterQids.get(className) && fieldWriterQids.get(className).get(f);
|
|
2252
|
+
let keep = false;
|
|
2253
|
+
for (const callerQid of callers) {
|
|
2254
|
+
if (writers && writers.has(callerQid)) { keep = true; break; }
|
|
2255
|
+
if (cha.methodOwners.get(callerQid) !== className) { keep = true; break; }
|
|
2256
|
+
}
|
|
2257
|
+
if (keep) effectiveFields.add(f);
|
|
2258
|
+
}
|
|
2259
|
+
seedFields = effectiveFields;
|
|
2260
|
+
}
|
|
2261
|
+
if (!seedFields.size) continue; // every discoverable caller of fn provably never taints any of these fields
|
|
2262
|
+
|
|
2263
|
+
if (summaryCache.has(fn.qid, seedFields)) continue;
|
|
1625
2264
|
const ctx = {
|
|
1626
2265
|
_findings: [], _taintSources: [], _returnTainted: false,
|
|
1627
2266
|
_stack: new Set(), deadlineMs,
|
|
@@ -1631,21 +2270,138 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
1631
2270
|
_cha: opts._cha,
|
|
1632
2271
|
_pointsTo: opts._pointsTo,
|
|
1633
2272
|
};
|
|
1634
|
-
|
|
1635
|
-
//
|
|
1636
|
-
//
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
//
|
|
1641
|
-
//
|
|
1642
|
-
//
|
|
1643
|
-
|
|
2273
|
+
// Seed every spelling of every tainted field so a reading method sees
|
|
2274
|
+
// the taint regardless of which form it uses to reference the field
|
|
2275
|
+
// — mirrors the write-side check above, `$this.` (PHP, W5.10) included.
|
|
2276
|
+
const seeded = new Set();
|
|
2277
|
+
for (const f of seedFields) { seeded.add(f); seeded.add(`this.${f}`); seeded.add(`_this_.${f}`); seeded.add(`$this.${f}`); }
|
|
2278
|
+
try { analyzeFunction(fn, _unionAnnotationTaint(fn, seeded), ctx); } catch {}
|
|
2279
|
+
// Unlike the k=2 pass below, this is NOT speculative: `seedFields` was
|
|
2280
|
+
// derived from a REAL, already-observed write elsewhere in this exact
|
|
2281
|
+
// class (the loop above), so a sink this re-analysis finds is a real,
|
|
2282
|
+
// reachable flow — report it directly instead of waiting for a call
|
|
2283
|
+
// site that will never come (most sibling methods still have no
|
|
2284
|
+
// caller/callee relationship for the taint engine to discover; the
|
|
2285
|
+
// call-string narrowing just above only ever removes fields when one
|
|
2286
|
+
// WAS discoverable).
|
|
2287
|
+
_collectFindings(fn, ctx._findings);
|
|
2288
|
+
// `findings` still rides on the cached summary too, for the (rarer)
|
|
2289
|
+
// case where a real caller ALSO consults this exact qid+entry pair via
|
|
2290
|
+
// _mergeSummaryFindings — dedup in _collectFindings makes this safe.
|
|
2291
|
+
const fieldSummary = {
|
|
1644
2292
|
returnTainted: !!ctx._returnTainted,
|
|
1645
2293
|
mutatedParams: ctx._mutatedParamsOut || new Set(),
|
|
2294
|
+
mutatedThisFields: ctx._mutatedThisFieldsOut || new Set(),
|
|
1646
2295
|
taintedGlobals: new Set(),
|
|
1647
2296
|
findings: ctx._findings,
|
|
1648
|
-
}
|
|
2297
|
+
};
|
|
2298
|
+
summaryCache.set(fn.qid, seedFields, fieldSummary);
|
|
2299
|
+
// SARD_80_F1 W3.x — the getter+return interprocedural-composition gap
|
|
2300
|
+
// documented at PHP W5.10: an EXTERNAL, no-argument caller of this
|
|
2301
|
+
// method (`$tainted = $obj->getInput();`) never consults the
|
|
2302
|
+
// `fields`-keyed entry above — it looks up the ORDINARY empty-context
|
|
2303
|
+
// summary (`summaryCache.get(fn.qid, new Set())`), which the earlier
|
|
2304
|
+
// fixed-point pre-pass computed WITHOUT knowing the field was tainted
|
|
2305
|
+
// (that fact is only established by the class-scan just above, which
|
|
2306
|
+
// runs once, after the pre-pass). `entryContext = new Set()` means
|
|
2307
|
+
// "no CALLER-supplied parameter is tainted" — true here regardless of
|
|
2308
|
+
// how many params `fn` declares, since this re-analysis seeded ONLY
|
|
2309
|
+
// the class's own already-tainted fields, no parameters — so this
|
|
2310
|
+
// result is exactly the correct value for that key, not a guess.
|
|
2311
|
+
// Safe to overwrite unconditionally: this model has no un-taint step,
|
|
2312
|
+
// so a field-seeded re-analysis can only find a superset of whatever
|
|
2313
|
+
// the plain empty-context pre-pass already found. The final per-
|
|
2314
|
+
// function reporting pass (the `for (const fn of fnList)` loop that
|
|
2315
|
+
// does the REAL, non-speculative analysis of every function,
|
|
2316
|
+
// including whatever caller invokes this getter) runs strictly AFTER
|
|
2317
|
+
// this whole class-field block, so it is guaranteed to see the
|
|
2318
|
+
// corrected summary — no fixed-point re-iteration needed for THIS
|
|
2319
|
+
// specific external-caller shape.
|
|
2320
|
+
summaryCache.set(fn.qid, new Set(), fieldSummary);
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
// Cross-class static-field taint (SARD_80_F1_EXECUTION_PRD.md — Juliet flow
|
|
2325
|
+
// variant 68: a driver class writes a tainted value to its OWN static
|
|
2326
|
+
// field, then a COMPLETELY DIFFERENT class reads it back via a qualified
|
|
2327
|
+
// reference — `String data = OtherClass.field;` — with no parameter
|
|
2328
|
+
// passed at all. The same-class pass just above is deliberately scoped to
|
|
2329
|
+
// "a sibling method of the SAME class" (see its own header comment); this
|
|
2330
|
+
// is the same mechanism widened one hop further, to any function anywhere
|
|
2331
|
+
// that references `<taintedClassName>.<taintedField>` by name.
|
|
2332
|
+
//
|
|
2333
|
+
// Bounded by a cheap TEXT pre-filter rather than re-analyzing every
|
|
2334
|
+
// function in the project for every tainted class: only a function whose
|
|
2335
|
+
// OWN source text contains the literal `<ClassName>.` substring is a
|
|
2336
|
+
// candidate at all (Juliet's own per-test-case files reference at most a
|
|
2337
|
+
// couple of sibling classes each, so this stays cheap even across a
|
|
2338
|
+
// 17000+ function corpus). `opts.fileContents` is the same map already
|
|
2339
|
+
// threaded through for incremental-cache hashing; falls back to
|
|
2340
|
+
// no-op when absent (in-process callers that don't pass it), same
|
|
2341
|
+
// graceful-degradation convention as this file's other opt-in passes.
|
|
2342
|
+
const fileContents = opts.fileContents;
|
|
2343
|
+
if (cha && fileContents && classTaintedFields.size) {
|
|
2344
|
+
// `callersOfQid` (SARD_80_F1 W5.48) is hoisted above the same-class pass
|
|
2345
|
+
// now that pass ALSO consults it (W5.50) — reused here rather than
|
|
2346
|
+
// re-derived, same memoized map, zero extra cost.
|
|
2347
|
+
for (const [className, fields] of classTaintedFields) {
|
|
2348
|
+
if (Date.now() > deadlineMs) break;
|
|
2349
|
+
const needle = `${className}.`;
|
|
2350
|
+
for (const fn of fnList) {
|
|
2351
|
+
if (Date.now() > deadlineMs) break;
|
|
2352
|
+
if (cha.methodOwners.get(fn.qid) === className) continue; // already covered above
|
|
2353
|
+
const src = fileContents[fn.file];
|
|
2354
|
+
if (typeof src !== 'string' || !src.includes(needle)) continue;
|
|
2355
|
+
|
|
2356
|
+
// Call-string precision: narrow `fields` to only the ones fn should
|
|
2357
|
+
// actually be seeded with. A field drops out only when EVERY direct
|
|
2358
|
+
// caller of fn is a same-class method we POSITIVELY confirmed does
|
|
2359
|
+
// NOT taint that field (absent from `fieldWriterQids`) — never when
|
|
2360
|
+
// a caller is unresolved, ambiguous, or outside this class, since
|
|
2361
|
+
// those cases carry no evidence either way. This is deliberately a
|
|
2362
|
+
// narrow, local instance of call-string sensitivity (Juliet's own
|
|
2363
|
+
// "write-then-immediately-call-a-uniquely-named-sink" idiom, one
|
|
2364
|
+
// direct hop) rather than a general k>1 CFA — see this file's own
|
|
2365
|
+
// CLAUDE.md "Scope — what we still do NOT model" for why the general
|
|
2366
|
+
// form is out of scope. Fails closed (keeps the field seeded) the
|
|
2367
|
+
// instant precision can't be established, same direction every
|
|
2368
|
+
// other heuristic in this pass already takes.
|
|
2369
|
+
const callers = callersOfQid(fn.qid);
|
|
2370
|
+
const effectiveFields = new Set();
|
|
2371
|
+
for (const f of fields) {
|
|
2372
|
+
if (!callers || !callers.size) { effectiveFields.add(f); continue; }
|
|
2373
|
+
const writers = fieldWriterQids.get(className) && fieldWriterQids.get(className).get(f);
|
|
2374
|
+
let keep = false;
|
|
2375
|
+
for (const callerQid of callers) {
|
|
2376
|
+
if (writers && writers.has(callerQid)) { keep = true; break; }
|
|
2377
|
+
if (cha.methodOwners.get(callerQid) !== className) { keep = true; break; }
|
|
2378
|
+
}
|
|
2379
|
+
if (keep) effectiveFields.add(f);
|
|
2380
|
+
}
|
|
2381
|
+
if (!effectiveFields.size) continue; // every caller of fn provably never taints any of these fields
|
|
2382
|
+
|
|
2383
|
+
const qualifiedSeeds = new Set();
|
|
2384
|
+
for (const f of effectiveFields) qualifiedSeeds.add(`${className}.${f}`);
|
|
2385
|
+
if (summaryCache.has(fn.qid, qualifiedSeeds)) continue;
|
|
2386
|
+
const ctx = {
|
|
2387
|
+
_findings: [], _taintSources: [], _returnTainted: false,
|
|
2388
|
+
_stack: new Set(), deadlineMs,
|
|
2389
|
+
_summaryCache: summaryCache, _callGraph: callGraph,
|
|
2390
|
+
_mutatedParamsOut: new Set(),
|
|
2391
|
+
_currentFnQid: fn.qid,
|
|
2392
|
+
_cha: opts._cha,
|
|
2393
|
+
_pointsTo: opts._pointsTo,
|
|
2394
|
+
};
|
|
2395
|
+
try { analyzeFunction(fn, _unionAnnotationTaint(fn, qualifiedSeeds), ctx); } catch {}
|
|
2396
|
+
_collectFindings(fn, ctx._findings);
|
|
2397
|
+
summaryCache.set(fn.qid, qualifiedSeeds, {
|
|
2398
|
+
returnTainted: !!ctx._returnTainted,
|
|
2399
|
+
mutatedParams: ctx._mutatedParamsOut || new Set(),
|
|
2400
|
+
mutatedThisFields: ctx._mutatedThisFieldsOut || new Set(),
|
|
2401
|
+
taintedGlobals: new Set(),
|
|
2402
|
+
findings: ctx._findings,
|
|
2403
|
+
});
|
|
2404
|
+
}
|
|
1649
2405
|
}
|
|
1650
2406
|
}
|
|
1651
2407
|
|
|
@@ -1686,6 +2442,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
1686
2442
|
summaryCache.set(fn.qid, taintedEntry, {
|
|
1687
2443
|
returnTainted: !!ctx._returnTainted,
|
|
1688
2444
|
mutatedParams: ctx._mutatedParamsOut || new Set(),
|
|
2445
|
+
mutatedThisFields: ctx._mutatedThisFieldsOut || new Set(),
|
|
1689
2446
|
taintedGlobals: new Set(),
|
|
1690
2447
|
findings: ctx._findings,
|
|
1691
2448
|
});
|
|
@@ -1757,6 +2514,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
1757
2514
|
return {
|
|
1758
2515
|
returnTainted: !!inner._returnTainted,
|
|
1759
2516
|
mutatedParams: inner._mutatedParamsOut || new Set(),
|
|
2517
|
+
mutatedThisFields: inner._mutatedThisFieldsOut || new Set(),
|
|
1760
2518
|
taintedGlobals: new Set(),
|
|
1761
2519
|
findings: inner._findings,
|
|
1762
2520
|
};
|
|
@@ -1801,13 +2559,26 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
1801
2559
|
// genuine caller is established.
|
|
1802
2560
|
function _collectFindings(attributedFn, srcFindings) {
|
|
1803
2561
|
for (const f of srcFindings) {
|
|
1804
|
-
|
|
2562
|
+
// SARD_80_F1 W4.C21/C22 — prefer the finding's OWN `file` (now stamped
|
|
2563
|
+
// at discovery time inside analyzeFunction, using the function that
|
|
2564
|
+
// ACTUALLY produced it) over `attributedFn.file`. `attributedFn` here
|
|
2565
|
+
// is whichever function the CALLER of `_collectFindings` happens to
|
|
2566
|
+
// be processing — correct for a finding discovered directly in that
|
|
2567
|
+
// function's own CFG walk, but WRONG for one merged in from a
|
|
2568
|
+
// callee's summary via `_mergeSummaryFindings` (most commonly a
|
|
2569
|
+
// cross-file interprocedural call): that finding genuinely belongs to
|
|
2570
|
+
// the callee's own file/line, which `f.file` now carries correctly.
|
|
2571
|
+
// The `attributedFn.file` fallback stays for defense-in-depth only —
|
|
2572
|
+
// every path pushing into `_findings` sets `file` explicitly as of
|
|
2573
|
+
// this fix.
|
|
2574
|
+
const file = f.file || attributedFn.file;
|
|
2575
|
+
const key = `${f.sinkId}:${file}:${f.line}`;
|
|
1805
2576
|
if (seen.has(key)) continue;
|
|
1806
2577
|
seen.add(key);
|
|
1807
2578
|
const fn = attributedFn;
|
|
1808
2579
|
all.push({
|
|
1809
|
-
id: `ir-taint:${
|
|
1810
|
-
file
|
|
2580
|
+
id: `ir-taint:${file}:${f.line}:${f.sinkId}`,
|
|
2581
|
+
file,
|
|
1811
2582
|
line: f.line,
|
|
1812
2583
|
vuln: f.vuln,
|
|
1813
2584
|
severity: f.severity,
|
|
@@ -1839,6 +2610,20 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
1839
2610
|
// backward-slice annotation was permanently a no-op regardless of
|
|
1840
2611
|
// AGENTIC_SECURITY_BACKWARD_SLICE.
|
|
1841
2612
|
...(f._funcQid ? { _funcQid: f._funcQid } : {}),
|
|
2613
|
+
// argIndex: which sink argument is the tainted one (a number, or the
|
|
2614
|
+
// literal 'all'/'rhs') — set upstream at the finding's creation site
|
|
2615
|
+
// but, exactly like `_funcQid`/`callee` above, silently dropped by
|
|
2616
|
+
// this same allowlist before backward.js's `annotateBackwardSlices`
|
|
2617
|
+
// ever saw it. Without it, `annotateBackwardSlices` falls back to
|
|
2618
|
+
// the placeholder string `arg[undefined]` for EVERY finding (it can
|
|
2619
|
+
// never be a real access path, since no source ever names a
|
|
2620
|
+
// variable that literally), so `sliceBackward` always started and
|
|
2621
|
+
// ended at the bare sink node with no source/sanitize steps at all
|
|
2622
|
+
// — the backward-slice pass was a structural no-op for real findings
|
|
2623
|
+
// regardless of AGENTIC_SECURITY_BACKWARD_SLICE, discovered while
|
|
2624
|
+
// rebuilding the SMT path-feasibility mechanism (next-gen taint
|
|
2625
|
+
// capability #5) to depend on a REAL slice for a sound proof.
|
|
2626
|
+
...(f.argIndex !== undefined ? { argIndex: f.argIndex } : {}),
|
|
1842
2627
|
// callee: kept as plain `callee` (not underscore-prefixed) to match
|
|
1843
2628
|
// backward.js's own contract, which reads `f.callee` on both real and
|
|
1844
2629
|
// fake-fixture findings throughout its module and test suite. It is
|