attackmap 0.6.4__py3-none-any.whl
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.
- attackmap/__init__.py +2 -0
- attackmap/analyzer.py +449 -0
- attackmap/analyzer_contracts.py +194 -0
- attackmap/analyzers.py +1003 -0
- attackmap/anomalies.py +612 -0
- attackmap/asset_model.py +245 -0
- attackmap/attack_taxonomy.py +133 -0
- attackmap/auth_protocol.py +702 -0
- attackmap/authz.py +1695 -0
- attackmap/bench.py +467 -0
- attackmap/cli.py +1474 -0
- attackmap/config_scanner.py +389 -0
- attackmap/context_pack.py +82 -0
- attackmap/contracts.py +201 -0
- attackmap/control_model.py +280 -0
- attackmap/crossrepo.py +356 -0
- attackmap/crypto.py +240 -0
- attackmap/cve.py +717 -0
- attackmap/defensive_review.py +647 -0
- attackmap/detection_opportunities.py +1221 -0
- attackmap/detections/__init__.py +491 -0
- attackmap/detections/bola.yml +57 -0
- attackmap/detections/cloud.yml +609 -0
- attackmap/detections/injection.yml +167 -0
- attackmap/detections/secrets.yml +48 -0
- attackmap/detections/sigma.py +511 -0
- attackmap/detections/ssrf.yml +98 -0
- attackmap/detections/weakness.yml +62 -0
- attackmap/detections/webhook.yml +45 -0
- attackmap/detections/workflow.yml +121 -0
- attackmap/diagrams.py +237 -0
- attackmap/diff.py +301 -0
- attackmap/exploitability.py +493 -0
- attackmap/fingerprint.py +91 -0
- attackmap/fleet.py +370 -0
- attackmap/gitref.py +129 -0
- attackmap/graph.py +26 -0
- attackmap/handler_auth.py +639 -0
- attackmap/handler_span.py +62 -0
- attackmap/hunt_harness.py +505 -0
- attackmap/insights.py +701 -0
- attackmap/llm_review.py +846 -0
- attackmap/lockfiles.py +521 -0
- attackmap/mass_assignment.py +543 -0
- attackmap/md.py +175 -0
- attackmap/merge.py +333 -0
- attackmap/models.py +1284 -0
- attackmap/plugins_lock.py +114 -0
- attackmap/popular_packages.py +193 -0
- attackmap/progress.py +258 -0
- attackmap/recon_models.py +31 -0
- attackmap/recon_to_analysis.py +149 -0
- attackmap/redact.py +220 -0
- attackmap/report.py +424 -0
- attackmap/review_eval.py +335 -0
- attackmap/review_json.py +308 -0
- attackmap/review_prompts.py +796 -0
- attackmap/route_auth_fusion.py +474 -0
- attackmap/safe_fs.py +285 -0
- attackmap/sarif.py +373 -0
- attackmap/sbom.py +418 -0
- attackmap/scanner.py +1379 -0
- attackmap/sdk/__init__.py +278 -0
- attackmap/sdk/contracts.py +17 -0
- attackmap/sdk/fs.py +156 -0
- attackmap/sdk/models.py +59 -0
- attackmap/secrets_history.py +161 -0
- attackmap/security_overlay.py +71 -0
- attackmap/srcpaths.py +301 -0
- attackmap/suggest.py +394 -0
- attackmap/supply_chain.py +688 -0
- attackmap/suppress.py +740 -0
- attackmap/taint.py +1540 -0
- attackmap/taint_flow.py +1419 -0
- attackmap/taint_sinks.py +431 -0
- attackmap/taint_sinks.yaml +308 -0
- attackmap/taint_sources.py +396 -0
- attackmap/taxonomy.py +465 -0
- attackmap/threat_model.py +3441 -0
- attackmap/topology.py +316 -0
- attackmap/triage.py +151 -0
- attackmap/weaknesses.py +365 -0
- attackmap/webhardening.py +479 -0
- attackmap/webhook_verification.py +1067 -0
- attackmap/workflow_scanner.py +964 -0
- attackmap-0.6.4.dist-info/METADATA +226 -0
- attackmap-0.6.4.dist-info/RECORD +91 -0
- attackmap-0.6.4.dist-info/WHEEL +5 -0
- attackmap-0.6.4.dist-info/entry_points.txt +2 -0
- attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
- attackmap-0.6.4.dist-info/top_level.txt +1 -0
attackmap/taint.py
ADDED
|
@@ -0,0 +1,1540 @@
|
|
|
1
|
+
"""Lite data-flow / taint analysis via import-graph walking (#45).
|
|
2
|
+
|
|
3
|
+
Attack paths today infer flow from file-locality (route + database in the
|
|
4
|
+
same file → likely reachable). That heuristic misses the common case
|
|
5
|
+
where the handler in ``routes/orders.py`` calls a service in
|
|
6
|
+
``services/orders.py`` that touches the database in ``db/orders.py``.
|
|
7
|
+
|
|
8
|
+
This module builds a per-file import graph for Python and JS/TS, walks
|
|
9
|
+
up to N hops from each route-handler file, and emits a ``TaintChain``
|
|
10
|
+
for each reachable sink.
|
|
11
|
+
|
|
12
|
+
Sinks it seeds:
|
|
13
|
+
- ``sql_execute`` — ``.execute(`` / ``.query(`` on a cursor,
|
|
14
|
+
session, or connection-shaped receiver
|
|
15
|
+
- ``subprocess_shell`` — ``subprocess.run/call/Popen(..., shell=True)``
|
|
16
|
+
or Node ``child_process.exec(...)``
|
|
17
|
+
- ``eval`` — Python or JS ``eval(...)``
|
|
18
|
+
- ``exec`` — Python ``exec(...)``
|
|
19
|
+
- ``dynamic_open`` — ``open(...)`` whose argument looks derived
|
|
20
|
+
from a request-shaped identifier
|
|
21
|
+
|
|
22
|
+
Import != call. Dynamic dispatch, DI containers, and duck-typed
|
|
23
|
+
callables aren't followed. Treat findings as evidence, not proof — the
|
|
24
|
+
``confidence`` field encodes that, and downstream chain builders should
|
|
25
|
+
raise confidence when a matching taint chain exists rather than replace
|
|
26
|
+
their own signals with taint alone.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import ast
|
|
32
|
+
import json
|
|
33
|
+
import re
|
|
34
|
+
import warnings
|
|
35
|
+
from collections import deque
|
|
36
|
+
from dataclasses import dataclass, replace
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
from .safe_fs import is_contained, is_oversized, read_repo_text, walk_repo
|
|
40
|
+
from .models import Route, ScanResult, TaintChain, TaintFlowStep
|
|
41
|
+
from .srcpaths import JS_TS_SUFFIXES, is_infra_route, is_test_file, is_vendored_file, line_number, split_lines
|
|
42
|
+
from .taint_flow import REST, FileFlow, SinkFlow
|
|
43
|
+
from .taint_sinks import SINKS, all_sinks, call_receiver
|
|
44
|
+
from .taint_sources import GO, JS, PHP, PY, lang_for_suffix
|
|
45
|
+
|
|
46
|
+
_MAX_HOPS = 2
|
|
47
|
+
# Bound the sweep so a deeply-linked monorepo can't blow up the scan.
|
|
48
|
+
_MAX_FILES_VISITED_PER_ROUTE = 40
|
|
49
|
+
|
|
50
|
+
# Recall mode (#148a). Aggressive discovery is only useful *behind* a verifier —
|
|
51
|
+
# so recall widens the walk, tags the extra reach speculative, and leaves
|
|
52
|
+
# adjudication to `--verify`/`--triage`. The default preset reproduces the
|
|
53
|
+
# conservative pass byte-for-byte; `recall_config()` is the aggressive preset.
|
|
54
|
+
_RECALL_MAX_HOPS = 4
|
|
55
|
+
_RECALL_MAX_FILES_VISITED_PER_ROUTE = 80
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class RecallConfig:
|
|
60
|
+
"""Knobs controlling how aggressively the taint walk discovers chains.
|
|
61
|
+
|
|
62
|
+
Defaults == today's conservative pass. In the aggressive preset the extra
|
|
63
|
+
reach (deeper hops, gates relaxed) is what makes a chain *speculative*: any
|
|
64
|
+
chain past ``_MAX_HOPS`` or surfaced only because ``include_static_args``
|
|
65
|
+
lifted the static-literal suppression is tagged so and confidence-docked.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
max_hops: int = _MAX_HOPS
|
|
69
|
+
max_files_visited: int = _MAX_FILES_VISITED_PER_ROUTE
|
|
70
|
+
# Surface sinks whose argument looks like a static literal / local-file read
|
|
71
|
+
# — the default pass suppresses these (#88 follow-up); recall keeps them as
|
|
72
|
+
# speculative leads (provenance is a heuristic, not proof).
|
|
73
|
+
include_static_args: bool = False
|
|
74
|
+
# Capability-reach enumeration (#148b): surface *every* reach to a powerful
|
|
75
|
+
# capability (network/template/fs/redirect) even with no known-bad pattern —
|
|
76
|
+
# the bare call, not just the request-token-gated form. Recall-only,
|
|
77
|
+
# speculative; the point is to list reaches a signature pass misses.
|
|
78
|
+
capability_reach: bool = False
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def aggressive(self) -> bool:
|
|
82
|
+
"""True when any knob is widened past the conservative default."""
|
|
83
|
+
return (
|
|
84
|
+
self.max_hops > _MAX_HOPS
|
|
85
|
+
or self.max_files_visited > _MAX_FILES_VISITED_PER_ROUTE
|
|
86
|
+
or self.include_static_args
|
|
87
|
+
or self.capability_reach
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
DEFAULT_RECALL = RecallConfig()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def recall_config() -> RecallConfig:
|
|
95
|
+
"""The aggressive `--recall` preset."""
|
|
96
|
+
return RecallConfig(
|
|
97
|
+
max_hops=_RECALL_MAX_HOPS,
|
|
98
|
+
max_files_visited=_RECALL_MAX_FILES_VISITED_PER_ROUTE,
|
|
99
|
+
include_static_args=True,
|
|
100
|
+
capability_reach=True,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
_PY_SUFFIXES = {".py"}
|
|
104
|
+
_JS_TS_SUFFIXES = JS_TS_SUFFIXES
|
|
105
|
+
_GO_SUFFIXES = {".go"}
|
|
106
|
+
_PHP_SUFFIXES = {".php"}
|
|
107
|
+
# Java (#239): indexed for same-file (0-hop) flows. Imports are not resolved,
|
|
108
|
+
# so a Java sink is only reached from a route in the same file.
|
|
109
|
+
_JAVA_SUFFIXES = {".java"}
|
|
110
|
+
# C# and Ruby (#240): indexed for same-file sinks only, like Java.
|
|
111
|
+
_SAME_FILE_ONLY_SUFFIXES = _JAVA_SUFFIXES | {".cs", ".rb"}
|
|
112
|
+
_SUPPORTED_SUFFIXES = _PY_SUFFIXES | _JS_TS_SUFFIXES | _GO_SUFFIXES | _PHP_SUFFIXES | _SAME_FILE_ONLY_SUFFIXES
|
|
113
|
+
|
|
114
|
+
# --- Import extraction -----------------------------------------------------
|
|
115
|
+
|
|
116
|
+
# Go imports: single `import "path"` and block `import ( "a"\n alias "b" )`.
|
|
117
|
+
# We only resolve INTRA-repo packages (those under the module path from go.mod).
|
|
118
|
+
_GO_IMPORT_RE = re.compile(r'\bimport\s+(?:"(?P<single>[^"]+)"|\((?P<block>[^)]*)\))', re.DOTALL)
|
|
119
|
+
_GO_IMPORT_PATH_RE = re.compile(r'"(?P<path>[^"]+)"')
|
|
120
|
+
_GO_MODULE_RE = re.compile(r"^\s*module\s+(?P<mod>\S+)", re.MULTILINE)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _parse_go_imports(content: str) -> list[str]:
|
|
124
|
+
"""Return the import paths in a Go file (single and block form)."""
|
|
125
|
+
paths: list[str] = []
|
|
126
|
+
for match in _GO_IMPORT_RE.finditer(content):
|
|
127
|
+
if match.group("single"):
|
|
128
|
+
paths.append(match.group("single"))
|
|
129
|
+
elif match.group("block") is not None:
|
|
130
|
+
paths.extend(m.group("path") for m in _GO_IMPORT_PATH_RE.finditer(match.group("block")))
|
|
131
|
+
return paths
|
|
132
|
+
|
|
133
|
+
# Only relative or bare-word imports we can resolve within the repo.
|
|
134
|
+
_JS_IMPORT_RE = re.compile(
|
|
135
|
+
r"""(?:^|\s|;) # boundary
|
|
136
|
+
(?:import\s+(?:[^"';]+?\s+from\s+)?|require\s*\(\s*)
|
|
137
|
+
["'](?P<path>[./][^"']+)["']
|
|
138
|
+
""",
|
|
139
|
+
re.VERBOSE,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# --- Call-graph awareness (#138) -------------------------------------------
|
|
143
|
+
# The import graph over-links: importing a module links a route to *every*
|
|
144
|
+
# sink in that module even when the imported symbol is never called. To
|
|
145
|
+
# approximate real call-edges we prune an import edge whose bound symbols are
|
|
146
|
+
# never *used* (called or referenced) in the importing file — a dead import no
|
|
147
|
+
# longer fans out to that module's sinks. This is recall-safe: an edge is only
|
|
148
|
+
# dropped when we have explicit binding names AND none appears outside its own
|
|
149
|
+
# import statement. Namespace/star/side-effect/dynamic imports carry no
|
|
150
|
+
# resolvable binding, so their edges are always kept (import-graph fallback).
|
|
151
|
+
|
|
152
|
+
# Import/require statements are stripped before collecting used identifiers so
|
|
153
|
+
# a symbol that appears *only* in its own import statement reads as unused. The
|
|
154
|
+
# patterns cover multi-line forms: parenthesized (`from x import (\n a,\n b\n)`)
|
|
155
|
+
# and backslash-continued (`from x import \` <nl> `a`) Python imports, and the
|
|
156
|
+
# `const { a } = require('…')` CommonJS binding declaration (whose left-hand side
|
|
157
|
+
# must be stripped too, else the binding always looks used).
|
|
158
|
+
_PY_IMPORT_LINE_RE = re.compile(
|
|
159
|
+
r"""^[ \t]*(?:
|
|
160
|
+
from\s+[\w.]+\s+import\s+(?:\*|\([^)]*\)|(?:\\\n|[^\n#])+)
|
|
161
|
+
| import\s+(?:\\\n|[^\n#])+
|
|
162
|
+
)""",
|
|
163
|
+
re.MULTILINE | re.VERBOSE,
|
|
164
|
+
)
|
|
165
|
+
_JS_IMPORT_STMT_RE = re.compile(
|
|
166
|
+
r"""import\s+[^;'"]*?\s+from\s+["'][^"']+["'] # import … from '…'
|
|
167
|
+
| import\s+["'][^"']+["'] # side-effect import '…'
|
|
168
|
+
| (?:(?:const|let|var)\s+(?:\{[^}]*\}|[A-Za-z_$][\w$]*)\s*=\s*)?
|
|
169
|
+
require\s*\(\s*["'][^"']+["']\s*\) # [const … =] require('…')
|
|
170
|
+
""",
|
|
171
|
+
re.VERBOSE,
|
|
172
|
+
)
|
|
173
|
+
_IDENTIFIER_RE = re.compile(r"[A-Za-z_$][\w$]*")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _used_identifiers(content: str, suffix: str) -> set[str]:
|
|
177
|
+
"""Identifiers that appear in ``content`` outside of import statements —
|
|
178
|
+
i.e. names that are actually referenced or called."""
|
|
179
|
+
stripper = _PY_IMPORT_LINE_RE if suffix in _PY_SUFFIXES else _JS_IMPORT_STMT_RE
|
|
180
|
+
body = stripper.sub(" ", content)
|
|
181
|
+
return set(_IDENTIFIER_RE.findall(body))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# --- Sink patterns ---------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
# Request-shaped identifiers. Used to gate sinks that are only dangerous
|
|
187
|
+
# when they consume attacker-controlled input (open, outbound HTTP,
|
|
188
|
+
# template render, Mongo filter). Sinks that are dangerous regardless
|
|
189
|
+
# (eval, exec, deserialization) don't reference this — reachability from
|
|
190
|
+
# a route is enough.
|
|
191
|
+
#
|
|
192
|
+
# Deliberately conservative. The precise signal for "attacker-controlled"
|
|
193
|
+
# is a *member access or subscript on a request container* —
|
|
194
|
+
# `req.query.url`, `request.args['x']`, `body['id']`, `params.slug` — not
|
|
195
|
+
# a bare identifier. Gating on a bare token drowns real findings: the JS
|
|
196
|
+
# Fetch API idiom `fetch(request)` takes a `Request` object literally
|
|
197
|
+
# named `request`, and generic names like `url`/`path`/`target` match
|
|
198
|
+
# nearly every outbound call (observed 600+ false SSRF hits on a real
|
|
199
|
+
# repo before this was tightened). Requiring the trailing `.`/`[` cuts
|
|
200
|
+
# that noise while keeping the genuine `req.query.url` shape.
|
|
201
|
+
#
|
|
202
|
+
# `_TAINTED` = a request container token immediately followed by property
|
|
203
|
+
# access (`.field`) or subscript (`[`). We accept lower recall for far
|
|
204
|
+
# higher precision; true data-flow to a variable's origin is out of scope
|
|
205
|
+
# for this heuristic engine.
|
|
206
|
+
_TAINTED = (
|
|
207
|
+
r"(?:req|request|body|query|params|payload|user_input|user_data|form_data)"
|
|
208
|
+
r"\s*(?:\.\s*\w|\[)"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
_TAINTED_RE = re.compile(_TAINTED)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# Sink signatures live in the declarative registry `taint_sinks.yaml` (#240),
|
|
216
|
+
# loaded and validated by `taint_sinks`; plugins add entries through the
|
|
217
|
+
# `attackmap.taint_sinks` entry-point group. `SINKS` is the core table.
|
|
218
|
+
_SINK_PATTERNS = SINKS
|
|
219
|
+
_LEGACY_LANGS = frozenset({PY, JS, GO, PHP})
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# --- Capability-reach patterns (#148b) -------------------------------------
|
|
223
|
+
# The bare-call forms of the request-token-gated sink kinds. The default pass
|
|
224
|
+
# only flags these when a request-shaped identifier is in the argument (a
|
|
225
|
+
# constant URL / template / path is fine); capability-reach surfaces the reach
|
|
226
|
+
# to the *capability itself* regardless — recall-only, speculative, deduped
|
|
227
|
+
# against any gated hit at the same line, and bounded to what a route reaches.
|
|
228
|
+
# NoSQL is deliberately omitted: a bare `.find(` is overwhelmingly JS array
|
|
229
|
+
# iteration, not a Mongo query, so without the request-token gate it's noise.
|
|
230
|
+
_CAPABILITY_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
|
231
|
+
# network (SSRF surface)
|
|
232
|
+
("ssrf", re.compile(r"\brequests\.(?:get|post|put|delete|patch|head|request)\s*\(")),
|
|
233
|
+
("ssrf", re.compile(r"\bhttpx\.(?:get|post|put|delete|patch|head|request|Client)\s*\(")),
|
|
234
|
+
("ssrf", re.compile(r"\b(?:urlopen|urlretrieve)\s*\(")),
|
|
235
|
+
(
|
|
236
|
+
"ssrf",
|
|
237
|
+
re.compile(
|
|
238
|
+
r"\b(?:axios\s*\.\s*(?:get|post|put|delete|patch|head|request)|fetch|http\.(?:get|request))\s*\("
|
|
239
|
+
),
|
|
240
|
+
),
|
|
241
|
+
# template rendering (SSTI surface)
|
|
242
|
+
("ssti", re.compile(r"\brender_template_string\s*\(")),
|
|
243
|
+
("ssti", re.compile(r"\bTemplate\s*\(")),
|
|
244
|
+
# filesystem open (path-traversal surface)
|
|
245
|
+
("dynamic_open", re.compile(r"(?<![\w.])open\s*\(")),
|
|
246
|
+
# redirect (open-redirect surface)
|
|
247
|
+
("open_redirect", re.compile(r"\bredirect\s*\(")),
|
|
248
|
+
("open_redirect", re.compile(r"\bres\s*\.\s*(?:redirect|location)\s*\(")),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# --- Sanitizer / validator awareness (#137, #239) ---------------------------
|
|
253
|
+
# Sanitizers are bound to the flow (#239): when the enclosing function can be
|
|
254
|
+
# analyzed, a neutralizer counts only if the tainted value passes through it
|
|
255
|
+
# (or a dominating guard checks it) on the way to the sink — see
|
|
256
|
+
# `taint_flow` and the catalog in `taint_sources`. This file-granular table is
|
|
257
|
+
# only the FALLBACK for sinks whose enclosing function can't be determined
|
|
258
|
+
# (module-level code, unparseable files): a sink-appropriate neutralizer
|
|
259
|
+
# anywhere in the file then marks the chain sanitized and downgrades it.
|
|
260
|
+
#
|
|
261
|
+
# Keep patterns HIGH-SIGNAL and sink-appropriate — a vague `validate(` would
|
|
262
|
+
# hide real bugs. `re.escape`/`html.escape` don't neutralize SSTI, and a bare
|
|
263
|
+
# `ipaddress.ip_address(` (often just logging) doesn't neutralize SSRF without
|
|
264
|
+
# an address-class decision, so neither counts (#239).
|
|
265
|
+
_SANITIZER_PATTERNS: dict[str, tuple[tuple[str, re.Pattern[str]], ...]] = {
|
|
266
|
+
"subprocess_shell": (
|
|
267
|
+
("shlex.quote", re.compile(r"\bshlex\.quote\s*\(")),
|
|
268
|
+
("pipes.quote", re.compile(r"\bpipes\.quote\s*\(")),
|
|
269
|
+
("escapeshellarg/escapeshellcmd", re.compile(r"\b(?:escapeshellarg|escapeshellcmd)\s*\(")),
|
|
270
|
+
),
|
|
271
|
+
"sql_execute": (
|
|
272
|
+
("mysqli_real_escape_string", re.compile(r"\bmysqli_real_escape_string\s*\(")),
|
|
273
|
+
("pg_escape_*", re.compile(r"\bpg_escape_(?:string|literal|identifier)\s*\(")),
|
|
274
|
+
),
|
|
275
|
+
"dynamic_open": (
|
|
276
|
+
("werkzeug.secure_filename", re.compile(r"\bsecure_filename\s*\(")),
|
|
277
|
+
("os.path.basename", re.compile(r"\bos\.path\.basename\s*\(")),
|
|
278
|
+
("path allow-list (realpath+startswith)", re.compile(r"\brealpath\s*\(")),
|
|
279
|
+
),
|
|
280
|
+
"ssti": (
|
|
281
|
+
("markupsafe.escape", re.compile(r"\b(?:markupsafe|Markup)\.escape\s*\(|(?<![\w.])escape\s*\(")),
|
|
282
|
+
("bleach.clean", re.compile(r"\bbleach\.clean\s*\(")),
|
|
283
|
+
),
|
|
284
|
+
"open_redirect": (
|
|
285
|
+
("is_safe_url", re.compile(r"\bis_safe_url\s*\(")),
|
|
286
|
+
("url_has_allowed_host_and_scheme", re.compile(r"\burl_has_allowed_host_and_scheme\s*\(")),
|
|
287
|
+
),
|
|
288
|
+
"ssrf": (
|
|
289
|
+
(
|
|
290
|
+
"private/loopback/link-local address check",
|
|
291
|
+
re.compile(r"\.is_(?:private|loopback|link_local|reserved)\b"),
|
|
292
|
+
),
|
|
293
|
+
("allow-list check", re.compile(r"\b(?:is_allowed_url|allowed_hosts|url_allowlist)\b")),
|
|
294
|
+
),
|
|
295
|
+
"nosql_injection": (
|
|
296
|
+
("mongo-sanitize", re.compile(r"\b(?:mongoSanitize|mongo_sanitize|sanitize)\s*\(")),
|
|
297
|
+
),
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _find_sanitizer(kind: str, content: str) -> str | None:
|
|
302
|
+
"""Return a label for the first sink-appropriate neutralizer present in
|
|
303
|
+
`content`, or None. File-granular — the fallback when no flow is known."""
|
|
304
|
+
for label, pattern in _SANITIZER_PATTERNS.get(kind, ()):
|
|
305
|
+
if pattern.search(content):
|
|
306
|
+
return label
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# --- Public API ------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def analyze_taint(
|
|
314
|
+
scan: ScanResult,
|
|
315
|
+
root: str | Path | None = None,
|
|
316
|
+
recall: RecallConfig = DEFAULT_RECALL,
|
|
317
|
+
) -> list[TaintChain]:
|
|
318
|
+
"""Walk the import graph from each route's file, emit TaintChains.
|
|
319
|
+
|
|
320
|
+
``scan.routes`` is treated as the source list. The walk is bounded by
|
|
321
|
+
``recall.max_hops`` and ``recall.max_files_visited``; duplicate
|
|
322
|
+
(route, sink_file, sink_kind, sink_line) tuples are collapsed. Pass an
|
|
323
|
+
aggressive ``recall`` (see ``recall_config()``) to widen discovery — the
|
|
324
|
+
extra reach is tagged ``speculative`` on the returned chains (#148a).
|
|
325
|
+
"""
|
|
326
|
+
root_path = Path(root or scan.root).resolve()
|
|
327
|
+
if not root_path.exists():
|
|
328
|
+
return []
|
|
329
|
+
|
|
330
|
+
files = _index_repo(root_path)
|
|
331
|
+
if not files:
|
|
332
|
+
return []
|
|
333
|
+
|
|
334
|
+
graph = _build_import_graph(files, root_path)
|
|
335
|
+
sinks_by_file = _find_sinks(files, root_path, recall)
|
|
336
|
+
|
|
337
|
+
# Route lines per file, so a same-file flow is attributed only to the
|
|
338
|
+
# route whose handler encloses the sink (#239).
|
|
339
|
+
route_lines: dict[str, list[int]] = {}
|
|
340
|
+
for r in scan.routes:
|
|
341
|
+
if r.line is not None:
|
|
342
|
+
route_lines.setdefault(_normalize_rel(r.file), []).append(r.line)
|
|
343
|
+
|
|
344
|
+
seen: set[tuple[str, str, str, int | None]] = set()
|
|
345
|
+
chains: list[TaintChain] = []
|
|
346
|
+
# Cache per-file content + name→module map so handler resolution reads
|
|
347
|
+
# each route file at most once.
|
|
348
|
+
content_cache: dict[str, str] = {}
|
|
349
|
+
import_cache: dict[str, dict[str, str]] = {}
|
|
350
|
+
py_bindings_cache: dict[str, tuple[dict[str, _PyBinding], list[int], frozenset[str]]] = {}
|
|
351
|
+
defs_cache: dict[str, _ModuleDefs] = {}
|
|
352
|
+
|
|
353
|
+
for route in scan.routes:
|
|
354
|
+
route_file = _normalize_rel(route.file)
|
|
355
|
+
if route_file not in files:
|
|
356
|
+
continue
|
|
357
|
+
# Static / infra endpoints (robots.txt, .well-known, health) return
|
|
358
|
+
# fixed bytes and don't feed request data to a sink — a chain from one
|
|
359
|
+
# is import-walk over-linking, not a real flow (#85). Skip seeding them.
|
|
360
|
+
if is_infra_route(route.path):
|
|
361
|
+
continue
|
|
362
|
+
|
|
363
|
+
# Handler-aware seeding (#107): for JS/TS central-registration apps,
|
|
364
|
+
# seed from the module that DEFINES the route's handler rather than the
|
|
365
|
+
# registration file — otherwise `server.ts` (importing ~100 handlers)
|
|
366
|
+
# fans every route out to every imported sink. Falls back to the route
|
|
367
|
+
# file when no handler identifier resolves (inline handlers, Python
|
|
368
|
+
# decorators, etc.).
|
|
369
|
+
seed_files = [route_file]
|
|
370
|
+
if Path(route_file).suffix in _JS_TS_SUFFIXES:
|
|
371
|
+
if route_file not in import_cache:
|
|
372
|
+
try:
|
|
373
|
+
rf_content = files[route_file].read_text(encoding="utf-8")
|
|
374
|
+
except (OSError, UnicodeDecodeError):
|
|
375
|
+
rf_content = ""
|
|
376
|
+
content_cache[route_file] = rf_content
|
|
377
|
+
import_cache[route_file] = _named_import_modules(
|
|
378
|
+
rf_content, route_file, files, root_path
|
|
379
|
+
)
|
|
380
|
+
handler_seeds = _handler_seed_files(
|
|
381
|
+
route, content_cache.get(route_file, ""), import_cache[route_file]
|
|
382
|
+
)
|
|
383
|
+
if handler_seeds:
|
|
384
|
+
seed_files = handler_seeds
|
|
385
|
+
walk_recall = recall
|
|
386
|
+
seed_spans: dict[str, list[tuple[int, int]] | None] = {}
|
|
387
|
+
if Path(route_file).suffix in _PY_SUFFIXES and route.line is not None:
|
|
388
|
+
# A URLconf route (`path("x/", views.x)`) starts from its view's
|
|
389
|
+
# module, not from urls.py, which imports every view (#372).
|
|
390
|
+
if route_file not in content_cache:
|
|
391
|
+
try:
|
|
392
|
+
content_cache[route_file] = files[route_file].read_text(encoding="utf-8")
|
|
393
|
+
except (OSError, UnicodeDecodeError):
|
|
394
|
+
content_cache[route_file] = ""
|
|
395
|
+
py_seeds = _py_urlconf_seeds(route, content_cache[route_file], route_file, files, py_bindings_cache)
|
|
396
|
+
if py_seeds is not None:
|
|
397
|
+
if py_seeds:
|
|
398
|
+
seed_files = []
|
|
399
|
+
for module, view in py_seeds:
|
|
400
|
+
if module not in defs_cache:
|
|
401
|
+
try:
|
|
402
|
+
text = files[module].read_text(encoding="utf-8")
|
|
403
|
+
except (OSError, UnicodeDecodeError):
|
|
404
|
+
text = ""
|
|
405
|
+
defs_cache[module] = _py_module_defs(text)
|
|
406
|
+
spans = _py_view_spans(defs_cache[module], view)
|
|
407
|
+
if module not in seed_files:
|
|
408
|
+
seed_files.append(module)
|
|
409
|
+
seed_spans[module] = spans
|
|
410
|
+
elif seed_spans.get(module) is not None:
|
|
411
|
+
# Two views in one module: both count, or the whole module.
|
|
412
|
+
seed_spans[module] = None if spans is None else [*seed_spans[module], *spans]
|
|
413
|
+
else:
|
|
414
|
+
# The view is a library class (`TemplateView.as_view()`):
|
|
415
|
+
# nothing in the repo runs but the URLconf itself.
|
|
416
|
+
walk_recall = replace(recall, max_hops=0)
|
|
417
|
+
|
|
418
|
+
for seed in seed_files:
|
|
419
|
+
for chain in _walk_from_route(
|
|
420
|
+
route,
|
|
421
|
+
seed,
|
|
422
|
+
graph,
|
|
423
|
+
sinks_by_file,
|
|
424
|
+
walk_recall,
|
|
425
|
+
route_lines.get(route_file, []),
|
|
426
|
+
handler_span=seed_spans.get(seed) if seed != route_file else None,
|
|
427
|
+
):
|
|
428
|
+
key = (
|
|
429
|
+
f"{chain.route_method} {chain.route_path}",
|
|
430
|
+
chain.sink_file,
|
|
431
|
+
chain.sink_kind,
|
|
432
|
+
chain.sink_line,
|
|
433
|
+
)
|
|
434
|
+
if key in seen:
|
|
435
|
+
continue
|
|
436
|
+
seen.add(key)
|
|
437
|
+
chain.source_analyzer = "taint"
|
|
438
|
+
chains.append(chain)
|
|
439
|
+
|
|
440
|
+
chains.sort(key=lambda c: (c.hops, c.route_file, c.sink_file))
|
|
441
|
+
return chains
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# --- Internals -------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _index_repo(root: Path) -> dict[str, Path]:
|
|
448
|
+
"""Return {rel_path: abs_path} for every taint-relevant source file."""
|
|
449
|
+
out: dict[str, Path] = {}
|
|
450
|
+
skip_dirs = {
|
|
451
|
+
".git", "node_modules", "__pycache__", "dist", "build", ".venv", "venv",
|
|
452
|
+
# Vendored third-party trees (#95) — import-walking someone else's
|
|
453
|
+
# bundled library adds sinks the project doesn't own.
|
|
454
|
+
"bower_components", "vendor", "third_party", "third-party",
|
|
455
|
+
"external", "externals", "jspm_packages", "site-packages",
|
|
456
|
+
}
|
|
457
|
+
for path in walk_repo(root):
|
|
458
|
+
if path.suffix not in _SUPPORTED_SUFFIXES or is_oversized(path):
|
|
459
|
+
continue
|
|
460
|
+
try:
|
|
461
|
+
if any(part in skip_dirs for part in path.relative_to(root).parts):
|
|
462
|
+
continue
|
|
463
|
+
except ValueError:
|
|
464
|
+
continue
|
|
465
|
+
rel = _normalize_rel(str(path.relative_to(root)))
|
|
466
|
+
# Skip test/spec files (#67) and vendored/minified/generated files
|
|
467
|
+
# (#95, incl. `*.d.ts` type stubs) — sinks there are a large false-
|
|
468
|
+
# positive source. `ATTACKMAP_INCLUDE_TESTS` / `_INCLUDE_VENDORED` opt in.
|
|
469
|
+
if is_test_file(rel) or is_vendored_file(rel):
|
|
470
|
+
continue
|
|
471
|
+
out[rel] = path
|
|
472
|
+
return out
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _normalize_rel(rel: str) -> str:
|
|
476
|
+
return rel.replace("\\", "/")
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _go_module_path(root: Path) -> str | None:
|
|
480
|
+
"""The module path declared in go.mod, if present (e.g. github.com/x/y)."""
|
|
481
|
+
gomod = root / "go.mod"
|
|
482
|
+
try:
|
|
483
|
+
if gomod.is_file() and is_contained(root, gomod):
|
|
484
|
+
m = _GO_MODULE_RE.search(gomod.read_text(encoding="utf-8", errors="ignore"))
|
|
485
|
+
if m:
|
|
486
|
+
return m.group("mod")
|
|
487
|
+
except OSError:
|
|
488
|
+
pass
|
|
489
|
+
return None
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _go_dir_index(files: dict[str, Path]) -> dict[str, set[str]]:
|
|
493
|
+
"""Map each directory (rel) to the set of .go files it contains — a Go
|
|
494
|
+
package is a directory, so imports resolve to all files in the dir."""
|
|
495
|
+
idx: dict[str, set[str]] = {}
|
|
496
|
+
for rel in files:
|
|
497
|
+
if rel.endswith(".go"):
|
|
498
|
+
d = rel.rsplit("/", 1)[0] if "/" in rel else ""
|
|
499
|
+
idx.setdefault(d, set()).add(rel)
|
|
500
|
+
return idx
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _resolve_go_imports(
|
|
504
|
+
content: str, module_path: str | None, dir_index: dict[str, set[str]]
|
|
505
|
+
) -> set[str]:
|
|
506
|
+
"""Resolve a Go file's intra-repo imports to the files in the imported
|
|
507
|
+
package directories (module-path based)."""
|
|
508
|
+
if not module_path:
|
|
509
|
+
return set()
|
|
510
|
+
out: set[str] = set()
|
|
511
|
+
for imp in _parse_go_imports(content):
|
|
512
|
+
if imp == module_path:
|
|
513
|
+
pkgdir = ""
|
|
514
|
+
elif imp.startswith(module_path + "/"):
|
|
515
|
+
pkgdir = imp[len(module_path) + 1 :]
|
|
516
|
+
else:
|
|
517
|
+
continue # external / stdlib package
|
|
518
|
+
out |= dir_index.get(pkgdir, set())
|
|
519
|
+
return out
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
_PHP_USE_RE = re.compile(r"^\s*use\s+(?P<fqcn>\\?[A-Za-z0-9_\\]+)", re.MULTILINE)
|
|
523
|
+
_PHP_REQUIRE_RE = re.compile(
|
|
524
|
+
r"\b(?:require|include)(?:_once)?\s*\(?\s*['\"](?P<path>[^'\"]+)['\"]"
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _php_psr4_map(root: Path) -> dict[str, str]:
|
|
529
|
+
"""namespace-prefix → directory from composer.json PSR-4 autoload."""
|
|
530
|
+
composer = root / "composer.json"
|
|
531
|
+
out: dict[str, str] = {}
|
|
532
|
+
try:
|
|
533
|
+
data = json.loads(read_repo_text(root, composer, errors="ignore"))
|
|
534
|
+
except (OSError, ValueError):
|
|
535
|
+
return out
|
|
536
|
+
for section in ("autoload", "autoload-dev"):
|
|
537
|
+
psr4 = data.get(section, {}).get("psr-4", {}) if isinstance(data.get(section), dict) else {}
|
|
538
|
+
for ns, directory in psr4.items():
|
|
539
|
+
dirs = directory if isinstance(directory, list) else [directory]
|
|
540
|
+
for d in dirs:
|
|
541
|
+
out.setdefault(ns.rstrip("\\"), str(d).strip("/"))
|
|
542
|
+
return out
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _resolve_php_imports(
|
|
546
|
+
content: str, from_rel: str, files: dict[str, Path], root: Path, psr4: dict[str, str]
|
|
547
|
+
) -> set[str]:
|
|
548
|
+
"""Resolve PHP `use Ns\\Class` (via composer PSR-4) and `require '…'` to
|
|
549
|
+
intra-repo files."""
|
|
550
|
+
out: set[str] = set()
|
|
551
|
+
for match in _PHP_USE_RE.finditer(content):
|
|
552
|
+
fqcn = match.group("fqcn").lstrip("\\")
|
|
553
|
+
for ns, directory in psr4.items():
|
|
554
|
+
if fqcn == ns or fqcn.startswith(ns + "\\"):
|
|
555
|
+
rest = fqcn[len(ns):].lstrip("\\").replace("\\", "/")
|
|
556
|
+
rel = _normalize_rel(str((Path(directory) / (rest + ".php"))) if directory else rest + ".php")
|
|
557
|
+
if rel in files:
|
|
558
|
+
out.add(rel)
|
|
559
|
+
break
|
|
560
|
+
from_dir = (root / from_rel).parent
|
|
561
|
+
for match in _PHP_REQUIRE_RE.finditer(content):
|
|
562
|
+
try:
|
|
563
|
+
rel = _normalize_rel(str((from_dir / match.group("path")).resolve().relative_to(root)))
|
|
564
|
+
except ValueError:
|
|
565
|
+
continue
|
|
566
|
+
if rel in files:
|
|
567
|
+
out.add(rel)
|
|
568
|
+
return out
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _build_import_graph(files: dict[str, Path], root: Path) -> dict[str, set[str]]:
|
|
572
|
+
"""rel_path -> set of rel_paths this file imports (intra-repo only).
|
|
573
|
+
|
|
574
|
+
For Python and JS/TS, import edges are pruned to those whose bound symbols
|
|
575
|
+
are actually used in the importing file (call-graph awareness, #138). Go
|
|
576
|
+
and PHP keep the plain import-graph (no pruning) — their binding shapes are
|
|
577
|
+
not resolved here, so every edge is kept as a fallback.
|
|
578
|
+
"""
|
|
579
|
+
graph: dict[str, set[str]] = {rel: set() for rel in files}
|
|
580
|
+
go_module = _go_module_path(root)
|
|
581
|
+
go_dirs = _go_dir_index(files)
|
|
582
|
+
php_psr4 = _php_psr4_map(root)
|
|
583
|
+
for rel, abs_path in files.items():
|
|
584
|
+
try:
|
|
585
|
+
content = abs_path.read_text(encoding="utf-8")
|
|
586
|
+
except (OSError, UnicodeDecodeError):
|
|
587
|
+
continue
|
|
588
|
+
if abs_path.suffix in _PY_SUFFIXES:
|
|
589
|
+
edges = _resolve_py_imports_named(content, rel, files)
|
|
590
|
+
graph[rel].update(_prune_unused_edges(edges, content, ".py"))
|
|
591
|
+
elif abs_path.suffix in _GO_SUFFIXES:
|
|
592
|
+
graph[rel].update(_resolve_go_imports(content, go_module, go_dirs))
|
|
593
|
+
elif abs_path.suffix in _PHP_SUFFIXES:
|
|
594
|
+
graph[rel].update(_resolve_php_imports(content, rel, files, root, php_psr4))
|
|
595
|
+
elif abs_path.suffix in _SAME_FILE_ONLY_SUFFIXES:
|
|
596
|
+
continue # same-file flows only (#239, #240)
|
|
597
|
+
else: # JS/TS
|
|
598
|
+
edges = _resolve_js_imports_named(content, rel, files, root)
|
|
599
|
+
graph[rel].update(_prune_unused_edges(edges, content, ".js"))
|
|
600
|
+
return graph
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _prune_unused_edges(
|
|
604
|
+
edges: dict[str, set[str]], content: str, suffix: str
|
|
605
|
+
) -> set[str]:
|
|
606
|
+
"""Keep an edge unless it has explicit binding names none of which are used
|
|
607
|
+
in ``content``. Edges with no resolvable bindings (empty set) are always
|
|
608
|
+
kept — the import-graph fallback for namespace/star/side-effect imports."""
|
|
609
|
+
used = _used_identifiers(content, suffix)
|
|
610
|
+
kept: set[str] = set()
|
|
611
|
+
for target, names in edges.items():
|
|
612
|
+
if names and names.isdisjoint(used):
|
|
613
|
+
continue # dead import — no call/reference backs this edge (#138)
|
|
614
|
+
kept.add(target)
|
|
615
|
+
return kept
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _resolve_js_imports_named(
|
|
619
|
+
content: str, from_rel: str, files: dict[str, Path], root: Path
|
|
620
|
+
) -> dict[str, set[str]]:
|
|
621
|
+
"""target rel -> bound names, for pruning. A target reached only through a
|
|
622
|
+
side-effect / dynamic import (no named binding) maps to an empty set, which
|
|
623
|
+
keeps its edge unconditionally."""
|
|
624
|
+
edges: dict[str, set[str]] = {t: set() for t in _resolve_js_imports(content, from_rel, files, root)}
|
|
625
|
+
for name, target in _named_import_modules(content, from_rel, files, root).items():
|
|
626
|
+
edges.setdefault(target, set()).add(name)
|
|
627
|
+
return edges
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _resolve_py_module(
|
|
631
|
+
module: str, package_parts: tuple[str, ...], files: dict[str, Path]
|
|
632
|
+
) -> str | None:
|
|
633
|
+
parts = module.split(".")
|
|
634
|
+
init_parts = parts + ["__init__.py"]
|
|
635
|
+
# Try as-if fully qualified relative to repo root.
|
|
636
|
+
candidates: list[str] = []
|
|
637
|
+
candidates.append("/".join(parts) + ".py")
|
|
638
|
+
candidates.append("/".join(init_parts))
|
|
639
|
+
# Try relative to package (siblings).
|
|
640
|
+
if package_parts:
|
|
641
|
+
pkg = "/".join(package_parts)
|
|
642
|
+
candidates.append(f"{pkg}/{'/'.join(parts)}.py")
|
|
643
|
+
candidates.append(f"{pkg}/{'/'.join(init_parts)}")
|
|
644
|
+
for cand in candidates:
|
|
645
|
+
norm = _normalize_rel(cand)
|
|
646
|
+
if norm in files:
|
|
647
|
+
return norm
|
|
648
|
+
return None
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
# from X import a, b as c | from X import (a,\n b) | from X import *
|
|
652
|
+
# | from X import \<nl> a (parenthesized + backslash continuations)
|
|
653
|
+
_PY_FROM_IMPORT_RE = re.compile(
|
|
654
|
+
r"^[ \t]*from\s+(?P<mod>[\w.]+)\s+import\s+(?P<names>\*|\([^)]*\)|(?:\\\n|[^\n#])+)",
|
|
655
|
+
re.MULTILINE,
|
|
656
|
+
)
|
|
657
|
+
# import a, b.c as d
|
|
658
|
+
_PY_PLAIN_IMPORT_RE = re.compile(r"^[ \t]*import\s+(?P<mods>[\w., ]+)", re.MULTILINE)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _resolve_py_imports_named(
|
|
662
|
+
content: str, from_rel: str, files: dict[str, Path]
|
|
663
|
+
) -> dict[str, set[str]]:
|
|
664
|
+
"""target rel -> the binding names it introduces, for edge pruning (#138).
|
|
665
|
+
|
|
666
|
+
A ``from X import *`` (unknown bindings) maps to an empty set, so its edge is
|
|
667
|
+
always kept. Mirrors ``_resolve_py_imports`` for module resolution."""
|
|
668
|
+
package_parts = Path(from_rel).parent.parts
|
|
669
|
+
edges: dict[str, set[str]] = {}
|
|
670
|
+
|
|
671
|
+
def _add(target: str | None, name: str | None) -> None:
|
|
672
|
+
if target is None:
|
|
673
|
+
return
|
|
674
|
+
bucket = edges.setdefault(target, set())
|
|
675
|
+
if name is not None:
|
|
676
|
+
bucket.add(name)
|
|
677
|
+
|
|
678
|
+
for match in _PY_FROM_IMPORT_RE.finditer(content):
|
|
679
|
+
target = _resolve_py_module(match.group("mod"), package_parts, files)
|
|
680
|
+
raw = match.group("names").strip()
|
|
681
|
+
if raw == "*":
|
|
682
|
+
_add(target, None) # star import — keep edge unconditionally
|
|
683
|
+
continue
|
|
684
|
+
# Normalize multi-line forms: drop parens and backslash/newline
|
|
685
|
+
# continuations so `(a,\n b)` and `\<nl> a` split cleanly.
|
|
686
|
+
raw = raw.strip("()").replace("\\", " ").replace("\n", " ")
|
|
687
|
+
for part in raw.split(","):
|
|
688
|
+
part = part.strip()
|
|
689
|
+
if not part:
|
|
690
|
+
continue
|
|
691
|
+
binding = part.split(" as ")[-1].strip() # alias if present
|
|
692
|
+
if binding.isidentifier():
|
|
693
|
+
_add(target, binding)
|
|
694
|
+
for match in _PY_PLAIN_IMPORT_RE.finditer(content):
|
|
695
|
+
for item in match.group("mods").split(","):
|
|
696
|
+
item = item.strip()
|
|
697
|
+
if not item:
|
|
698
|
+
continue
|
|
699
|
+
if " as " in item:
|
|
700
|
+
mod_part, binding = item.split(" as ", 1)
|
|
701
|
+
binding = binding.strip()
|
|
702
|
+
else:
|
|
703
|
+
mod_part = item
|
|
704
|
+
binding = item.split(".")[0].strip() # `import a.b.c` binds `a`
|
|
705
|
+
target = _resolve_py_module(mod_part.split(" as ")[0].split(".")[0], package_parts, files)
|
|
706
|
+
if binding.isidentifier():
|
|
707
|
+
_add(target, binding)
|
|
708
|
+
return edges
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _resolve_js_imports(
|
|
712
|
+
content: str, from_rel: str, files: dict[str, Path], root: Path
|
|
713
|
+
) -> set[str]:
|
|
714
|
+
from_dir = (root / from_rel).parent
|
|
715
|
+
out: set[str] = set()
|
|
716
|
+
for match in _JS_IMPORT_RE.finditer(content):
|
|
717
|
+
raw = match.group("path")
|
|
718
|
+
target = (from_dir / raw).resolve()
|
|
719
|
+
rel = _resolve_js_target(target, files, root)
|
|
720
|
+
if rel is not None:
|
|
721
|
+
out.add(rel)
|
|
722
|
+
return out
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
# Named / default / namespace / require bindings → module specifier, so a
|
|
726
|
+
# route's handler identifier can be resolved to the module that defines it
|
|
727
|
+
# (#107 handler-aware seeding).
|
|
728
|
+
_JS_NAMED_IMPORT_RE = re.compile(r"""import\s+(?P<clause>[^;'"]+?)\s+from\s+["'](?P<path>[^"']+)["']""")
|
|
729
|
+
_JS_REQUIRE_BIND_RE = re.compile(
|
|
730
|
+
r"""(?:const|let|var)\s+(?P<clause>\{[^}]*\}|[A-Za-z_$][\w$]*)\s*=\s*require\(\s*["'](?P<path>[^"']+)["']\s*\)"""
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _binding_names(clause: str) -> list[str]:
|
|
735
|
+
"""Extract bound identifiers from an import/require clause.
|
|
736
|
+
|
|
737
|
+
`{ a, b as c }` → [a, c]; `foo` → [foo]; `* as ns` → [ns];
|
|
738
|
+
`Def, { a }` → [Def, a]."""
|
|
739
|
+
names: list[str] = []
|
|
740
|
+
clause = clause.strip()
|
|
741
|
+
brace = re.search(r"\{([^}]*)\}", clause)
|
|
742
|
+
if brace:
|
|
743
|
+
for part in brace.group(1).split(","):
|
|
744
|
+
part = part.strip()
|
|
745
|
+
if not part:
|
|
746
|
+
continue
|
|
747
|
+
name = re.split(r"\s+as\s+", part)[-1].strip()
|
|
748
|
+
if name.isidentifier():
|
|
749
|
+
names.append(name)
|
|
750
|
+
before = clause[: brace.start()].strip().rstrip(",").strip()
|
|
751
|
+
if before.isidentifier():
|
|
752
|
+
names.append(before)
|
|
753
|
+
else:
|
|
754
|
+
ns = re.search(r"\*\s+as\s+([A-Za-z_$][\w$]*)", clause)
|
|
755
|
+
if ns:
|
|
756
|
+
names.append(ns.group(1))
|
|
757
|
+
elif clause.isidentifier():
|
|
758
|
+
names.append(clause)
|
|
759
|
+
return names
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _named_import_modules(
|
|
763
|
+
content: str, from_rel: str, files: dict[str, Path], root: Path
|
|
764
|
+
) -> dict[str, str]:
|
|
765
|
+
"""Map each imported binding name in ``content`` to its resolved intra-repo
|
|
766
|
+
module (rel path). Only bindings that resolve to a file we indexed appear."""
|
|
767
|
+
from_dir = (root / from_rel).parent
|
|
768
|
+
out: dict[str, str] = {}
|
|
769
|
+
for regex in (_JS_NAMED_IMPORT_RE, _JS_REQUIRE_BIND_RE):
|
|
770
|
+
for match in regex.finditer(content):
|
|
771
|
+
spec = match.group("path")
|
|
772
|
+
if not spec.startswith((".", "/")):
|
|
773
|
+
# Bare specifier (`require("path")`, `from "express"`) is a
|
|
774
|
+
# package, never a sibling file that happens to share its name.
|
|
775
|
+
continue
|
|
776
|
+
rel = _resolve_js_target((from_dir / spec).resolve(), files, root)
|
|
777
|
+
if rel is None:
|
|
778
|
+
continue
|
|
779
|
+
for name in _binding_names(match.group("clause")):
|
|
780
|
+
out.setdefault(name, rel)
|
|
781
|
+
return out
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
_ROUTE_CALL_RE = re.compile(
|
|
785
|
+
r"\.(?:get|post|put|delete|patch|options|head|all|use)\s*\(", re.IGNORECASE
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _handler_seed_files(route: Route, content: str, name_to_module: dict[str, str]) -> list[str]:
|
|
790
|
+
"""For a JS/TS route registration (`app.get(path, …handler…)`), resolve an
|
|
791
|
+
imported handler *identifier* to its defining module.
|
|
792
|
+
|
|
793
|
+
Only the registration call's arguments are inspected — not the handler
|
|
794
|
+
body. An inline handler (`=>` / `function`) keeps its own code in the
|
|
795
|
+
registration file, so we don't redirect the seed (returns []); a reference
|
|
796
|
+
to an imported handler (`getUserProfile`, `utils.asyncHandler(getUserProfile())`)
|
|
797
|
+
seeds from that handler's module instead, avoiding the central-registration
|
|
798
|
+
import-hub fan-out (#107)."""
|
|
799
|
+
if route.line is None:
|
|
800
|
+
return []
|
|
801
|
+
lines = split_lines(content, keepends=True)
|
|
802
|
+
idx = route.line - 1
|
|
803
|
+
if idx < 0 or idx >= len(lines):
|
|
804
|
+
return []
|
|
805
|
+
line_start = sum(len(line) for line in lines[:idx])
|
|
806
|
+
call = _ROUTE_CALL_RE.search(content, line_start, min(len(content), line_start + 400))
|
|
807
|
+
if call is None:
|
|
808
|
+
return []
|
|
809
|
+
paren = content.find("(", call.start())
|
|
810
|
+
if paren == -1:
|
|
811
|
+
return []
|
|
812
|
+
args = _extract_call_arg(content, paren)
|
|
813
|
+
if "=>" in args or re.search(r"\bfunction\b", args):
|
|
814
|
+
return [] # inline handler — its body lives in the registration file
|
|
815
|
+
seeds: list[str] = []
|
|
816
|
+
for ident in dict.fromkeys(re.findall(r"[A-Za-z_$][\w$]*", args)):
|
|
817
|
+
mod = name_to_module.get(ident)
|
|
818
|
+
if mod is not None and mod not in seeds:
|
|
819
|
+
seeds.append(mod)
|
|
820
|
+
return seeds
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
# A URL registration whose view argument names the handler: Django `path`,
|
|
824
|
+
# `re_path`, `url`, zulip's `rest_path`, DRF `router.register`, Flask
|
|
825
|
+
# `add_url_rule`, aiohttp `add_get`/`add_route`/… (#372). Decorator routes
|
|
826
|
+
# (`@app.route`) keep their handler in the route file and never match.
|
|
827
|
+
_PY_URL_REGISTRATION_RE = re.compile(
|
|
828
|
+
r"\b(?:path|re_path|url|rest_path|register|add_url_rule|add_(?:get|post|put|patch|delete|head|route|view))\s*\("
|
|
829
|
+
)
|
|
830
|
+
_PY_STRING_RE = re.compile(r"""[rbuRBU]{0,2}(?:'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*")""")
|
|
831
|
+
# A module-level `name = …` (a view built in the URLconf: `hook = csrf_exempt(f)`).
|
|
832
|
+
_PY_ASSIGNED_NAME_RE = re.compile(r"^([A-Za-z_]\w{0,63})[ \t]*=(?!=)", re.MULTILINE)
|
|
833
|
+
_PY_DOTTED_REF_RE = re.compile(r"(?<![\w.])([A-Za-z_]\w*)((?:\.[A-Za-z_]\w*)*)")
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _line_starts(content: str) -> list[int]:
|
|
837
|
+
starts = [0]
|
|
838
|
+
pos = content.find("\n")
|
|
839
|
+
while pos != -1:
|
|
840
|
+
starts.append(pos + 1)
|
|
841
|
+
pos = content.find("\n", pos + 1)
|
|
842
|
+
return starts
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
# An imported name: the intra-repo module it comes from (None: third-party or
|
|
846
|
+
# stdlib) and, for `from mod import name`, the name defined in that module.
|
|
847
|
+
_PyBinding = tuple[str | None, str | None]
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _py_bindings(content: str, from_rel: str, files: dict[str, Path]) -> dict[str, _PyBinding]:
|
|
851
|
+
"""Every name ``content`` imports → (module, defined name). `from pkg
|
|
852
|
+
import mod` binds the submodule when one exists; relative imports resolve
|
|
853
|
+
from the importing file's package."""
|
|
854
|
+
package = Path(from_rel).parent.parts
|
|
855
|
+
out: dict[str, _PyBinding] = {}
|
|
856
|
+
|
|
857
|
+
def resolve(module: str) -> str | None:
|
|
858
|
+
dots = len(module) - len(module.lstrip("."))
|
|
859
|
+
name = module.lstrip(".")
|
|
860
|
+
if not name:
|
|
861
|
+
return None
|
|
862
|
+
if dots:
|
|
863
|
+
if dots - 1 > len(package):
|
|
864
|
+
return None
|
|
865
|
+
return _resolve_py_module(name, package[: len(package) - (dots - 1)], files)
|
|
866
|
+
return _resolve_py_module(name, package, files)
|
|
867
|
+
|
|
868
|
+
for match in _PY_FROM_IMPORT_RE.finditer(content):
|
|
869
|
+
mod = match.group("mod")
|
|
870
|
+
raw = match.group("names").strip()
|
|
871
|
+
if raw == "*":
|
|
872
|
+
continue
|
|
873
|
+
raw = raw.strip("()").replace("\\", " ").replace("\n", " ")
|
|
874
|
+
sep = "" if mod.endswith(".") else "."
|
|
875
|
+
for part in raw.split(","):
|
|
876
|
+
bits = part.strip().split(" as ")
|
|
877
|
+
real, binding = bits[0].strip(), bits[-1].strip()
|
|
878
|
+
if not binding.isidentifier():
|
|
879
|
+
continue
|
|
880
|
+
submodule = resolve(f"{mod}{sep}{real}")
|
|
881
|
+
if submodule is not None:
|
|
882
|
+
out.setdefault(binding, (submodule, None))
|
|
883
|
+
else:
|
|
884
|
+
out.setdefault(binding, (resolve(mod) if mod.strip(".") else None, real))
|
|
885
|
+
for match in _PY_PLAIN_IMPORT_RE.finditer(content):
|
|
886
|
+
for item in match.group("mods").split(","):
|
|
887
|
+
bits = item.strip().split(" as ")
|
|
888
|
+
mod = bits[0].strip()
|
|
889
|
+
if not mod:
|
|
890
|
+
continue
|
|
891
|
+
binding = bits[1].strip() if len(bits) > 1 else mod.split(".")[0]
|
|
892
|
+
if binding.isidentifier():
|
|
893
|
+
out.setdefault(binding, (resolve(mod if len(bits) > 1 else mod.split(".")[0]), None))
|
|
894
|
+
return out
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _py_urlconf_seeds(
|
|
898
|
+
route: Route,
|
|
899
|
+
content: str,
|
|
900
|
+
route_file: str,
|
|
901
|
+
files: dict[str, Path],
|
|
902
|
+
cache: dict[str, tuple[dict[str, _PyBinding], list[int], frozenset[str]]],
|
|
903
|
+
) -> list[tuple[str, str | None]] | None:
|
|
904
|
+
"""The views a Python URL registration names, as (module, view name) (#372).
|
|
905
|
+
|
|
906
|
+
None when the handler is unknown (not a registration line, an
|
|
907
|
+
`include()`, or a view defined in the route file itself): the walk then
|
|
908
|
+
starts from the route file as before. [] when every named view comes from
|
|
909
|
+
a third-party package (`TemplateView.as_view(...)`): no repo module runs."""
|
|
910
|
+
if route_file not in cache:
|
|
911
|
+
cache[route_file] = (
|
|
912
|
+
_py_bindings(content, route_file, files),
|
|
913
|
+
_line_starts(content),
|
|
914
|
+
frozenset(_py_module_defs(content)) | frozenset(_PY_ASSIGNED_NAME_RE.findall(content)),
|
|
915
|
+
)
|
|
916
|
+
bindings, starts, local_defs = cache[route_file]
|
|
917
|
+
idx = (route.line or 0) - 1
|
|
918
|
+
if idx < 0 or idx >= len(starts):
|
|
919
|
+
return None
|
|
920
|
+
line_end = content.find("\n", starts[idx])
|
|
921
|
+
call = _PY_URL_REGISTRATION_RE.search(content, starts[idx], len(content) if line_end < 0 else line_end)
|
|
922
|
+
if call is None:
|
|
923
|
+
return None
|
|
924
|
+
args = _PY_STRING_RE.sub("''", _extract_call_arg(content, call.end() - 1))
|
|
925
|
+
seeds: list[tuple[str, str | None]] = []
|
|
926
|
+
external = False
|
|
927
|
+
local = False
|
|
928
|
+
for match in _PY_DOTTED_REF_RE.finditer(args):
|
|
929
|
+
name = match.group(1)
|
|
930
|
+
if name == "include":
|
|
931
|
+
return None # the route's views live in the included URLconf
|
|
932
|
+
if name not in bindings:
|
|
933
|
+
# A view defined in the URLconf itself, possibly wrapped by a
|
|
934
|
+
# library decorator (`csrf_exempt(hook)`): not external.
|
|
935
|
+
local = local or name in local_defs
|
|
936
|
+
continue
|
|
937
|
+
module, defined = bindings[name]
|
|
938
|
+
if module is None:
|
|
939
|
+
external = True
|
|
940
|
+
continue
|
|
941
|
+
rest = [p for p in match.group(2).split(".") if p]
|
|
942
|
+
if defined is None:
|
|
943
|
+
# `views.billing.page`: the deepest submodule that exists, then
|
|
944
|
+
# the first remaining name is the view.
|
|
945
|
+
base = module[: -len("/__init__.py")] if module.endswith("/__init__.py") else module[: -len(".py")]
|
|
946
|
+
for k in range(len(rest), 0, -1):
|
|
947
|
+
sub = _resolve_py_module(".".join([*base.split("/"), *rest[:k]]), (), files)
|
|
948
|
+
if sub is not None:
|
|
949
|
+
module, rest = sub, rest[k:]
|
|
950
|
+
break
|
|
951
|
+
defined = rest[0] if rest else None
|
|
952
|
+
if module in files and (module, defined) not in seeds:
|
|
953
|
+
seeds.append((module, defined))
|
|
954
|
+
if seeds:
|
|
955
|
+
return seeds
|
|
956
|
+
return [] if external and not local else None
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
_VIEW_CLOSURE_LIMIT = 64
|
|
960
|
+
|
|
961
|
+
# A module's top-level definitions: name → (first line, last line, the other
|
|
962
|
+
# top-level names its body, decorators or bases mention).
|
|
963
|
+
_ModuleDefs = dict[str, tuple[int, int, frozenset[str]]]
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def _py_module_defs(content: str) -> _ModuleDefs:
|
|
967
|
+
"""Top-level `def`/`class` and assignment spans of a module, parsed once (#372)."""
|
|
968
|
+
try:
|
|
969
|
+
with warnings.catch_warnings():
|
|
970
|
+
warnings.simplefilter("ignore", SyntaxWarning) # scanned code's escapes aren't ours
|
|
971
|
+
tree = ast.parse(content)
|
|
972
|
+
except (SyntaxError, ValueError, RecursionError, MemoryError):
|
|
973
|
+
return {}
|
|
974
|
+
# Definitions, plus module-level assignments the walk passes through
|
|
975
|
+
# (`HANDLERS = {'a': handler_a}`, `export = partial(_do, 'csv')`,
|
|
976
|
+
# `RUNNER = Runner()`): an assignment's mentions are its right-hand names.
|
|
977
|
+
entries: list[tuple[str, ast.AST, int, int, ast.AST]] = []
|
|
978
|
+
for node in tree.body:
|
|
979
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
980
|
+
start = min([d.lineno for d in node.decorator_list] + [node.lineno])
|
|
981
|
+
entries.append((node.name, node, start, node.end_lineno or node.lineno, node))
|
|
982
|
+
elif isinstance(node, (ast.Assign, ast.AnnAssign)) and node.value is not None:
|
|
983
|
+
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
|
984
|
+
for target in targets:
|
|
985
|
+
for name in (t.id for t in ast.walk(target) if isinstance(t, ast.Name)):
|
|
986
|
+
entries.append((name, node, node.lineno, node.end_lineno or node.lineno, node.value))
|
|
987
|
+
names = {name for name, *_ in entries}
|
|
988
|
+
out: _ModuleDefs = {}
|
|
989
|
+
for name, _node, start, end, body in entries:
|
|
990
|
+
mentioned = frozenset(sub.id for sub in ast.walk(body) if isinstance(sub, ast.Name) and sub.id in names and sub.id != name)
|
|
991
|
+
out.setdefault(name, (start, end, mentioned))
|
|
992
|
+
return out
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _py_view_spans(defs: _ModuleDefs, view: str | None) -> list[tuple[int, int]] | None:
|
|
996
|
+
"""Line spans of ``view`` plus the same-module functions and base classes
|
|
997
|
+
it reaches (transitively, at most ``_VIEW_CLOSURE_LIMIT`` definitions).
|
|
998
|
+
None when ``view`` isn't a top-level definition here (a re-export): the
|
|
999
|
+
whole module then counts, as before."""
|
|
1000
|
+
if not view or view not in defs:
|
|
1001
|
+
return None
|
|
1002
|
+
seen = [view]
|
|
1003
|
+
queue = [view]
|
|
1004
|
+
while queue and len(seen) < _VIEW_CLOSURE_LIMIT:
|
|
1005
|
+
for ref in sorted(defs[queue.pop()][2]):
|
|
1006
|
+
if ref not in seen and len(seen) < _VIEW_CLOSURE_LIMIT:
|
|
1007
|
+
seen.append(ref)
|
|
1008
|
+
queue.append(ref)
|
|
1009
|
+
return sorted((defs[name][0], defs[name][1]) for name in seen)
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def _resolve_js_target(target: Path, files: dict[str, Path], root: Path) -> str | None:
|
|
1013
|
+
# Direct suffixed hit.
|
|
1014
|
+
for suffix in _JS_TS_SUFFIXES:
|
|
1015
|
+
candidate = target.with_suffix(suffix) if target.suffix else target.parent / (target.name + suffix)
|
|
1016
|
+
try:
|
|
1017
|
+
rel = _normalize_rel(str(candidate.relative_to(root)))
|
|
1018
|
+
except ValueError:
|
|
1019
|
+
continue
|
|
1020
|
+
if rel in files:
|
|
1021
|
+
return rel
|
|
1022
|
+
# index.* fallback for directory imports.
|
|
1023
|
+
for suffix in _JS_TS_SUFFIXES:
|
|
1024
|
+
candidate = target / f"index{suffix}"
|
|
1025
|
+
try:
|
|
1026
|
+
rel = _normalize_rel(str(candidate.relative_to(root)))
|
|
1027
|
+
except ValueError:
|
|
1028
|
+
continue
|
|
1029
|
+
if rel in files:
|
|
1030
|
+
return rel
|
|
1031
|
+
# Raw suffixed path (target already has an extension).
|
|
1032
|
+
if target.suffix in _JS_TS_SUFFIXES:
|
|
1033
|
+
try:
|
|
1034
|
+
rel = _normalize_rel(str(target.relative_to(root)))
|
|
1035
|
+
except ValueError:
|
|
1036
|
+
return None
|
|
1037
|
+
if rel in files:
|
|
1038
|
+
return rel
|
|
1039
|
+
return None
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
# Sink families that fire on reachability alone (no request-token gate),
|
|
1043
|
+
# because the operation is dangerous regardless of the argument's shape.
|
|
1044
|
+
# But that over-fires when the argument is provably STATIC/local — e.g.
|
|
1045
|
+
# `yaml.load(fs.readFileSync('./swagger.yml'))` loads a shipped config at
|
|
1046
|
+
# boot, not attacker bytes. Left ungated, one such call in a hub module
|
|
1047
|
+
# fans out to a chain from every route (observed: 113/123 chains on a real
|
|
1048
|
+
# app collapsing to one benign `yaml.load` of a static file). We suppress
|
|
1049
|
+
# these kinds when the call argument is a bare string literal or a
|
|
1050
|
+
# literal-path file read with no dynamic/attacker component.
|
|
1051
|
+
_STATIC_GATED_KINDS = frozenset({"unsafe_deserialization", "eval", "exec"})
|
|
1052
|
+
|
|
1053
|
+
# A quoted string literal appears in the argument.
|
|
1054
|
+
_ARG_STRING_LITERAL = re.compile(r"""['"][^'"]*['"]""")
|
|
1055
|
+
# Signals the argument is (or may be) dynamic / attacker-influenced: string
|
|
1056
|
+
# concatenation, template/f-string interpolation, a request / runtime-input
|
|
1057
|
+
# container, or a `$`-variable (PHP `$_POST`/`$var`, so `unserialize($_POST['k'])`
|
|
1058
|
+
# — whose `'k'` array-key literal would otherwise read as static — is flagged).
|
|
1059
|
+
# If any appears, we do NOT treat the arg as static.
|
|
1060
|
+
_ARG_DYNAMIC = re.compile(
|
|
1061
|
+
r"\+|\$|`|\b(?:req|request|body|query|params|payload|argv|input|stdin)\b|process\."
|
|
1062
|
+
)
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
# A redirect to `reverse("view")` / `url_for("view", …)` / `resolve_url(view)`
|
|
1066
|
+
# goes to an internal path whatever the request supplies as kwargs or query
|
|
1067
|
+
# (#375): the view name pins it, like #327's fixed host.
|
|
1068
|
+
_INTERNAL_URL_CALL_RE = re.compile(
|
|
1069
|
+
r"(?:[fF]['\"]\{)?(?<![\w.])(?:reverse|reverse_lazy|url_for|resolve_url)\s*\(\s*(?:[rbuRBU]?['\"][^'\"\n]{1,200}['\"]|[A-Za-z_][\w.]{0,200})\s*[,)]"
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
_KEYWORD_ARG_RE = re.compile(r"[A-Za-z_]\w{0,63}[ \t]*=(?!=)")
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def _top_level_args(text: str) -> list[tuple[int, int]] | None:
|
|
1077
|
+
"""Offsets of each top-level comma-separated argument in ``text`` (a
|
|
1078
|
+
call's argument text), skipping brackets and quoted strings (triple
|
|
1079
|
+
quotes included). None when the text doesn't parse cleanly: an
|
|
1080
|
+
unterminated string or unbalanced brackets."""
|
|
1081
|
+
spans: list[tuple[int, int]] = []
|
|
1082
|
+
depth = 0
|
|
1083
|
+
start = 0
|
|
1084
|
+
i = 0
|
|
1085
|
+
n = len(text)
|
|
1086
|
+
while i < n:
|
|
1087
|
+
c = text[i]
|
|
1088
|
+
if c in "'\"`":
|
|
1089
|
+
delim = text[i : i + 3] if text[i : i + 3] in ("'''", '"""') else c
|
|
1090
|
+
j = i + len(delim)
|
|
1091
|
+
while j < n and not text.startswith(delim, j):
|
|
1092
|
+
j += 2 if text[j] == "\\" else 1
|
|
1093
|
+
if j >= n:
|
|
1094
|
+
return None
|
|
1095
|
+
i = j + len(delim)
|
|
1096
|
+
continue
|
|
1097
|
+
if c in "([{":
|
|
1098
|
+
depth += 1
|
|
1099
|
+
elif c in ")]}":
|
|
1100
|
+
depth -= 1
|
|
1101
|
+
if depth < 0:
|
|
1102
|
+
return None
|
|
1103
|
+
elif c == "," and depth == 0:
|
|
1104
|
+
spans.append((start, i))
|
|
1105
|
+
start = i + 1
|
|
1106
|
+
i += 1
|
|
1107
|
+
if depth != 0:
|
|
1108
|
+
return None
|
|
1109
|
+
spans.append((start, n))
|
|
1110
|
+
return spans
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
def _token_in_dangerous_args(
|
|
1114
|
+
content: str, arg_lo: int, token_hi: int, arg_text: str, args: tuple[int, ...] | None, kind: str
|
|
1115
|
+
) -> bool:
|
|
1116
|
+
"""The legacy request-token gate, limited to the sink's dangerous
|
|
1117
|
+
argument(s) when it names them by position (#375). Falls back to the old
|
|
1118
|
+
span (paren to first `)`) for `all`/receiver/rest sinks, and whenever the
|
|
1119
|
+
positions can't be trusted: keyword or `*`/`**` arguments, a call text
|
|
1120
|
+
cut at the extraction cap, or one that doesn't parse."""
|
|
1121
|
+
whole = _TAINTED_RE.search(content, arg_lo, token_hi) is not None
|
|
1122
|
+
if args is None or any(index < 0 for index in args):
|
|
1123
|
+
return whole
|
|
1124
|
+
complete = content.startswith(")", arg_lo + len(arg_text))
|
|
1125
|
+
spans = _top_level_args(arg_text) if complete else None
|
|
1126
|
+
if spans is None:
|
|
1127
|
+
return whole
|
|
1128
|
+
stripped = []
|
|
1129
|
+
for lo, hi in spans:
|
|
1130
|
+
while lo < hi and arg_text[lo].isspace():
|
|
1131
|
+
lo += 1
|
|
1132
|
+
if arg_text.startswith("*", lo) or _KEYWORD_ARG_RE.match(arg_text, lo, hi):
|
|
1133
|
+
return whole
|
|
1134
|
+
stripped.append((lo, hi))
|
|
1135
|
+
for index in args:
|
|
1136
|
+
if not 0 <= index < len(stripped):
|
|
1137
|
+
continue
|
|
1138
|
+
lo, hi = stripped[index]
|
|
1139
|
+
if kind == "open_redirect" and _INTERNAL_URL_CALL_RE.match(arg_text, lo, hi):
|
|
1140
|
+
continue
|
|
1141
|
+
if _TAINTED_RE.search(arg_text, lo, hi) is not None:
|
|
1142
|
+
return True
|
|
1143
|
+
return False
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def _extract_call_arg(content: str, open_paren: int, cap: int = 400) -> str:
|
|
1147
|
+
"""Return the text inside the balanced parens starting at ``open_paren``
|
|
1148
|
+
(the index of the ``(``), bounded to ``cap`` chars."""
|
|
1149
|
+
depth = 0
|
|
1150
|
+
end = min(len(content), open_paren + cap)
|
|
1151
|
+
for i in range(open_paren, end):
|
|
1152
|
+
ch = content[i]
|
|
1153
|
+
if ch == "(":
|
|
1154
|
+
depth += 1
|
|
1155
|
+
elif ch == ")":
|
|
1156
|
+
depth -= 1
|
|
1157
|
+
if depth == 0:
|
|
1158
|
+
return content[open_paren + 1 : i]
|
|
1159
|
+
return content[open_paren + 1 : end]
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _is_static_local_arg(content: str, match: re.Match[str]) -> bool:
|
|
1163
|
+
"""True if the sink call's argument is provably static/local — a string
|
|
1164
|
+
literal or a literal-path file read — with no dynamic/attacker component."""
|
|
1165
|
+
paren = content.find("(", match.start())
|
|
1166
|
+
if paren == -1:
|
|
1167
|
+
return False
|
|
1168
|
+
arg = _extract_call_arg(content, paren)
|
|
1169
|
+
if not _ARG_STRING_LITERAL.search(arg):
|
|
1170
|
+
return False # no literal → provenance unknown → keep flagging
|
|
1171
|
+
# Strip quoted spans before the dynamic check so a `+`/`${`/token that is
|
|
1172
|
+
# DATA inside a literal (e.g. `eval('1 + 1')`, `yaml.load('a: b+c')`)
|
|
1173
|
+
# doesn't read as concatenation/interpolation of the argument.
|
|
1174
|
+
residual = _ARG_STRING_LITERAL.sub("", arg)
|
|
1175
|
+
return _ARG_DYNAMIC.search(residual) is None
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
# Placeholder markers that indicate a parameterized query (bind params rather
|
|
1179
|
+
# than string interpolation): %s / %(name)s, $1, :name, ?.
|
|
1180
|
+
_SQL_PLACEHOLDER_RE = re.compile(r"%\(?\w*\)?[sd]|\$\d+|:[A-Za-z_]\w*|(?<!\w)\?(?!\w)")
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def _is_parameterized_sql(content: str, match: re.Match[str], suffix: str = "") -> bool:
|
|
1184
|
+
"""True if a `sql_execute` call is a *safe* parameterized query rather than a
|
|
1185
|
+
raw string-built one — so we don't flag it as an injection sink (#101)."""
|
|
1186
|
+
paren = content.find("(", match.start())
|
|
1187
|
+
if paren == -1:
|
|
1188
|
+
return False
|
|
1189
|
+
args = _extract_call_arg(content, paren)
|
|
1190
|
+
stripped = args.strip()
|
|
1191
|
+
if not stripped:
|
|
1192
|
+
return True # builder terminal, e.g. Kysely `.execute()`
|
|
1193
|
+
if suffix == ".php":
|
|
1194
|
+
# PHP-tailored (#103): flag only on evidence of DYNAMIC query building —
|
|
1195
|
+
# `.` concatenation, double-quote `"…$var…"` interpolation, or sprintf.
|
|
1196
|
+
# Avoids the `+`/comma heuristic's traps here (`.` concat, mysqli's
|
|
1197
|
+
# ($conn, $sql) arg order). A bare `$db->query($sql)` is suppressed —
|
|
1198
|
+
# provenance is unknown and flagging every ORM call is noise.
|
|
1199
|
+
if re.search(r'["\']\s*\.|\.\s*\$|\bsprintf\s*\(', args) or re.search(r'"[^"]*\$', args):
|
|
1200
|
+
return False
|
|
1201
|
+
return True
|
|
1202
|
+
# Interpolation OUTSIDE string literals ⇒ raw/dynamic ⇒ NOT safe.
|
|
1203
|
+
if "${" in args or re.search(r"\bf['\"]", args): # template literal / f-string
|
|
1204
|
+
return False
|
|
1205
|
+
no_str = re.sub(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"", "", args) # drop quoted spans
|
|
1206
|
+
# concat / .format() / %-format / Go fmt.Sprintf — string building, not binding.
|
|
1207
|
+
if re.search(r"\+|\.format\s*\(|%\s*[(\w]|\bSprintf\b|\bfmt\.", no_str):
|
|
1208
|
+
return False
|
|
1209
|
+
no_tmpl = re.sub(r"`[^`]*`", "", no_str) # drop (now interp-free) template literals
|
|
1210
|
+
if "," in no_tmpl:
|
|
1211
|
+
return True # query + params bind
|
|
1212
|
+
return _SQL_PLACEHOLDER_RE.search(args) is not None
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
@dataclass
|
|
1216
|
+
class _SinkHit:
|
|
1217
|
+
kind: str
|
|
1218
|
+
line: int
|
|
1219
|
+
snippet: str
|
|
1220
|
+
# Neutralizer label: flow-bound when `flow` is known, else the file-level
|
|
1221
|
+
# fallback (#137).
|
|
1222
|
+
sanitizer: str | None
|
|
1223
|
+
# Recall only: kept because a gate was lifted (#148a/b) → speculative.
|
|
1224
|
+
relaxed: bool
|
|
1225
|
+
# Intra-procedural verdict for this call (#239); None when the enclosing
|
|
1226
|
+
# function couldn't be analyzed.
|
|
1227
|
+
flow: SinkFlow | None = None
|
|
1228
|
+
# The request gate was satisfied only by the traced flow (no same-call
|
|
1229
|
+
# request token) — such a hit belongs to the handler that encloses it.
|
|
1230
|
+
flow_only: bool = False
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def _call_paren(content: str, match: re.Match[str]) -> int:
|
|
1234
|
+
"""Offset of the sink call's opening paren: the one ending the match, the
|
|
1235
|
+
first inside it, or one right after it — else -1 (not a call)."""
|
|
1236
|
+
end = match.end()
|
|
1237
|
+
if end > match.start() and content[end - 1] == "(":
|
|
1238
|
+
return end - 1
|
|
1239
|
+
inside = content.find("(", match.start(), end)
|
|
1240
|
+
if inside != -1:
|
|
1241
|
+
return inside
|
|
1242
|
+
after = end
|
|
1243
|
+
while after < len(content) and content[after] in " \t":
|
|
1244
|
+
after += 1
|
|
1245
|
+
return after if after < len(content) and content[after] == "(" else -1
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def _line_end(content: str, offset: int) -> int:
|
|
1249
|
+
end = content.find("\n", offset)
|
|
1250
|
+
return len(content) if end == -1 else end
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
def _find_sinks(
|
|
1254
|
+
files: dict[str, Path], root: Path, recall: RecallConfig = DEFAULT_RECALL
|
|
1255
|
+
) -> dict[str, list[_SinkHit]]:
|
|
1256
|
+
"""rel_path -> sink hits.
|
|
1257
|
+
|
|
1258
|
+
Request-gated sinks fire on the legacy same-call request token or on an
|
|
1259
|
+
intra-procedural flow from a source into the dangerous argument (#239).
|
|
1260
|
+
Each hit carries that flow (source kind/line, steps) and a flow-bound
|
|
1261
|
+
sanitizer label; files whose functions can't be analyzed fall back to the
|
|
1262
|
+
file-granular sanitizer table. ``relaxed`` is True (recall only) when the
|
|
1263
|
+
hit was kept only because a gate was lifted — the static-literal
|
|
1264
|
+
suppression (#148a) or the request-token gate a capability-reach hit
|
|
1265
|
+
bypasses (#148b) — and the walk marks chains from such hits speculative.
|
|
1266
|
+
"""
|
|
1267
|
+
out: dict[str, list[_SinkHit]] = {}
|
|
1268
|
+
for rel, abs_path in files.items():
|
|
1269
|
+
try:
|
|
1270
|
+
content = abs_path.read_text(encoding="utf-8")
|
|
1271
|
+
except (OSError, UnicodeDecodeError):
|
|
1272
|
+
continue
|
|
1273
|
+
suffix = abs_path.suffix
|
|
1274
|
+
lang = lang_for_suffix(suffix)
|
|
1275
|
+
flows = FileFlow(content, suffix) # parsed lazily, once per file
|
|
1276
|
+
hits: list[_SinkHit] = []
|
|
1277
|
+
# Fallback per-file sanitizer lookup, memoized per sink kind.
|
|
1278
|
+
sanitizer_by_kind: dict[str, str | None] = {}
|
|
1279
|
+
# (kind, line) already emitted by the gated pass — so a capability-reach
|
|
1280
|
+
# hit never duplicates a request-derived sink at the same spot.
|
|
1281
|
+
emitted: set[tuple[str, int]] = set()
|
|
1282
|
+
for sink in all_sinks():
|
|
1283
|
+
if lang not in sink.langs:
|
|
1284
|
+
continue
|
|
1285
|
+
if sink.requires is not None and sink.requires.search(content) is None:
|
|
1286
|
+
continue
|
|
1287
|
+
kind = sink.kind
|
|
1288
|
+
for match in sink.pattern.finditer(content):
|
|
1289
|
+
# A "dangerous-regardless" sink whose argument is a static
|
|
1290
|
+
# literal / local-file read (#88 follow-up): the default pass
|
|
1291
|
+
# suppresses it; recall keeps it as a speculative lead.
|
|
1292
|
+
relaxed = False
|
|
1293
|
+
if kind in _STATIC_GATED_KINDS and _is_static_local_arg(content, match):
|
|
1294
|
+
if not recall.include_static_args:
|
|
1295
|
+
continue
|
|
1296
|
+
relaxed = True
|
|
1297
|
+
paren = _call_paren(content, match)
|
|
1298
|
+
# The call's argument text (or, for a statement sink, the rest
|
|
1299
|
+
# of the line) — what `when`/`unless` and the token gate read.
|
|
1300
|
+
if sink.args == REST or paren < 0:
|
|
1301
|
+
arg_lo, arg_hi = match.end(), _line_end(content, match.end())
|
|
1302
|
+
token_hi = arg_hi
|
|
1303
|
+
else:
|
|
1304
|
+
arg_lo = paren + 1
|
|
1305
|
+
arg_hi = arg_lo + len(_extract_call_arg(content, paren))
|
|
1306
|
+
first_close = content.find(")", arg_lo)
|
|
1307
|
+
token_hi = len(content) if first_close == -1 else first_close
|
|
1308
|
+
arg_text = content[arg_lo:arg_hi]
|
|
1309
|
+
if sink.when is not None and sink.when.search(arg_text) is None:
|
|
1310
|
+
continue
|
|
1311
|
+
if sink.unless is not None and sink.unless.search(arg_text) is not None:
|
|
1312
|
+
continue
|
|
1313
|
+
if sink.receiver_unless is not None and sink.receiver_unless.search(call_receiver(content, match.start())):
|
|
1314
|
+
continue
|
|
1315
|
+
snippet = _line_snippet(content, match.start())
|
|
1316
|
+
flow = flows.sink_flow(kind, paren, sink.args, snippet, match.start(), match.end())
|
|
1317
|
+
traced = flow is not None and (flow.tainted or flow.sanitized)
|
|
1318
|
+
flow_only = False
|
|
1319
|
+
if sink.gate == "tainted":
|
|
1320
|
+
# The pre-#239 same-call gate: a request-container token
|
|
1321
|
+
# between the call's paren and its first `)` — inside the
|
|
1322
|
+
# sink's dangerous argument(s) only (#375): a request
|
|
1323
|
+
# object passed as `getattr`'s object, or as the subject
|
|
1324
|
+
# of a fixed regex, isn't the injected value.
|
|
1325
|
+
legacy_ok = sink.token_gate and _token_in_dangerous_args(
|
|
1326
|
+
content, arg_lo, token_hi, arg_text, sink.args, kind
|
|
1327
|
+
)
|
|
1328
|
+
if not legacy_ok:
|
|
1329
|
+
if not traced:
|
|
1330
|
+
continue
|
|
1331
|
+
flow_only = True
|
|
1332
|
+
# Suppress parameterized SQL — bind params / builder / placeholders
|
|
1333
|
+
# only — vs. raw string-built queries (#101). A traced flow into the
|
|
1334
|
+
# query argument itself overrides it: `$db->query($sql)` with a
|
|
1335
|
+
# request-built `$sql` is injectable whatever the call looks like.
|
|
1336
|
+
if kind == "sql_execute" and not traced and _is_parameterized_sql(content, match, suffix):
|
|
1337
|
+
continue
|
|
1338
|
+
if flow is not None:
|
|
1339
|
+
sanitizer = flow.sanitizer if flow.sanitized else None
|
|
1340
|
+
else:
|
|
1341
|
+
if kind not in sanitizer_by_kind:
|
|
1342
|
+
sanitizer_by_kind[kind] = _find_sanitizer(kind, content)
|
|
1343
|
+
sanitizer = sanitizer_by_kind[kind]
|
|
1344
|
+
line = line_number(content, match.start())
|
|
1345
|
+
hits.append(_SinkHit(kind, line, snippet, sanitizer, relaxed, flow, flow_only))
|
|
1346
|
+
emitted.add((kind, line))
|
|
1347
|
+
|
|
1348
|
+
# Capability-reach pass (#148b, recall only): the bare-call form of the
|
|
1349
|
+
# request-gated kinds, surfaced even without a request token. Always
|
|
1350
|
+
# relaxed → speculative; skipped where the gated pass already fired.
|
|
1351
|
+
# These carry NO sanitizer label: sanitizer status describes a tainted
|
|
1352
|
+
# flow being neutralized, but a capability-reach hit asserts no taint —
|
|
1353
|
+
# a file-level sanitizer token must not mark it sanitized (which would
|
|
1354
|
+
# make generate_findings drop it and silently lose the capability
|
|
1355
|
+
# inventory this pass exists to produce).
|
|
1356
|
+
if recall.capability_reach and lang in _LEGACY_LANGS:
|
|
1357
|
+
for kind, pattern in _CAPABILITY_PATTERNS:
|
|
1358
|
+
for match in pattern.finditer(content):
|
|
1359
|
+
line = line_number(content, match.start())
|
|
1360
|
+
if (kind, line) in emitted:
|
|
1361
|
+
continue
|
|
1362
|
+
snippet = _line_snippet(content, match.start())
|
|
1363
|
+
hits.append(_SinkHit(kind, line, snippet, None, True))
|
|
1364
|
+
emitted.add((kind, line))
|
|
1365
|
+
if hits:
|
|
1366
|
+
out[rel] = hits
|
|
1367
|
+
return out
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
def _line_snippet(content: str, offset: int, radius: int = 80) -> str:
|
|
1371
|
+
start = max(0, content.rfind("\n", 0, offset) + 1)
|
|
1372
|
+
end = content.find("\n", offset)
|
|
1373
|
+
if end == -1:
|
|
1374
|
+
end = len(content)
|
|
1375
|
+
line = content[start:end].strip()
|
|
1376
|
+
if len(line) > radius * 2:
|
|
1377
|
+
line = line[: radius * 2] + "…"
|
|
1378
|
+
return line
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
def _walk_from_route(
|
|
1382
|
+
route: Route,
|
|
1383
|
+
route_file: str,
|
|
1384
|
+
graph: dict[str, set[str]],
|
|
1385
|
+
sinks_by_file: dict[str, list[_SinkHit]],
|
|
1386
|
+
recall: RecallConfig = DEFAULT_RECALL,
|
|
1387
|
+
route_lines: list[int] | None = None,
|
|
1388
|
+
handler_span: list[tuple[int, int]] | None = None,
|
|
1389
|
+
) -> list[TaintChain]:
|
|
1390
|
+
"""BFS out from route_file, up to ``recall.max_hops``. Emit chains at each
|
|
1391
|
+
sink; tag as speculative any chain that only exists because a recall knob
|
|
1392
|
+
was widened — a relaxed-gate sink, or a sink in a file the *conservative*
|
|
1393
|
+
traversal (default hop depth + visit budget) would never have processed
|
|
1394
|
+
(#148a). Comparing against the default-reachable set covers both the deeper
|
|
1395
|
+
hops and the widened visit cap precisely — a fan-out-heavy graph can push a
|
|
1396
|
+
within-two-hops sink past the 40-file budget, and that reach is speculative
|
|
1397
|
+
too.
|
|
1398
|
+
|
|
1399
|
+
Same-file flows are attributed to the route whose handler encloses the
|
|
1400
|
+
sink (#239): when the sink sits in *another* route's handler, this route
|
|
1401
|
+
gets no flow (and no chain at all if the flow was the only gate)."""
|
|
1402
|
+
# The set of files the conservative pass would actually process — computed
|
|
1403
|
+
# only when a knob is widened (otherwise nothing is speculative and the
|
|
1404
|
+
# traversals are identical).
|
|
1405
|
+
default_reachable: set[str] | None = (
|
|
1406
|
+
_reachable_files(route_file, graph, _MAX_HOPS, _MAX_FILES_VISITED_PER_ROUTE)
|
|
1407
|
+
if recall.aggressive
|
|
1408
|
+
else None
|
|
1409
|
+
)
|
|
1410
|
+
route_lines = route_lines or []
|
|
1411
|
+
|
|
1412
|
+
visited: dict[str, int] = {route_file: 0}
|
|
1413
|
+
parents: dict[str, str] = {}
|
|
1414
|
+
queue: deque[str] = deque([route_file])
|
|
1415
|
+
chains: list[TaintChain] = []
|
|
1416
|
+
|
|
1417
|
+
while queue and len(visited) < recall.max_files_visited:
|
|
1418
|
+
current = queue.popleft()
|
|
1419
|
+
current_hops = visited[current]
|
|
1420
|
+
|
|
1421
|
+
for hit in sinks_by_file.get(current, []):
|
|
1422
|
+
flow = hit.flow
|
|
1423
|
+
if current_hops == 0 and handler_span is not None:
|
|
1424
|
+
# Seeded from the view a URLconf names (#372): that view's sinks,
|
|
1425
|
+
# and those of the same-module helpers and bases it reaches, are
|
|
1426
|
+
# this route's; the module's other views aren't.
|
|
1427
|
+
if not any(lo <= hit.line <= hi for lo, hi in handler_span):
|
|
1428
|
+
continue
|
|
1429
|
+
elif current_hops == 0 and flow is not None and flow.span is not None:
|
|
1430
|
+
lo, hi = flow.span
|
|
1431
|
+
in_handler = route.line is None or lo <= route.line <= hi
|
|
1432
|
+
other_handler = any(lo <= ln <= hi for ln in route_lines if ln != route.line)
|
|
1433
|
+
if not in_handler and other_handler:
|
|
1434
|
+
if hit.flow_only:
|
|
1435
|
+
continue # another route's input, not this one's
|
|
1436
|
+
flow = None
|
|
1437
|
+
confidence = _confidence_for_hops(current_hops)
|
|
1438
|
+
if flow is not None and flow.tainted:
|
|
1439
|
+
# A traced source→sink flow is stronger evidence than reach.
|
|
1440
|
+
confidence = round(min(0.9, confidence + 0.15), 2)
|
|
1441
|
+
if hit.sanitizer is not None:
|
|
1442
|
+
# A neutralizer is present at the sink — likely defended.
|
|
1443
|
+
# Downgrade well below the HIGH threshold, keep as evidence.
|
|
1444
|
+
confidence = round(confidence * 0.4, 2)
|
|
1445
|
+
# A relaxed-gate hit, or a sink the conservative pass would never
|
|
1446
|
+
# have reached, is a discovery lead — mark it and dock confidence so
|
|
1447
|
+
# it can't clear the HIGH bar until the verifier confirms it.
|
|
1448
|
+
speculative = hit.relaxed or (
|
|
1449
|
+
default_reachable is not None and current not in default_reachable
|
|
1450
|
+
)
|
|
1451
|
+
if speculative:
|
|
1452
|
+
confidence = round(confidence * 0.5, 2)
|
|
1453
|
+
traced = flow is not None and (flow.tainted or flow.sanitized)
|
|
1454
|
+
chains.append(
|
|
1455
|
+
TaintChain(
|
|
1456
|
+
route_path=route.path,
|
|
1457
|
+
route_method=route.method,
|
|
1458
|
+
route_file=route_file,
|
|
1459
|
+
sink_kind=hit.kind, # type: ignore[arg-type]
|
|
1460
|
+
sink_file=current,
|
|
1461
|
+
sink_line=hit.line,
|
|
1462
|
+
hops=current_hops,
|
|
1463
|
+
files=_reconstruct_path(route_file, current, parents),
|
|
1464
|
+
evidence_text=hit.snippet,
|
|
1465
|
+
confidence=confidence,
|
|
1466
|
+
sanitized=hit.sanitizer is not None,
|
|
1467
|
+
sanitizer_evidence=hit.sanitizer,
|
|
1468
|
+
speculative=speculative,
|
|
1469
|
+
source_kind=flow.source_kind if traced and flow is not None else None,
|
|
1470
|
+
source_line=flow.source_line if traced and flow is not None else None,
|
|
1471
|
+
flow=(
|
|
1472
|
+
[
|
|
1473
|
+
TaintFlowStep(file=current, line=ln, kind=k, evidence_text=note) # type: ignore[arg-type]
|
|
1474
|
+
for ln, k, note in flow.steps
|
|
1475
|
+
]
|
|
1476
|
+
if traced and flow is not None
|
|
1477
|
+
else []
|
|
1478
|
+
),
|
|
1479
|
+
)
|
|
1480
|
+
)
|
|
1481
|
+
|
|
1482
|
+
if current_hops >= recall.max_hops:
|
|
1483
|
+
continue
|
|
1484
|
+
# Sorted: `graph` values are sets, and the visit cap decides which
|
|
1485
|
+
# files are reached, so set order would change the chains per run (#353).
|
|
1486
|
+
for neighbor in sorted(graph.get(current, ())):
|
|
1487
|
+
if neighbor in visited:
|
|
1488
|
+
continue
|
|
1489
|
+
visited[neighbor] = current_hops + 1
|
|
1490
|
+
parents[neighbor] = current
|
|
1491
|
+
queue.append(neighbor)
|
|
1492
|
+
return chains
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
def _reachable_files(
|
|
1496
|
+
start: str, graph: dict[str, set[str]], max_hops: int, max_files: int
|
|
1497
|
+
) -> set[str]:
|
|
1498
|
+
"""The set of files a BFS bounded by ``max_hops`` / ``max_files`` would
|
|
1499
|
+
*process* (pop) from ``start``. Mirrors ``_walk_from_route``'s control flow
|
|
1500
|
+
exactly so it is a faithful model of what the conservative pass reaches —
|
|
1501
|
+
used to decide which recall discoveries are speculative (#148a)."""
|
|
1502
|
+
visited: dict[str, int] = {start: 0}
|
|
1503
|
+
queue: deque[str] = deque([start])
|
|
1504
|
+
processed: set[str] = set()
|
|
1505
|
+
while queue and len(visited) < max_files:
|
|
1506
|
+
current = queue.popleft()
|
|
1507
|
+
processed.add(current)
|
|
1508
|
+
if visited[current] >= max_hops:
|
|
1509
|
+
continue
|
|
1510
|
+
for neighbor in sorted(graph.get(current, ())): # same order as _walk_from_route
|
|
1511
|
+
if neighbor in visited:
|
|
1512
|
+
continue
|
|
1513
|
+
visited[neighbor] = visited[current] + 1
|
|
1514
|
+
queue.append(neighbor)
|
|
1515
|
+
return processed
|
|
1516
|
+
|
|
1517
|
+
|
|
1518
|
+
def _reconstruct_path(start: str, end: str, parents: dict[str, str]) -> list[str]:
|
|
1519
|
+
if start == end:
|
|
1520
|
+
return [start]
|
|
1521
|
+
chain: list[str] = [end]
|
|
1522
|
+
cursor = end
|
|
1523
|
+
while cursor in parents:
|
|
1524
|
+
cursor = parents[cursor]
|
|
1525
|
+
chain.append(cursor)
|
|
1526
|
+
if cursor == start:
|
|
1527
|
+
break
|
|
1528
|
+
chain.reverse()
|
|
1529
|
+
return chain
|
|
1530
|
+
|
|
1531
|
+
|
|
1532
|
+
def _confidence_for_hops(hops: int) -> float:
|
|
1533
|
+
# Same-file sinks are already caught by the file-locality heuristic;
|
|
1534
|
+
# weight taint contribution slightly lower for 0-hop and taper further
|
|
1535
|
+
# for transitive edges — import != call.
|
|
1536
|
+
if hops == 0:
|
|
1537
|
+
return 0.65
|
|
1538
|
+
if hops == 1:
|
|
1539
|
+
return 0.60
|
|
1540
|
+
return 0.50
|