@clear-capabilities/agentic-security-scanner 0.137.0 → 0.139.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 +219 -0
- package/dist/113.index.js +2 -2
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +29 -1
- package/dist/526.index.js +2 -2
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +14 -14
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +10 -6
- package/src/dataflow/CLAUDE.md +30 -0
- package/src/dataflow/catalog.js +512 -14
- package/src/dataflow/engine.js +275 -27
- package/src/dataflow/summaries.js +30 -5
- package/src/engine.js +512 -120
- package/src/ir/CLAUDE.md +20 -5
- package/src/ir/balanced-call.js +11 -1
- package/src/ir/callgraph.js +34 -0
- package/src/ir/parser-cs.js +55 -6
- package/src/ir/parser-go.js +106 -2
- package/src/ir/parser-java.js +111 -10
- package/src/ir/parser-js.js +40 -0
- package/src/ir/parser-kt.js +194 -10
- package/src/ir/parser-php.js +108 -6
- package/src/ir/parser-py.helper.py +199 -10
- package/src/ir/parser-rb.js +405 -31
- package/src/mcp/tools.js +29 -1
- package/src/posture/accuracy-scorecard.js +103 -0
- package/src/runScan.js +5 -2
- package/src/sast/CLAUDE.md +1 -1
- package/src/sast/_auth-signals.js +141 -0
- package/src/sast/_comment-strip.js +80 -13
- package/src/sast/codegen-sink.js +110 -0
- package/src/sast/convention-deviation.js +235 -0
- package/src/sast/fastapi-hardening.js +45 -6
- package/src/sast/file-upload.js +29 -1
- package/src/sast/ownership-authz.js +245 -0
- package/src/sast/php.js +12 -2
- package/src/sast/rate-limit.js +2 -0
- package/src/sast/rbac-consistency.js +1 -1
- package/src/sast/redirect-toctou.js +167 -0
- package/src/sast/resource-exhaustion.js +217 -0
- package/src/sast/sibling-guard.js +176 -0
- package/src/sast/zip-slip.js +53 -2
package/src/dataflow/engine.js
CHANGED
|
@@ -65,6 +65,27 @@ import { resolveMethod, classOfVar } from '../ir/class-hierarchy.js';
|
|
|
65
65
|
// AGENTIC_SECURITY_POINTS_TO=1).
|
|
66
66
|
function _addPathAliasAware(state, path, callContext) {
|
|
67
67
|
let s = addPath(state, path);
|
|
68
|
+
// T3.3 — a write through an UNKNOWN key taints the whole container.
|
|
69
|
+
//
|
|
70
|
+
// `parser-js.js` lowers a computed write whose key is not a literal
|
|
71
|
+
// (`bag[k] = tainted`) to the access path `bag.*`, and — as that parser's own
|
|
72
|
+
// comment states — `'*'` is a LITERAL property name here, not a
|
|
73
|
+
// match-anything token. `isCoveredBy` only propagates DOWN from a prefix, so
|
|
74
|
+
// `bag.*` covers nothing: a later read of `bag.anything` resolves to a
|
|
75
|
+
// different path entirely and the taint is unreachable from every correctly
|
|
76
|
+
// computed read.
|
|
77
|
+
//
|
|
78
|
+
// Since the key is statically unknown, the write may have landed on ANY
|
|
79
|
+
// property, so the sound abstraction is the container itself. Widening to the
|
|
80
|
+
// base is what makes the value observable again, and it is the same
|
|
81
|
+
// over-approximate direction the lattice already takes at branch joins.
|
|
82
|
+
//
|
|
83
|
+
// Scoped deliberately to a TRAILING `.*`. An interior wildcard
|
|
84
|
+
// (`bag.*.inner`) still describes a definite final property and does not
|
|
85
|
+
// justify tainting the root.
|
|
86
|
+
if (typeof path === 'string' && path.length > 2 && path.endsWith('.*')) {
|
|
87
|
+
s = addPath(s, path.slice(0, -2));
|
|
88
|
+
}
|
|
68
89
|
const pt = callContext && callContext._pointsTo;
|
|
69
90
|
const fnQid = callContext && callContext._currentFnQid;
|
|
70
91
|
if (!pt || !fnQid || typeof path !== 'string') return s;
|
|
@@ -268,7 +289,7 @@ function _nestedCallReturnTainted(calleeExpr, argExprs, state, callContext) {
|
|
|
268
289
|
const { qid, fn } = target;
|
|
269
290
|
const paramNames = (fn && Array.isArray(fn.params)) ? fn.params : [];
|
|
270
291
|
const entry = paramNames.length
|
|
271
|
-
? entryStateFromCall(paramNames, argExprs || [], state)
|
|
292
|
+
? entryStateFromCall(paramNames, argExprs || [], state, (a) => exprTaint(a, state, callContext))
|
|
272
293
|
: new Set();
|
|
273
294
|
let sum = callContext._summaryCache.get(qid, entry);
|
|
274
295
|
if (!sum && fn && fn.cfg) {
|
|
@@ -294,8 +315,32 @@ function _nestedCallReturnTainted(calleeExpr, argExprs, state, callContext) {
|
|
|
294
315
|
return !!(sum && sum.returnTainted);
|
|
295
316
|
}
|
|
296
317
|
|
|
318
|
+
// Taint-recall PRD (80%): is a call's own RECEIVER tainted? Handles the two
|
|
319
|
+
// distinct shapes this codebase's IR frontends use for a call's `callee`:
|
|
320
|
+
// parser-js.js (Babel) emits a structured {kind:'member', object, prop}
|
|
321
|
+
// node, so the receiver is `callee.object`, checked via a normal exprTaint
|
|
322
|
+
// recursion. parser-cs.js/parser-go.js/parser-kt.js/parser-php.js/
|
|
323
|
+
// parser-rb.js/parser-java.js/parser-py.js instead emit a flat, dot-joined
|
|
324
|
+
// STRING callee ("it.toString", "user.getName") — there is no sub-expression
|
|
325
|
+
// to recurse into, so the receiver is recovered by slicing off the string
|
|
326
|
+
// after its LAST '.' and checking that prefix directly against the access-
|
|
327
|
+
// path lattice via `isCoveredBy` (handles both a bare var and a longer
|
|
328
|
+
// chain, e.g. "req.session.user.toString" -> receiver "req.session.user").
|
|
329
|
+
function _calleeReceiverTainted(callee, state, callContext) {
|
|
330
|
+
if (!callee) return false;
|
|
331
|
+
if (typeof callee === 'string') {
|
|
332
|
+
const idx = callee.lastIndexOf('.');
|
|
333
|
+
if (idx <= 0) return false;
|
|
334
|
+
return isCoveredBy(state, callee.slice(0, idx));
|
|
335
|
+
}
|
|
336
|
+
if (callee.kind === 'member' && callee.object) {
|
|
337
|
+
return exprTaint(callee.object, state, callContext);
|
|
338
|
+
}
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
|
|
297
342
|
function exprTaint(expr, state, callContext) {
|
|
298
|
-
if (expr && (expr.kind === 'member' || expr.kind === 'call') && exprIsSource(expr)) return true;
|
|
343
|
+
if (expr && (expr.kind === 'member' || expr.kind === 'call' || expr.kind === 'ident') && exprIsSource(expr)) return true;
|
|
299
344
|
if (!expr) return false;
|
|
300
345
|
// Constant propagation: variables assigned from literals are never tainted
|
|
301
346
|
if (expr.kind === 'ident' && _activeConstantVars && _activeConstantVars.has(expr.name)) return false;
|
|
@@ -314,12 +359,44 @@ function exprTaint(expr, state, callContext) {
|
|
|
314
359
|
case 'object': return (expr.props || []).some(p => exprTaint(p.value, state, callContext));
|
|
315
360
|
case 'array': return (expr.elements || []).some(e => exprTaint(e, state, callContext));
|
|
316
361
|
case 'call': {
|
|
317
|
-
// The call's own arguments
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
|
|
362
|
+
// The call's own arguments OR — PRD R10 — the resolved callee's own
|
|
363
|
+
// return-taint summary. Taint-recall PRD (80%): this used to
|
|
364
|
+
// short-circuit on args-tainted and SKIP _nestedCallReturnTainted
|
|
365
|
+
// entirely — but that call's real job isn't just the boolean it
|
|
366
|
+
// returns, it's the _mergeSummaryFindings side effect that surfaces
|
|
367
|
+
// the CALLEE's own internal sink findings (e.g. `return
|
|
368
|
+
// helper(taintedArg)` where `helper`'s body itself contains
|
|
369
|
+
// `sink(param)`). Short-circuiting on "args are tainted" (the
|
|
370
|
+
// overwhelmingly common interprocedural shape — a tainted value IS
|
|
371
|
+
// usually passed as an argument) silently dropped exactly the
|
|
372
|
+
// findings this mechanism exists to surface. Confirmed via direct
|
|
373
|
+
// fixture debugging (PHP: `$name = get_name(); return
|
|
374
|
+
// find_user($doc, $name);` produced zero findings until this fix,
|
|
375
|
+
// even though find_user's own body has a cataloged sink fed by
|
|
376
|
+
// $name) — not language-specific, this is generic exprTaint logic.
|
|
377
|
+
// Both sides always evaluated now (no short-circuit either
|
|
378
|
+
// direction) so the merge always runs when a call expression is
|
|
379
|
+
// visited; _resolveCalleeForSummary + SummaryCache make repeat
|
|
380
|
+
// resolution/computation for the same (qid, entry-state) cheap.
|
|
381
|
+
const argsTainted = (expr.args || []).some(a => exprTaint(a, state, callContext));
|
|
382
|
+
const nestedTainted = _nestedCallReturnTainted(expr.callee, expr.args, state, callContext);
|
|
383
|
+
// Taint-recall PRD (80%): a call's RECEIVER can itself be tainted
|
|
384
|
+
// independent of its arguments — `tainted.toString()`, `tainted.trim()`,
|
|
385
|
+
// `it.getBytes()` — and neither argsTainted (there are no/unrelated
|
|
386
|
+
// args) nor nestedTainted (that resolves a FUNCTION-NAME callee via the
|
|
387
|
+
// call graph; a bare method name like "toString" never resolves) sees
|
|
388
|
+
// it. Confirmed via `req.getBytes()` (Java @RequestBody InputStream) and
|
|
389
|
+
// the general shape this codebase's five hand-rolled parsers all
|
|
390
|
+
// produce for a chained call: `expr.callee` there is a flat DOT-JOINED
|
|
391
|
+
// STRING ("it.toString"), not a structured {kind:'member', object,
|
|
392
|
+
// prop} node like parser-js.js emits — so the two shapes need distinct
|
|
393
|
+
// handling. Deliberately receiver-taint-propagates for ANY call on a
|
|
394
|
+
// tainted receiver (not just no-arg ones): `tainted.replace(a,b)`'s
|
|
395
|
+
// receiver taint matters exactly as much as `tainted.toString()`'s, and
|
|
396
|
+
// this is already OR'd with argsTainted so it only WIDENS recall,
|
|
397
|
+
// matching this engine's recall-preserving precedent everywhere else.
|
|
398
|
+
const receiverTainted = _calleeReceiverTainted(expr.callee, state, callContext);
|
|
399
|
+
return argsTainted || nestedTainted || receiverTainted;
|
|
323
400
|
}
|
|
324
401
|
case 'unknown': return false;
|
|
325
402
|
default: return false;
|
|
@@ -396,6 +473,24 @@ function exprIsSource(expr) {
|
|
|
396
473
|
const hit = matchSource(expr, _currentFile);
|
|
397
474
|
if (hit) return hit;
|
|
398
475
|
}
|
|
476
|
+
// Taint-recall PRD (80%): bare-identifier GLOBAL sources — PHP's $_GET/
|
|
477
|
+
// $_POST/$_REQUEST/$_SERVER, Ruby's params/cookies/session/ENV, JS's
|
|
478
|
+
// location — referenced DIRECTLY (not first assigned to a local, not a
|
|
479
|
+
// member-read off them) were completely unreachable here. matchSource
|
|
480
|
+
// itself has always supported a bare-ident branch (GLOBAL_INDEX lookup by
|
|
481
|
+
// the identifier's own name), but neither this function nor exprTaint's
|
|
482
|
+
// early check ever called it for expr.kind === 'ident' — only
|
|
483
|
+
// 'member'/'call'. So `header("X: " . $_GET)` (or any shape where a
|
|
484
|
+
// global is read directly rather than through member/subscript access)
|
|
485
|
+
// never tainted anything, no matter how the value was actually used.
|
|
486
|
+
// Confirmed via a real corpus fixture whose PHP parser also mis-splits
|
|
487
|
+
// `$_GET["trace"]`'s string literal, leaving a bare `$_GET` ident in a
|
|
488
|
+
// template's parts — this fix makes that residue still count, which is
|
|
489
|
+
// the right behavior regardless of that separate mis-split bug.
|
|
490
|
+
if (expr.kind === 'ident') {
|
|
491
|
+
const hit = matchSource(expr, _currentFile);
|
|
492
|
+
if (hit) return hit;
|
|
493
|
+
}
|
|
399
494
|
if (expr.kind === 'member' && expr.object) {
|
|
400
495
|
return exprIsSource(expr.object);
|
|
401
496
|
}
|
|
@@ -404,7 +499,6 @@ function exprIsSource(expr) {
|
|
|
404
499
|
|
|
405
500
|
const _SQL_KEYWORDS = /\b(SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|UNION|WHERE|FROM|JOIN|INTO|VALUES|SET|EXEC|EXECUTE)\b/i;
|
|
406
501
|
const _HTML_META = /[<>'"&]|innerHTML|outerHTML|document\.write/;
|
|
407
|
-
const _SHELL_META = /[;|`$(){}]|&&|\|\|/;
|
|
408
502
|
|
|
409
503
|
function _literalPartsOfExpr(expr) {
|
|
410
504
|
if (!expr) return [];
|
|
@@ -421,7 +515,25 @@ function literalSkeletonMatchesFamily(expr, cwe) {
|
|
|
421
515
|
if (!joined.trim()) return true;
|
|
422
516
|
if (cwe === 'CWE-89' || cwe === 'CWE-943') return _SQL_KEYWORDS.test(joined);
|
|
423
517
|
if (cwe === 'CWE-79') return _HTML_META.test(joined);
|
|
424
|
-
|
|
518
|
+
// Taint-recall PRD (80%): CWE-78 (command injection) deliberately does NOT
|
|
519
|
+
// require the STATIC portion of the concat to contain a shell
|
|
520
|
+
// metacharacter. The SQL/HTML checks above make sense because their sinks
|
|
521
|
+
// (a generic `.raw()`/`.query()` call, an `innerHTML` assignment) can
|
|
522
|
+
// legitimately carry non-SQL, non-HTML strings — the skeleton check filters
|
|
523
|
+
// those out. Command-injection's sink catalog has no such ambiguity: every
|
|
524
|
+
// CWE-78 entry (`os.system`, `subprocess.call`, `Runtime.exec`,
|
|
525
|
+
// `child_process.exec`, …) is an unambiguous shell-execution API, so a
|
|
526
|
+
// tainted argument reaching one is inherently suspicious regardless of what
|
|
527
|
+
// the static prefix looks like. Requiring a metacharacter in the STATIC
|
|
528
|
+
// portion gets the vulnerability backwards: the textbook shape is
|
|
529
|
+
// `exec("ping " + host)` — the attacker supplies the metacharacter via the
|
|
530
|
+
// TAINTED value, not the template, so the static prefix is legitimately
|
|
531
|
+
// metacharacter-free in the overwhelmingly common case. Confirmed this was
|
|
532
|
+
// blocking CVE-2016-10033-java-cmdi's exact real-world shape; found while
|
|
533
|
+
// investigating this PRD's Java chained-call fix and left deferred until
|
|
534
|
+
// now, when the corpus's Tier 2/3 audit showed it plausibly gates a large
|
|
535
|
+
// fraction of the whole command-injection family, not just one entry.
|
|
536
|
+
if (cwe === 'CWE-78') return true;
|
|
425
537
|
return true;
|
|
426
538
|
}
|
|
427
539
|
|
|
@@ -503,7 +615,7 @@ function _sanitizersForExpr(expr, callContext) {
|
|
|
503
615
|
// expression (via callContext._taintSources, not the state Set itself).
|
|
504
616
|
// line: source line to attach to any emitted finding.
|
|
505
617
|
// Returns { findings }.
|
|
506
|
-
function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callContext, line) {
|
|
618
|
+
function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callContext, line, callKwargs) {
|
|
507
619
|
const findings = [];
|
|
508
620
|
if (cat) {
|
|
509
621
|
for (const e of cat) {
|
|
@@ -516,6 +628,57 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
|
|
|
516
628
|
const taintedArgExpr = (argExprs || [])[taintedArgIdx];
|
|
517
629
|
// String content analysis: skip if literal skeleton doesn't match injection family
|
|
518
630
|
if (e.vuln && taintedArgExpr && !literalSkeletonMatchesFamily(taintedArgExpr, e.vuln.cwe)) continue;
|
|
631
|
+
// Taint-recall PRD (80%): `match.requireLiteralArg` — a precision
|
|
632
|
+
// gate for sinks whose danger depends on a SIBLING argument's
|
|
633
|
+
// literal value, not the tainted one. Go's `exec.Command(path,
|
|
634
|
+
// args...)` is only actually shell-interpreted (so a metacharacter
|
|
635
|
+
// in a tainted arg is dangerous) when `path` is "/bin/sh"/"bash"/
|
|
636
|
+
// "sh" and the next arg is "-c" — the array-execve form
|
|
637
|
+
// (`exec.Command("ping", "-c", "1", host)`) never invokes a shell
|
|
638
|
+
// at all, so `host` becomes one opaque argv element regardless of
|
|
639
|
+
// its content and is SAFE even when tainted. `argIndex: 'all'`
|
|
640
|
+
// alone can't express this (it only asks "is ANY arg tainted", not
|
|
641
|
+
// "is the INVOCATION FORM itself dangerous"). Fails CLOSED on
|
|
642
|
+
// precision: if the checked arg isn't a literal at all (a
|
|
643
|
+
// variable — we can't statically know its value), the requirement
|
|
644
|
+
// is NOT satisfied, same direction proof-gate.js and every other
|
|
645
|
+
// precision annotator in this codebase take when evidence is
|
|
646
|
+
// simply unavailable rather than affirmatively clean.
|
|
647
|
+
if (e.match && e.match.requireLiteralArg) {
|
|
648
|
+
const { index, pattern } = e.match.requireLiteralArg;
|
|
649
|
+
const checkArg = (argExprs || [])[index];
|
|
650
|
+
if (!checkArg || checkArg.kind !== 'literal' || !new RegExp(pattern).test(String(checkArg.value))) continue;
|
|
651
|
+
}
|
|
652
|
+
// `match.requireKeyword` — the KEYWORD-argument analogue of the
|
|
653
|
+
// positional gate above, for languages where the dangerous form is
|
|
654
|
+
// selected by a named argument rather than a positional literal.
|
|
655
|
+
//
|
|
656
|
+
// Python's `subprocess.run(cmd, shell=True)` is command injection;
|
|
657
|
+
// `subprocess.run([cmd], capture_output=True)` is an argv-array
|
|
658
|
+
// execve that cannot be, however tainted `cmd` is. requireLiteralArg
|
|
659
|
+
// cannot express the difference — it matches a POSITIONAL literal,
|
|
660
|
+
// and shell is a keyword. Measured cost of not having this: enabling
|
|
661
|
+
// the argparse CLI source surfaced 15 findings across 9 of this
|
|
662
|
+
// repository's own hand-reviewed scripts, on argv-array calls,
|
|
663
|
+
// labelled "shell=True".
|
|
664
|
+
//
|
|
665
|
+
// Fails CLOSED exactly like requireLiteralArg: a keyword that is
|
|
666
|
+
// absent, or present but not a statically-known literal matching the
|
|
667
|
+
// pattern, does NOT satisfy the requirement.
|
|
668
|
+
if (e.match && e.match.requireKeyword) {
|
|
669
|
+
const { name, pattern } = e.match.requireKeyword;
|
|
670
|
+
// A `**opts` splat means the keyword set is not enumerable: we
|
|
671
|
+
// cannot prove `shell` is absent, so we must not suppress. Failing
|
|
672
|
+
// closed here would be a FALSE NEGATIVE on a genuinely exploitable
|
|
673
|
+
// call — bench/cve-replay/deep/py-interproc-cmdi-shape exists
|
|
674
|
+
// precisely to pin that case (`subprocess.call(cmd, **SHELL_OPTS)`)
|
|
675
|
+
// and caught this the first time the gate was written without it.
|
|
676
|
+
const enumerable = !(callKwargs && callKwargs['**']);
|
|
677
|
+
if (enumerable) {
|
|
678
|
+
const kw = callKwargs && callKwargs[name];
|
|
679
|
+
if (!kw || kw.kind !== 'literal' || !new RegExp(pattern).test(String(kw.value))) continue;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
519
682
|
// Premortem #10: attribute the source for THIS sink to the
|
|
520
683
|
// source(s) that taint the actual argument expression — not the
|
|
521
684
|
// first source the worklist happened to record. We walk the
|
|
@@ -552,6 +715,36 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
|
|
|
552
715
|
return { findings };
|
|
553
716
|
}
|
|
554
717
|
|
|
718
|
+
// Taint-recall PRD (80%): a sink call NESTED inside another call's own
|
|
719
|
+
// argument (`render(File.read(tainted))`, Rails' idiomatic wrap-and-render
|
|
720
|
+
// pattern) was invisible to sink-matching entirely. `_sinkFindingsForCall`
|
|
721
|
+
// only ever checks the CFG NODE'S OWN callee/args — a node representing
|
|
722
|
+
// `render(...)` never independently examines whether one of `render`'s OWN
|
|
723
|
+
// arguments is itself a sink-shaped call expression. Confirmed via a real
|
|
724
|
+
// corpus fixture: `File.read` is a correctly-cataloged sink and `name` a
|
|
725
|
+
// correctly-cataloged source, but nested one level inside `render`'s own
|
|
726
|
+
// arg list, neither `case 'call'` nor `case 'assign'` ever checked it — the
|
|
727
|
+
// exact "sink nested inside another call's own argument" limitation
|
|
728
|
+
// documented in this PRD's Ruby/C#/Go/Kotlin corpus notes, now closed for
|
|
729
|
+
// the general case rather than left as a permanent gap. Recall-preserving:
|
|
730
|
+
// walks every arg looking for a nested call expression, checks each
|
|
731
|
+
// independently against the catalog, and recurses further (an arg's arg
|
|
732
|
+
// can itself be a call) — bounded by JS's own call-stack depth on
|
|
733
|
+
// pathologically deep expressions, no explicit cap needed at this level of
|
|
734
|
+
// real-world nesting. Deliberately does NOT re-check the TOP-LEVEL call
|
|
735
|
+
// itself (the caller already did that via its own _matchCallCatalog +
|
|
736
|
+
// _sinkFindingsForCall), so this never duplicates a finding.
|
|
737
|
+
function _nestedSinkFindings(argExprs, state, callContext, line) {
|
|
738
|
+
const findings = [];
|
|
739
|
+
for (const arg of (argExprs || [])) {
|
|
740
|
+
if (!arg || arg.kind !== 'call') continue;
|
|
741
|
+
const { cat, argTaints } = _matchCallCatalog(arg.callee, arg.args, state, callContext);
|
|
742
|
+
findings.push(..._sinkFindingsForCall(arg.callee, arg.args, cat, argTaints, state, callContext, line).findings);
|
|
743
|
+
findings.push(..._nestedSinkFindings(arg.args, state, callContext, line));
|
|
744
|
+
}
|
|
745
|
+
return findings;
|
|
746
|
+
}
|
|
747
|
+
|
|
555
748
|
// PRD R13(a): finding shape for a member-write sink match (el.innerHTML =
|
|
556
749
|
// tainted). Distinct from _sinkFindingsForCall because there is no call
|
|
557
750
|
// argument list to index into — the "argument" of interest is the whole
|
|
@@ -639,7 +832,8 @@ function step(node, stateIn, callContext) {
|
|
|
639
832
|
_matchCallCatalog(node.source.callee, node.source.args, state, callContext);
|
|
640
833
|
findings.push(..._sinkFindingsForCall(
|
|
641
834
|
node.source.callee, node.source.args, _sinkCat, _sinkArgTaints,
|
|
642
|
-
state, callContext, node.line).findings);
|
|
835
|
+
state, callContext, node.line, node.source.kwargs).findings);
|
|
836
|
+
findings.push(..._nestedSinkFindings(node.source.args, state, callContext, node.line));
|
|
643
837
|
}
|
|
644
838
|
// PRD R13(a): the assignment TARGET can itself be a sink shape
|
|
645
839
|
// (el.innerHTML = tainted) — additive to the RHS-call-sink check
|
|
@@ -694,7 +888,7 @@ function step(node, stateIn, callContext) {
|
|
|
694
888
|
const callArgs = (node.source.args || []);
|
|
695
889
|
const paramNames = (fn && Array.isArray(fn.params)) ? fn.params : [];
|
|
696
890
|
const entry = paramNames.length
|
|
697
|
-
? entryStateFromCall(paramNames, callArgs, callerTainted)
|
|
891
|
+
? entryStateFromCall(paramNames, callArgs, callerTainted, (a) => exprTaint(a, callerTainted, callContext))
|
|
698
892
|
: new Set();
|
|
699
893
|
let sum = callContext._summaryCache.get(qid, entry);
|
|
700
894
|
if (!sum && fn && fn.cfg) {
|
|
@@ -825,7 +1019,7 @@ function step(node, stateIn, callContext) {
|
|
|
825
1019
|
if (typeof qid === 'string' && fn && Array.isArray(fn.params)) {
|
|
826
1020
|
const paramNames = fn.params;
|
|
827
1021
|
const entry = paramNames.length
|
|
828
|
-
? entryStateFromCall(paramNames, node.args || [], state)
|
|
1022
|
+
? entryStateFromCall(paramNames, node.args || [], state, (a) => exprTaint(a, state, callContext))
|
|
829
1023
|
: new Set();
|
|
830
1024
|
let sum = callContext._summaryCache.get(qid, entry);
|
|
831
1025
|
// FR-SEM-2: context-sensitive lazy compute at the plain-call site,
|
|
@@ -882,21 +1076,56 @@ function step(node, stateIn, callContext) {
|
|
|
882
1076
|
});
|
|
883
1077
|
}
|
|
884
1078
|
}
|
|
885
|
-
// R4 (PRD §5):
|
|
886
|
-
//
|
|
887
|
-
//
|
|
888
|
-
// by the receiver prefix.
|
|
889
|
-
// access-path lattice — this
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
1079
|
+
// R4 (PRD §5) + T3.3: collection-element taint. A mutating collection
|
|
1080
|
+
// method called with a tainted argument taints the receiver; an index or
|
|
1081
|
+
// key read (`a[0]` → access path "a.0", `m.get(k)` → receiver taint via
|
|
1082
|
+
// _calleeReceiverTainted) is then covered by the receiver prefix.
|
|
1083
|
+
// Object-property taint already flows via the access-path lattice — this
|
|
1084
|
+
// closes the container case.
|
|
1085
|
+
//
|
|
1086
|
+
// R4 covered JS arrays only, in both of its dimensions, and T3.3 widens
|
|
1087
|
+
// each:
|
|
1088
|
+
// - METHODS: keyed collections mutate through `set`/`add`, and the
|
|
1089
|
+
// non-JS containers through `append`/`extend`/`insert`/`update`/
|
|
1090
|
+
// `addAll`/`putAll`/`put`. These are writes exactly as `push` is.
|
|
1091
|
+
// - CALLEE SHAPE: the `callee.kind === 'member'` test only ever matched
|
|
1092
|
+
// `parser-js.js` (Babel), the one frontend emitting a structured
|
|
1093
|
+
// callee. The seven hand-rolled parsers emit a flat dot-joined STRING
|
|
1094
|
+
// ("items.append"), so Python/Ruby/PHP/Go/Java/C#/Kotlin containers
|
|
1095
|
+
// never matched at all — measured: python's `items.append(tainted)`
|
|
1096
|
+
// produced no IR-TAINT finding, and only a PY-SAST pattern rule
|
|
1097
|
+
// caught the sink, which masked the taint-layer miss entirely. Same
|
|
1098
|
+
// frontend duality `_calleeReceiverTainted` documents and handles.
|
|
1099
|
+
//
|
|
1100
|
+
// `add`/`put` are deliberately NOT extended to bare-name calls: the
|
|
1101
|
+
// receiver is what gets tainted, so a call with no receiver has nothing
|
|
1102
|
+
// to taint and is skipped by both branches below.
|
|
1103
|
+
if (Array.isArray(argTaints) && argTaints.some(Boolean)) {
|
|
1104
|
+
// `__setitem__` is not a method anyone writes: `parser-py.js` lowers a
|
|
1105
|
+
// subscript assignment (`bag['k'] = v`, `arr[i] = v`) to a CALL node
|
|
1106
|
+
// with that flat callee rather than to an assign node with a member
|
|
1107
|
+
// target, so it reaches this rule instead of the assign path. The
|
|
1108
|
+
// matching read lowers to `bag.[]`, already covered by the tainted
|
|
1109
|
+
// receiver prefix.
|
|
1110
|
+
const _MUTATORS = /^(?:push|unshift|splice|fill|copyWithin|set|add|append|extend|insert|update|addAll|putAll|put|__setitem__)$/;
|
|
893
1111
|
// Mutate the state Set IN PLACE (the binding is const; the call case
|
|
894
|
-
// returns this same Set ref). Avoids touching the unrelated
|
|
895
|
-
// paths in this case, keeping the blast radius to
|
|
896
|
-
|
|
897
|
-
|
|
1112
|
+
// returns this same Set ref). Avoids touching the unrelated
|
|
1113
|
+
// mutated-param paths in this case, keeping the blast radius to
|
|
1114
|
+
// collection-element taint only.
|
|
1115
|
+
let _recv = null;
|
|
1116
|
+
if (node.callee && node.callee.kind === 'member' && typeof node.callee.prop === 'string'
|
|
1117
|
+
&& _MUTATORS.test(node.callee.prop)) {
|
|
1118
|
+
_recv = accessPathOf(node.callee.object);
|
|
1119
|
+
} else if (typeof _plainCallCalleeName === 'string') {
|
|
1120
|
+
const _dot = _plainCallCalleeName.lastIndexOf('.');
|
|
1121
|
+
if (_dot > 0 && _MUTATORS.test(_plainCallCalleeName.slice(_dot + 1))) {
|
|
1122
|
+
_recv = _plainCallCalleeName.slice(0, _dot);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
if (_recv) state.add(_recv);
|
|
898
1126
|
}
|
|
899
|
-
findings.push(..._sinkFindingsForCall(node.callee, node.args, cat, argTaints, state, callContext, node.line).findings);
|
|
1127
|
+
findings.push(..._sinkFindingsForCall(node.callee, node.args, cat, argTaints, state, callContext, node.line, node.kwargs).findings);
|
|
1128
|
+
findings.push(..._nestedSinkFindings(node.args, state, callContext, node.line));
|
|
900
1129
|
// 2. P1.3 — higher-order taint flow. When the call is `arr.map(fn)` or
|
|
901
1130
|
// `promise.then(fn)` and the receiver is tainted, propagate taint
|
|
902
1131
|
// into the callback's first parameter. v1: we propagate AT THE
|
|
@@ -960,6 +1189,25 @@ function step(node, stateIn, callContext) {
|
|
|
960
1189
|
}
|
|
961
1190
|
|
|
962
1191
|
case 'return': {
|
|
1192
|
+
// Taint-engine PRD P1: `return sink(x)` was invisible — this only ever
|
|
1193
|
+
// asked whether the returned value is TAINTED (for interprocedural
|
|
1194
|
+
// callers, below), never whether the call expression itself is a sink.
|
|
1195
|
+
// JS is accidentally immune (Babel emits a redundant standalone 'call'
|
|
1196
|
+
// node for every CallExpression, including ones nested in a return
|
|
1197
|
+
// argument, so case 'call' above already caught it there). Every
|
|
1198
|
+
// hand-rolled parser does not do that, so `return
|
|
1199
|
+
// File.ReadAllText(path)` — idiomatic ASP.NET Core — was structurally
|
|
1200
|
+
// blind. Mirrors case 'call''s sink-check exactly; deliberately does
|
|
1201
|
+
// NOT mirror its summary-cache/mutation/higher-order machinery, which
|
|
1202
|
+
// is about a callee's OWN internal findings and mutated params — an
|
|
1203
|
+
// unrelated concern from whether this return statement's own call
|
|
1204
|
+
// expression is directly a sink.
|
|
1205
|
+
if (node.value && node.value.kind === 'call') {
|
|
1206
|
+
const { cat, argTaints } = _matchCallCatalog(node.value.callee, node.value.args, state, callContext);
|
|
1207
|
+
findings.push(..._sinkFindingsForCall(
|
|
1208
|
+
node.value.callee, node.value.args, cat, argTaints, state, callContext, node.line, node.value.kwargs).findings);
|
|
1209
|
+
findings.push(..._nestedSinkFindings(node.value.args, state, callContext, node.line));
|
|
1210
|
+
}
|
|
963
1211
|
if (exprTaint(node.value, state, callContext)) {
|
|
964
1212
|
callContext._returnTainted = true;
|
|
965
1213
|
}
|
|
@@ -214,20 +214,45 @@ function _summaryEq(a, b) {
|
|
|
214
214
|
// Build the entry-taint-state for a callee from a call site:
|
|
215
215
|
// given the callee's param names + the caller's tainted-var set + the
|
|
216
216
|
// call args, return a Set of param names that are tainted at entry.
|
|
217
|
-
|
|
217
|
+
//
|
|
218
|
+
// `isArgTaintedExpr` (optional) — Taint-recall PRD (80%): the ident/member
|
|
219
|
+
// checks above only recognize an arg that is (or reads off) an ALREADY
|
|
220
|
+
// state-tracked local variable. A source used directly INLINE as a call
|
|
221
|
+
// argument — `helper(request.getParameter("id"))`, never first assigned to
|
|
222
|
+
// a local — has no entry in `callerTaintedVars` at all (that set tracks
|
|
223
|
+
// variable PROVENANCE via assignment, not "is this arbitrary expression a
|
|
224
|
+
// source when evaluated"), so it was silently invisible here even though
|
|
225
|
+
// the engine's own exprTaint/exprIsSource would recognize it immediately.
|
|
226
|
+
// Confirmed via a real corpus fixture: `find_user($doc, $_GET['name'])`
|
|
227
|
+
// with no intermediate variable produced zero interprocedural findings.
|
|
228
|
+
// Callers pass a closure over their own exprTaint (this module has no
|
|
229
|
+
// access to matchSource/exprIsSource — engine.js does) rather than this
|
|
230
|
+
// module reaching across the layering boundary. Falls back to the
|
|
231
|
+
// ident/member-only checks above when not provided, so every other caller
|
|
232
|
+
// of this function is unaffected.
|
|
233
|
+
export function entryStateFromCall(paramNames, callArgs, callerTaintedVars, isArgTaintedExpr) {
|
|
218
234
|
const out = new Set();
|
|
219
235
|
if (!Array.isArray(paramNames) || !Array.isArray(callArgs)) return out;
|
|
220
236
|
for (let i = 0; i < paramNames.length && i < callArgs.length; i++) {
|
|
221
237
|
const arg = callArgs[i];
|
|
222
238
|
if (!arg) continue;
|
|
239
|
+
// Each check is independent — arg SHAPE (ident vs. member) selecting a
|
|
240
|
+
// branch must not skip the fallback when that branch's own membership
|
|
241
|
+
// check comes back false. An earlier version used if/else-if keyed on
|
|
242
|
+
// shape alone, so `helper($_GET['name'])` (a member arg whose base
|
|
243
|
+
// "$_GET" is never itself in callerTaintedVars — that set tracks
|
|
244
|
+
// ASSIGNED locals, not raw globals) took the member branch, found
|
|
245
|
+
// nothing, and — being an else-if — never reached isArgTaintedExpr at
|
|
246
|
+
// all. Confirmed via a real corpus fixture.
|
|
247
|
+
let tainted = false;
|
|
223
248
|
if (arg.kind === 'ident' && callerTaintedVars.has(arg.name)) {
|
|
224
|
-
|
|
249
|
+
tainted = true;
|
|
225
250
|
} else if (arg.kind === 'member' && arg.object?.kind === 'ident') {
|
|
226
251
|
const base = arg.object.name;
|
|
227
|
-
if (callerTaintedVars.has(base) || callerTaintedVars.has(`${base}.${arg.prop}`))
|
|
228
|
-
out.add(paramNames[i]);
|
|
229
|
-
}
|
|
252
|
+
if (callerTaintedVars.has(base) || callerTaintedVars.has(`${base}.${arg.prop}`)) tainted = true;
|
|
230
253
|
}
|
|
254
|
+
if (!tainted && typeof isArgTaintedExpr === 'function' && isArgTaintedExpr(arg)) tainted = true;
|
|
255
|
+
if (tainted) out.add(paramNames[i]);
|
|
231
256
|
}
|
|
232
257
|
return out;
|
|
233
258
|
}
|