@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +219 -0
  2. package/dist/113.index.js +2 -2
  3. package/dist/178.index.js +1 -1
  4. package/dist/384.index.js +1 -1
  5. package/dist/435.index.js +29 -1
  6. package/dist/526.index.js +2 -2
  7. package/dist/637.index.js +1 -1
  8. package/dist/agentic-security.mjs +14 -14
  9. package/dist/agentic-security.mjs.sha256 +1 -1
  10. package/package.json +10 -6
  11. package/src/dataflow/CLAUDE.md +30 -0
  12. package/src/dataflow/catalog.js +512 -14
  13. package/src/dataflow/engine.js +275 -27
  14. package/src/dataflow/summaries.js +30 -5
  15. package/src/engine.js +512 -120
  16. package/src/ir/CLAUDE.md +20 -5
  17. package/src/ir/balanced-call.js +11 -1
  18. package/src/ir/callgraph.js +34 -0
  19. package/src/ir/parser-cs.js +55 -6
  20. package/src/ir/parser-go.js +106 -2
  21. package/src/ir/parser-java.js +111 -10
  22. package/src/ir/parser-js.js +40 -0
  23. package/src/ir/parser-kt.js +194 -10
  24. package/src/ir/parser-php.js +108 -6
  25. package/src/ir/parser-py.helper.py +199 -10
  26. package/src/ir/parser-rb.js +405 -31
  27. package/src/mcp/tools.js +29 -1
  28. package/src/posture/accuracy-scorecard.js +103 -0
  29. package/src/runScan.js +5 -2
  30. package/src/sast/CLAUDE.md +1 -1
  31. package/src/sast/_auth-signals.js +141 -0
  32. package/src/sast/_comment-strip.js +80 -13
  33. package/src/sast/codegen-sink.js +110 -0
  34. package/src/sast/convention-deviation.js +235 -0
  35. package/src/sast/fastapi-hardening.js +45 -6
  36. package/src/sast/file-upload.js +29 -1
  37. package/src/sast/ownership-authz.js +245 -0
  38. package/src/sast/php.js +12 -2
  39. package/src/sast/rate-limit.js +2 -0
  40. package/src/sast/rbac-consistency.js +1 -1
  41. package/src/sast/redirect-toctou.js +167 -0
  42. package/src/sast/resource-exhaustion.js +217 -0
  43. package/src/sast/sibling-guard.js +176 -0
  44. package/src/sast/zip-slip.js +53 -2
@@ -127,12 +127,7 @@ def _lower_expr(node: ast.AST) -> dict[str, Any]:
127
127
  op = type(node.ops[0]).__name__ if node.ops else "Eq"
128
128
  return {"kind": "binary", "op": op, "left": left, "right": right}
129
129
  if isinstance(node, ast.Call):
130
- callee = _flatten_callee(node.func)
131
- args = [_lower_expr(a) for a in (node.args or [])]
132
- # Keyword args lowered as positional — taint analysis treats them similarly.
133
- for kw in (node.keywords or []):
134
- args.append(_lower_expr(kw.value))
135
- return {"kind": "call", "callee": callee, "args": args}
130
+ return _lower_call_chain(node)
136
131
  if isinstance(node, ast.List) or isinstance(node, ast.Tuple) or isinstance(node, ast.Set):
137
132
  return {"kind": "array", "elements": [_lower_expr(e) for e in (node.elts or [])]}
138
133
  if isinstance(node, ast.Dict):
@@ -186,6 +181,79 @@ def _lower_expr(node: ast.AST) -> dict[str, Any]:
186
181
  return {"kind": "unknown"}
187
182
 
188
183
 
