@clear-capabilities/agentic-security-scanner 0.137.1 → 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 +204 -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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|