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.
Files changed (91) hide show
  1. attackmap/__init__.py +2 -0
  2. attackmap/analyzer.py +449 -0
  3. attackmap/analyzer_contracts.py +194 -0
  4. attackmap/analyzers.py +1003 -0
  5. attackmap/anomalies.py +612 -0
  6. attackmap/asset_model.py +245 -0
  7. attackmap/attack_taxonomy.py +133 -0
  8. attackmap/auth_protocol.py +702 -0
  9. attackmap/authz.py +1695 -0
  10. attackmap/bench.py +467 -0
  11. attackmap/cli.py +1474 -0
  12. attackmap/config_scanner.py +389 -0
  13. attackmap/context_pack.py +82 -0
  14. attackmap/contracts.py +201 -0
  15. attackmap/control_model.py +280 -0
  16. attackmap/crossrepo.py +356 -0
  17. attackmap/crypto.py +240 -0
  18. attackmap/cve.py +717 -0
  19. attackmap/defensive_review.py +647 -0
  20. attackmap/detection_opportunities.py +1221 -0
  21. attackmap/detections/__init__.py +491 -0
  22. attackmap/detections/bola.yml +57 -0
  23. attackmap/detections/cloud.yml +609 -0
  24. attackmap/detections/injection.yml +167 -0
  25. attackmap/detections/secrets.yml +48 -0
  26. attackmap/detections/sigma.py +511 -0
  27. attackmap/detections/ssrf.yml +98 -0
  28. attackmap/detections/weakness.yml +62 -0
  29. attackmap/detections/webhook.yml +45 -0
  30. attackmap/detections/workflow.yml +121 -0
  31. attackmap/diagrams.py +237 -0
  32. attackmap/diff.py +301 -0
  33. attackmap/exploitability.py +493 -0
  34. attackmap/fingerprint.py +91 -0
  35. attackmap/fleet.py +370 -0
  36. attackmap/gitref.py +129 -0
  37. attackmap/graph.py +26 -0
  38. attackmap/handler_auth.py +639 -0
  39. attackmap/handler_span.py +62 -0
  40. attackmap/hunt_harness.py +505 -0
  41. attackmap/insights.py +701 -0
  42. attackmap/llm_review.py +846 -0
  43. attackmap/lockfiles.py +521 -0
  44. attackmap/mass_assignment.py +543 -0
  45. attackmap/md.py +175 -0
  46. attackmap/merge.py +333 -0
  47. attackmap/models.py +1284 -0
  48. attackmap/plugins_lock.py +114 -0
  49. attackmap/popular_packages.py +193 -0
  50. attackmap/progress.py +258 -0
  51. attackmap/recon_models.py +31 -0
  52. attackmap/recon_to_analysis.py +149 -0
  53. attackmap/redact.py +220 -0
  54. attackmap/report.py +424 -0
  55. attackmap/review_eval.py +335 -0
  56. attackmap/review_json.py +308 -0
  57. attackmap/review_prompts.py +796 -0
  58. attackmap/route_auth_fusion.py +474 -0
  59. attackmap/safe_fs.py +285 -0
  60. attackmap/sarif.py +373 -0
  61. attackmap/sbom.py +418 -0
  62. attackmap/scanner.py +1379 -0
  63. attackmap/sdk/__init__.py +278 -0
  64. attackmap/sdk/contracts.py +17 -0
  65. attackmap/sdk/fs.py +156 -0
  66. attackmap/sdk/models.py +59 -0
  67. attackmap/secrets_history.py +161 -0
  68. attackmap/security_overlay.py +71 -0
  69. attackmap/srcpaths.py +301 -0
  70. attackmap/suggest.py +394 -0
  71. attackmap/supply_chain.py +688 -0
  72. attackmap/suppress.py +740 -0
  73. attackmap/taint.py +1540 -0
  74. attackmap/taint_flow.py +1419 -0
  75. attackmap/taint_sinks.py +431 -0
  76. attackmap/taint_sinks.yaml +308 -0
  77. attackmap/taint_sources.py +396 -0
  78. attackmap/taxonomy.py +465 -0
  79. attackmap/threat_model.py +3441 -0
  80. attackmap/topology.py +316 -0
  81. attackmap/triage.py +151 -0
  82. attackmap/weaknesses.py +365 -0
  83. attackmap/webhardening.py +479 -0
  84. attackmap/webhook_verification.py +1067 -0
  85. attackmap/workflow_scanner.py +964 -0
  86. attackmap-0.6.4.dist-info/METADATA +226 -0
  87. attackmap-0.6.4.dist-info/RECORD +91 -0
  88. attackmap-0.6.4.dist-info/WHEEL +5 -0
  89. attackmap-0.6.4.dist-info/entry_points.txt +2 -0
  90. attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
  91. 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