184
+ def _lower_call_chain(node: ast.Call) -> dict[str, Any]:
185
+ """Lowers a (possibly chained) call expression, e.g. `open(x).read()` or
186
+ `a.b(x).c(y).d(z)`, WITHOUT losing an inner call's own arguments.
187
+
188
+ _flatten_callee's "mixed shape" fallback (`func()[0].attr` -> just the
189
+ last segment name) previously ALSO caught this shape — `.read` on a
190
+ Call value is not an ast.Attribute-of-ast.Name chain, so it fell to
191
+ that same fallback, which returns ONLY the bare terminal name and
192
+ discards the entire inner Call node, including its arguments. Opening
193
+ a path built from a tainted suffix and immediately reading it in one
194
+ chained expression meant the tainted portion vanished completely — not
195
+ just misattributed to the wrong arg index, but never represented in
196
+ the IR at all. Confirmed via a real corpus fixture
197
+ (CVE-2019-10097-python-path-traversal — see its pre/srv.py fixture for
198
+ the exact disclosed shape this fixes).
199
+
200
+ Walks the chain from the OUTERMOST call inward, collecting each level's
201
+ (name segment, own args) — mirrors every hand-rolled parser's
202
+ `_followChain` convention in this codebase: the reconstructed callee is
203
+ the dot-joined names in SOURCE order (inner-to-outer, since we walked
204
+ outer-to-inner), and the args are kept in OUTERMOST-FIRST order (a
205
+ trailing no-arg call like `.read()` contributes nothing, so the
206
+ innermost call's real argument — the tainted path — survives as
207
+ `args[0]`, exactly where `argIndex: 0` catalog entries expect it).
208
+ """
209
+ segments: list[tuple[Any, list[dict[str, Any]]]] = []
210
+ kwargs: dict[str, Any] = {}
211
+ cur: Any = node
212
+ while isinstance(cur, ast.Call):
213
+ args = [_lower_expr(a) for a in (cur.args or [])]
214
+ for kw in (cur.keywords or []):
215
+ args.append(_lower_expr(kw.value))
216
+ # Keyword NAMES were previously dropped: `subprocess.run(cmd,
217
+ # shell=True)` lowered to args=[cmd, True] with no way to tell
218
+ # which argument was `shell`. That made it impossible for a sink
219
+ # to distinguish the shell-interpreted form from the safe
220
+ # argv-array form, so py-subprocess-run fired on both and
221
+ # labelled both "shell=True". Recorded alongside args rather than
222
+ # instead of them, so every existing argIndex consumer is
223
+ # unaffected. (PRD T3.1 prerequisite.)
224
+ if kw.arg:
225
+ kwargs.setdefault(kw.arg, _lower_expr(kw.value))
226
+ else:
227
+ # `**opts` — the keyword set is NOT enumerable here. Recorded
228
+ # under a reserved key so a requireKeyword gate can tell
229
+ # "this keyword is absent" from "we cannot see the keywords",
230
+ # and stay recall-preserving in the second case.
231
+ kwargs.setdefault("**", {"kind": "unknown"})
232
+ func = cur.func
233
+ if isinstance(func, ast.Attribute):
234
+ segments.append((func.attr, args))
235
+ cur = func.value
236
+ elif isinstance(func, ast.Name):
237
+ segments.append((func.id, args))
238
+ cur = None
239
+ else:
240
+ # e.g. a subscript-returned callable (`handlers[key](x)`) —
241
+ # no further name segment to extract; stop walking but keep
242
+ # this level's own args.
243
+ segments.append((None, args))
244
+ cur = None
245
+ prefix = _flatten_callee(cur) if cur is not None else None
246
+ names = ([prefix] if prefix else []) + [s[0] for s in reversed(segments) if s[0]]
247
+ callee = ".".join(names) if names else None
248
+ all_args: list[dict[str, Any]] = []
249
+ for _, args in segments:
250
+ all_args = all_args + args
251
+ call: dict[str, Any] = {"kind": "call", "callee": callee, "args": all_args}
252
+ if kwargs:
253
+ call["kwargs"] = kwargs
254
+ return call
255
+
256
+
189
257
  def _flatten_callee(node: ast.AST) -> Any:
190
258
  """Return a dot-joined name like 'os.path.join' for a callee, or a
191
259
  structured member-access tree for harder shapes. The dataflow engine
@@ -329,13 +397,22 @@ class CfgBuilder:
329
397
  # Bare expression — useful when it's a call (decorator pattern,
330
398
  # dispatch shape). For everything else, noop.
331
399
  if isinstance(stmt.value, ast.Call):
332
- cur = self._add({
400
+ _node = {
333
401
  "kind": "call",
334
402
  "callee": _flatten_callee(stmt.value.func),
335
403
  "args": [_lower_expr(a) for a in (stmt.value.args or [])]
336
404
  + [_lower_expr(kw.value) for kw in (stmt.value.keywords or [])],
337
405
  "line": line,
338
- })
406
+ }
407
+ # Keyword names, so a sink can tell `subprocess.run(cmd,
408
+ # shell=True)` from the safe argv-array form. See
409
+ # _lower_call_chain for the full rationale.
410
+ _kw = {kw.arg: _lower_expr(kw.value) for kw in (stmt.value.keywords or []) if kw.arg}
411
+ if any(kw.arg is None for kw in (stmt.value.keywords or [])):
412
+ _kw["**"] = {"kind": "unknown"} # see _lower_call_chain
413
+ if _kw:
414
+ _node["kwargs"] = _kw
415
+ cur = self._add(_node)
339
416
  elif isinstance(stmt.value, ast.NamedExpr):
340
417
  cur = self._add({
341
418
  "kind": "assign",
@@ -347,6 +424,31 @@ class CfgBuilder:
347
424
  cur = self._add({"kind": "noop", "line": line})
348
425
  self._link(prev, cur)
349
426
  return cur
427
+ if isinstance(stmt, ast.Assign) and len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Subscript):
428
+ # Taint-recall PRD (80%): `response["X-Trace"] = tainted` / a
429
+ # subscript-assignment target. `_assign_target` has no branch for
430
+ # ast.Subscript, so this previously fell through to `target: None`
431
+ # — an untracked, effectively no-op assign; the write itself was
432
+ # invisible to every sink check (matchMemberWriteSink needs a
433
+ # dotted `target` string; the RHS-is-a-call check doesn't apply
434
+ # since the RHS here is rarely a call). Lowered instead as a
435
+ # synthetic `<receiver>.__setitem__(key, value)` call so it flows
436
+ # through the SAME argument-based sink-matching machinery every
437
+ # other call already uses — no new engine mechanism, argIndex 1
438
+ # for the value. `d[k] = v` for an ordinary dict still produces
439
+ # this same shape; it's harmless (no catalog entry matches
440
+ # '__setitem__' without a receiver constraint, so it resolves as
441
+ # an ordinary unrecognized call, same effective no-op as before).
442
+ receiver = _flatten_callee(stmt.targets[0].value)
443
+ key_expr = _lower_expr(stmt.targets[0].slice)
444
+ cur = self._add({
445
+ "kind": "call",
446
+ "callee": f"{receiver}.__setitem__" if receiver else "__setitem__",
447
+ "args": [key_expr, _lower_expr(stmt.value)],
448
+ "line": line,
449
+ })
450
+ self._link(prev, cur)
451
+ return cur
350
452
  if isinstance(stmt, (ast.Assign, ast.AugAssign, ast.AnnAssign)):
351
453
  # AugAssign: x += y → assign x = x + y
352
454
  # AnnAssign: x: int = y → assign x = y (or noop if no value)
@@ -568,7 +670,7 @@ def _extract_functions(tree: ast.Module, file: str) -> list[dict[str, Any]]:
568
670
  line = node.lineno or 0
569
671
  builder = CfgBuilder(node.name)
570
672
  builder.lower(node.body)
571
- fns.append({
673
+ fn_rec = {
572
674
  "qid": _qid(file, node.name, line),
573
675
  "name": node.name,
574
676
  "line": line,
@@ -579,10 +681,97 @@ def _extract_functions(tree: ast.Module, file: str) -> list[dict[str, Any]]:
579
681
  "exit": builder.exit,
580
682
  "nodes": builder.nodes,
581
683
  },
582
- })
684
+ }
685
+ pa = _param_annotations(node, params)
686
+ if pa:
687
+ fn_rec["paramAnnotations"] = pa
688
+ fns.append(fn_rec)
583
689
  return fns
584
690
 
585
691
 
692
+ def _decorator_names(node: Any) -> list[str]:
693
+ """Every decorator on a function, as both its dotted form and its last
694
+ segment.
695
+
696
+ Both are emitted because the receiver of a decorator is a local naming
697
+ choice — `@mcp.tool()`, `@server.tool()` and `@app.tool()` are the same
698
+ framework concept — while the last segment alone (`tool`) is too generic
699
+ to key a taint source on by itself. Emitting both lets the catalog choose
700
+ how specific it wants to be, and costs nothing when it matches neither.
701
+ """
702
+ out: list[str] = []
703
+ for d in getattr(node, "decorator_list", []) or []:
704
+ expr = d.func if isinstance(d, ast.Call) else d
705
+ parts: list[str] = []
706
+ while isinstance(expr, ast.Attribute):
707
+ parts.append(expr.attr)
708
+ expr = expr.value
709
+ if isinstance(expr, ast.Name):
710
+ parts.append(expr.id)
711
+ if not parts:
712
+ continue
713
+ parts.reverse()
714
+ dotted = ".".join(parts)
715
+ out.append(dotted)
716
+ if len(parts) > 1:
717
+ out.append(parts[-1])
718
+ return out
719
+
720
+
721
+ def _param_annotations(node: Any, params: list[str]) -> list[dict[str, Any]]:
722
+ """Entry-point markers on a function's parameters, in the IR's shared
723
+ `{index, name, decorator}` side-channel shape (see ir/CLAUDE.md).
724
+
725
+ Python expresses "this parameter is attacker-controlled" two ways, and both
726
+ are captured here:
727
+
728
+ 1. A FUNCTION-level decorator that makes the whole function an entry
729
+ point — `@mcp.tool()`, `@app.route(...)`, `@celery.task`. Every
730
+ parameter inherits it.
731
+ 2. A PER-PARAMETER default marker — FastAPI's
732
+ `q: str = Query(...)` / `Body(...)` / `Form(...)` / `Header(...)`.
733
+
734
+ Nothing is decided here about which of these is untrusted. The IR emits the
735
+ fact that the decorator exists; dataflow/catalog.js decides whether it
736
+ names a source, exactly as it already does for Java/C#/NestJS. That
737
+ separation is why emitting broadly is safe: an unrecognised decorator
738
+ (`@staticmethod`, `@lru_cache`) resolves to no catalog entry and therefore
739
+ to no taint.
740
+ """
741
+ out: list[dict[str, Any]] = []
742
+
743
+ for dec in _decorator_names(node):
744
+ for idx, pname in enumerate(params):
745
+ if pname in ("self", "cls"):
746
+ continue
747
+ out.append({"index": idx, "name": pname, "decorator": dec})
748
+
749
+ # Per-parameter markers: the default value is a call, e.g. `= Query(...)`.
750
+ args = node.args
751
+ positional = list(getattr(args, "posonlyargs", [])) + list(args.args)
752
+ defaults = list(args.defaults)
753
+ # `defaults` right-aligns with the positional parameters.
754
+ offset = len(positional) - len(defaults)
755
+ pairs = [(positional[offset + i], d) for i, d in enumerate(defaults) if 0 <= offset + i < len(positional)]
756
+ pairs += [(a, d) for a, d in zip(args.kwonlyargs, args.kw_defaults) if d is not None]
757
+ for arg, default in pairs:
758
+ expr = default.func if isinstance(default, ast.Call) else default
759
+ marker = None
760
+ if isinstance(expr, ast.Name):
761
+ marker = expr.id
762
+ elif isinstance(expr, ast.Attribute):
763
+ marker = expr.attr
764
+ if not marker:
765
+ continue
766
+ try:
767
+ idx = params.index(arg.arg)
768
+ except ValueError:
769
+ continue
770
+ out.append({"index": idx, "name": arg.arg, "decorator": marker})
771
+
772
+ return out
773
+
774
+
586
775
  # ─── Driver ──────────────────────────────────────────────────────────────────
587
776
 
588
777