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
@@ -0,0 +1,396 @@
1
+ """Per-language taint source, sanitizer and guard catalogs (#239).
2
+
3
+ The intra-procedural flow pass (:mod:`attackmap.taint_flow`) marks a local
4
+ variable tainted when the right-hand side of its assignment matches one of the
5
+ **sources** below, then follows simple assignments to the sink. A
6
+ **sanitizer** neutralizes the value only when it wraps the tainted value on
7
+ the way to the sink (``u = secure_filename(u)``), and a **guard** only when it
8
+ is an early-exit (or enclosing) check on a variable derived from the same
9
+ source (``if ip.is_private: abort(400)``). Nothing here is file-granular.
10
+
11
+ Every entry is a ``(label, compiled-regex[, weight])`` tuple so that
12
+ ``tests/test_regex_budget.py`` auto-discovers and budget-checks each pattern.
13
+
14
+ Source weights:
15
+
16
+ - ``"high"`` — attacker-controlled request input (query, body, path params,
17
+ headers, cookies, uploaded filenames, queue/event payloads). Satisfies the
18
+ request-taint gate on SSRF / SSTI / path / redirect / NoSQL sinks.
19
+ - ``"low"`` — process environment and argv. Recorded on a chain as its source
20
+ when nothing stronger reaches the sink, but never satisfies the gate: an
21
+ operator-set env var is not attacker input for a web route.
22
+
23
+ To extend: add a tuple under the language. Keep patterns specific (a member
24
+ access on a request-shaped receiver, not a bare identifier) and linear — no
25
+ nested quantifiers.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+
32
+ # Language keys used throughout the flow pass.
33
+ PY, JS, GO, PHP, JAVA = "python", "js", "go", "php", "java"
34
+ # Indexed for sinks (#240); C# also gets flow analysis, Ruby only the
35
+ # same-call request-token gate (no brace-delimited functions to scope).
36
+ CSHARP, RUBY = "csharp", "ruby"
37
+
38
+ _LANG_BY_SUFFIX = {
39
+ ".py": PY,
40
+ ".js": JS, ".jsx": JS, ".mjs": JS, ".cjs": JS, ".ts": JS, ".tsx": JS, ".mts": JS, ".cts": JS,
41
+ ".go": GO,
42
+ ".php": PHP,
43
+ ".java": JAVA,
44
+ ".cs": CSHARP,
45
+ ".rb": RUBY,
46
+ }
47
+
48
+
49
+ def lang_for_suffix(suffix: str) -> str | None:
50
+ """The flow-pass language for a file suffix, or None if unsupported."""
51
+ return _LANG_BY_SUFFIX.get(suffix.lower())
52
+
53
+
54
+ # --- Sources -----------------------------------------------------------------
55
+ # (kind, pattern, weight). Python patterns are applied with ``re.match`` to the
56
+ # source text of an expression node (so they are anchored at the expression
57
+ # start); the other languages are searched in statement text with string
58
+ # literals blanked out.
59
+
60
+ _AW = r"(?:await\s+)?"
61
+
62
+ SOURCES: dict[str, tuple[tuple[str, re.Pattern[str], str], ...]] = {
63
+ PY: (
64
+ ("query", re.compile(_AW + r"request\.(?:args|GET|query_params|query_string|values)\b"), "high"),
65
+ ("body", re.compile(_AW + r"request\.(?:form|json|data|POST|body|get_json|get_data|stream|media)\b"), "high"),
66
+ ("upload_filename", re.compile(_AW + r"request\.(?:files|FILES)\b"), "high"),
67
+ ("header", re.compile(_AW + r"request\.(?:headers|META|environ)\b"), "high"),
68
+ ("cookie", re.compile(_AW + r"request\.(?:cookies|COOKIES)\b"), "high"),
69
+ ("path_param", re.compile(_AW + r"request\.(?:view_args|path_params|match_info|path|full_path|url)\b"), "high"),
70
+ # aiohttp / falcon / starlette style `req`
71
+ ("query", re.compile(_AW + r"req\.(?:params|query|query_params|get_param\w*|rel_url)\b"), "high"),
72
+ ("body", re.compile(_AW + r"req\.(?:media|json|text|post|content|stream|form)\b"), "high"),
73
+ ("header", re.compile(_AW + r"req\.(?:headers|get_header)\b"), "high"),
74
+ ("cookie", re.compile(_AW + r"req\.cookies\b"), "high"),
75
+ ("path_param", re.compile(_AW + r"req\.(?:match_info|path_params)\b"), "high"),
76
+ # AWS Lambda / API Gateway events and queue messages
77
+ (
78
+ "body",
79
+ re.compile(
80
+ r"event\s*(?:\[\s*|\.get\s*\(\s*)[\"'](?:body|queryStringParameters|pathParameters|headers|multiValueQueryStringParameters)[\"']"
81
+ ),
82
+ "high",
83
+ ),
84
+ ("message", re.compile(r"(?:message|msg|record)\s*\[\s*[\"'](?:body|data|payload|value)[\"']"), "high"),
85
+ ("message", re.compile(r"(?:message|msg)\.(?:value|body|data|payload)\b"), "high"),
86
+ ("env", re.compile(r"os\.(?:environ|getenv)\b"), "low"),
87
+ ("argv", re.compile(r"sys\.argv\b"), "low"),
88
+ ),
89
+ JS: (
90
+ ("query", re.compile(r"\breq(?:uest)?\s*\.\s*query\b"), "high"),
91
+ ("query", re.compile(r"\bctx\s*\.\s*(?:request\s*\.\s*)?query(?:string)?\b"), "high"),
92
+ ("query", re.compile(r"\bsearchParams\s*\.\s*get(?:All)?\s*\("), "high"),
93
+ ("body", re.compile(r"\breq(?:uest)?\s*\.\s*body\b"), "high"),
94
+ ("body", re.compile(r"\bctx\s*\.\s*request\s*\.\s*body\b"), "high"),
95
+ ("body", re.compile(r"\bawait\s+req(?:uest)?\s*\.\s*(?:json|formData|text)\s*\("), "high"),
96
+ ("path_param", re.compile(r"\breq(?:uest)?\s*\.\s*params\b"), "high"),
97
+ ("path_param", re.compile(r"\bctx\s*\.\s*params\b"), "high"),
98
+ ("path_param", re.compile(r"\breq\s*\.\s*(?:path|originalUrl|url)\b"), "high"),
99
+ ("header", re.compile(r"\breq(?:uest)?\s*\.\s*headers\b"), "high"),
100
+ ("header", re.compile(r"\breq\s*\.\s*(?:get|header)\s*\("), "high"),
101
+ ("header", re.compile(r"\bctx\s*\.\s*(?:headers|get\s*\()"), "high"),
102
+ ("cookie", re.compile(r"\breq(?:uest)?\s*\.\s*(?:cookies|signedCookies)\b"), "high"),
103
+ ("cookie", re.compile(r"\bctx\s*\.\s*cookies\s*\.\s*get\s*\("), "high"),
104
+ ("upload_filename", re.compile(r"\breq\s*\.\s*files?\b"), "high"),
105
+ ("message", re.compile(r"\b(?:message|msg)\s*\.\s*(?:value|body|content)\b"), "high"),
106
+ ("env", re.compile(r"\bprocess\s*\.\s*env\b"), "low"),
107
+ ("argv", re.compile(r"\bprocess\s*\.\s*argv\b"), "low"),
108
+ ),
109
+ GO: (
110
+ ("query", re.compile(r"\b\w+\.URL\.(?:Query\s*\(\s*\)|RawQuery)"), "high"),
111
+ ("query", re.compile(r"\b(?:c|ctx)\.(?:Query|DefaultQuery|QueryParam|QueryArray|GetQuery)\s*\("), "high"),
112
+ ("body", re.compile(r"\b\w+\.(?:FormValue|PostFormValue)\s*\("), "high"),
113
+ ("body", re.compile(r"\b(?:r|req|request)\.(?:Body|Form|PostForm|MultipartForm)\b"), "high"),
114
+ ("body", re.compile(r"\b(?:c|ctx)\.(?:PostForm|DefaultPostForm|GetRawData|Body)\s*\("), "high"),
115
+ ("path_param", re.compile(r"\bmux\.Vars\s*\("), "high"),
116
+ ("path_param", re.compile(r"\bchi\.URLParam\s*\("), "high"),
117
+ ("path_param", re.compile(r"\b(?:c|ctx)\.(?:Param|Params)\s*\("), "high"),
118
+ ("path_param", re.compile(r"\b(?:r|req|request)\.(?:URL\.Path|RequestURI|PathValue\s*\()"), "high"),
119
+ ("header", re.compile(r"\b(?:r|req|request)\.Header\.(?:Get|Values)\s*\("), "high"),
120
+ ("header", re.compile(r"\b(?:c|ctx)\.(?:GetHeader|Get)\s*\("), "high"),
121
+ ("cookie", re.compile(r"\b(?:r|req|request)\.Cookies?\s*\("), "high"),
122
+ ("cookie", re.compile(r"\b(?:c|ctx)\.Cookie\s*\("), "high"),
123
+ ("upload_filename", re.compile(r"\b\w+\.FormFile\s*\("), "high"),
124
+ ("env", re.compile(r"\bos\.(?:Getenv|LookupEnv|Environ)\s*\("), "low"),
125
+ ("argv", re.compile(r"\bos\.Args\b|\bflag\.(?:Arg|Args)\s*\("), "low"),
126
+ ),
127
+ PHP: (
128
+ ("query", re.compile(r"\$_GET\b"), "high"),
129
+ ("body", re.compile(r"\$_(?:POST|REQUEST)\b"), "high"),
130
+ ("body", re.compile(r"php://input"), "high"),
131
+ ("cookie", re.compile(r"\$_COOKIE\b"), "high"),
132
+ ("upload_filename", re.compile(r"\$_FILES\b"), "high"),
133
+ ("header", re.compile(r"\$_SERVER\s*\[\s*['\"](?:HTTP_\w+|REQUEST_URI|QUERY_STRING|PATH_INFO|PHP_SELF)['\"]"), "high"),
134
+ # Laravel / Symfony request objects
135
+ (
136
+ "body",
137
+ re.compile(r"\$request\s*->\s*(?:input|get|post|all|only|json|getContent|validated)\s*\("),
138
+ "high",
139
+ ),
140
+ ("query", re.compile(r"\$request\s*->\s*(?:query)\b"), "high"),
141
+ ("header", re.compile(r"\$request\s*->\s*(?:header|headers)\b"), "high"),
142
+ ("cookie", re.compile(r"\$request\s*->\s*(?:cookie|cookies)\b"), "high"),
143
+ ("path_param", re.compile(r"\$request\s*->\s*(?:route|attributes|getPathInfo|getRequestUri)\b"), "high"),
144
+ ("upload_filename", re.compile(r"\$request\s*->\s*(?:file|files)\b"), "high"),
145
+ ("body", re.compile(r"\$request\s*->\s*request\s*->\s*get\s*\("), "high"),
146
+ ("env", re.compile(r"\bgetenv\s*\(|\$_ENV\b"), "low"),
147
+ ("argv", re.compile(r"\$argv\b"), "low"),
148
+ ),
149
+ JAVA: (
150
+ (
151
+ "query",
152
+ re.compile(r"\b\w+\s*\.\s*(?:getParameter|getParameterValues|getParameterMap|getQueryString)\s*\("),
153
+ "high",
154
+ ),
155
+ (
156
+ "body",
157
+ re.compile(
158
+ r"\b(?:request|req|httpRequest|servletRequest|httpServletRequest)\s*\.\s*"
159
+ r"(?:getInputStream|getReader|getPart|getParts)\s*\("
160
+ ),
161
+ "high",
162
+ ),
163
+ ("path_param", re.compile(r"\b\w+\s*\.\s*(?:getRequestURI|getPathInfo|getServletPath)\s*\("), "high"),
164
+ (
165
+ "header",
166
+ re.compile(r"\b(?:request|req|httpRequest|servletRequest|httpServletRequest)\s*\.\s*getHeaders?\s*\("),
167
+ "high",
168
+ ),
169
+ ("cookie", re.compile(r"\b\w+\s*\.\s*getCookies\s*\(\s*\)"), "high"),
170
+ ("env", re.compile(r"\bSystem\s*\.\s*(?:getenv|getProperty)\s*\("), "low"),
171
+ ),
172
+ CSHARP: (
173
+ ("query", re.compile(r"\bRequest\s*\.\s*(?:Query|QueryString)\b"), "high"),
174
+ ("body", re.compile(r"\bRequest\s*\.\s*(?:Form|Body|ReadFromJsonAsync)\b"), "high"),
175
+ ("header", re.compile(r"\bRequest\s*\.\s*Headers\b"), "high"),
176
+ ("cookie", re.compile(r"\bRequest\s*\.\s*Cookies\b"), "high"),
177
+ ("path_param", re.compile(r"\bRequest\s*\.\s*(?:RouteValues|Path)\b"), "high"),
178
+ ("env", re.compile(r"\bEnvironment\s*\.\s*GetEnvironmentVariable\s*\("), "low"),
179
+ ),
180
+ }
181
+
182
+ # Handler parameters bound from the request by a framework annotation /
183
+ # decorator (Spring, JAX-RS, NestJS). The parameter name follows the
184
+ # annotation; the flow pass extracts it procedurally.
185
+ PARAM_ANNOTATIONS: dict[str, tuple[tuple[str, re.Pattern[str]], ...]] = {
186
+ JAVA: (
187
+ ("query", re.compile(r"@(?:RequestParam|QueryParam|MatrixParam)\b")),
188
+ ("path_param", re.compile(r"@(?:PathVariable|PathParam)\b")),
189
+ ("body", re.compile(r"@(?:RequestBody|ModelAttribute|RequestPart|FormParam|BeanParam)\b")),
190
+ ("header", re.compile(r"@(?:RequestHeader|HeaderParam)\b")),
191
+ ("cookie", re.compile(r"@(?:CookieValue|CookieParam)\b")),
192
+ ),
193
+ CSHARP: (
194
+ ("query", re.compile(r"\[\s*FromQuery\b")),
195
+ ("path_param", re.compile(r"\[\s*FromRoute\b")),
196
+ ("body", re.compile(r"\[\s*From(?:Body|Form)\b")),
197
+ ("header", re.compile(r"\[\s*FromHeader\b")),
198
+ ),
199
+ JS: (
200
+ ("query", re.compile(r"@Query\s*\(")),
201
+ ("path_param", re.compile(r"@Param\s*\(")),
202
+ ("body", re.compile(r"@Body\s*\(")),
203
+ ("header", re.compile(r"@Headers\s*\(")),
204
+ ("upload_filename", re.compile(r"@UploadedFiles?\s*\(")),
205
+ ),
206
+ }
207
+
208
+ # Python route decorators (the decorator call's final attribute name, matched
209
+ # on the AST) — a decorated function is a request handler and its
210
+ # (non-framework) parameters are request-bound: path params, FastAPI query /
211
+ # body params.
212
+ PY_ROUTE_DECORATOR_NAMES = frozenset(
213
+ {"route", "get", "post", "put", "patch", "delete", "head", "options", "api_route", "websocket"}
214
+ )
215
+ # Parameters that are framework plumbing, not attacker data.
216
+ PY_PLUMBING_PARAMS = frozenset(
217
+ {"self", "cls", "request", "req", "db", "session", "response", "background_tasks", "current_user", "user"}
218
+ )
219
+ # Annotations that coerce (int / UUID / date) or mark plumbing — such a
220
+ # parameter is not an injectable string.
221
+ PY_SAFE_ANNOTATION = re.compile(
222
+ r"\b(?:int|float|bool|UUID|uuid\.UUID|datetime|date|time|Decimal|PositiveInt|NonNegativeInt|conint|"
223
+ r"Request|Response|Session|AsyncSession|BackgroundTasks|WebSocket|HTTPConnection|Depends|Security)\b"
224
+ )
225
+ PY_SAFE_DEFAULT = re.compile(r"\b(?:Depends|Security)\s*\(")
226
+ PY_SCALAR_ANNOTATION = re.compile(r"\b(?:str|list|List|Optional|Query|Path)\b")
227
+
228
+
229
+ # --- Sanitizers (flow-bound wrappers) ----------------------------------------
230
+ # A sanitizer counts only when the tainted value passes *through* the call on
231
+ # the way to the sink: ``safe = shlex.quote(host)`` then ``run(... safe ...)``,
232
+ # or the wrapper inline in the sink argument. Each pattern matches the callee
233
+ # name up to (and including) its opening paren.
234
+
235
+ SANITIZERS: dict[str, tuple[tuple[str, re.Pattern[str]], ...]] = {
236
+ "subprocess_shell": (
237
+ ("shlex.quote", re.compile(r"\bshlex\.quote\s*\(")),
238
+ ("pipes.quote", re.compile(r"\bpipes\.quote\s*\(")),
239
+ ("escapeshellarg/escapeshellcmd", re.compile(r"\b(?:escapeshellarg|escapeshellcmd)\s*\(")),
240
+ ("shell-escape", re.compile(r"\b(?:shellEscape|shellescape|shellQuote\.quote)\s*\(")),
241
+ ),
242
+ "sql_execute": (
243
+ ("mysqli_real_escape_string", re.compile(r"\bmysqli_real_escape_string\s*\(")),
244
+ ("pg_escape_*", re.compile(r"\bpg_escape_(?:string|literal|identifier)\s*\(")),
245
+ ("PDO::quote", re.compile(r"->\s*quote\s*\(")),
246
+ ("sql escape()", re.compile(r"\b(?:mysql|connection|conn|pool|SqlString)\s*\.\s*escape(?:Id)?\s*\(")),
247
+ ),
248
+ "dynamic_open": (
249
+ ("werkzeug.secure_filename", re.compile(r"\bsecure_filename\s*\(")),
250
+ ("os.path.basename", re.compile(r"\bos\.path\.basename\s*\(")),
251
+ ("path.basename", re.compile(r"\bpath\s*\.\s*basename\s*\(")),
252
+ ("filepath.Base", re.compile(r"\bfilepath\.Base\s*\(")),
253
+ ("basename()", re.compile(r"(?<![\w.>$])basename\s*\(")),
254
+ ("FilenameUtils.getName", re.compile(r"\bFilenameUtils\s*\.\s*getName\s*\(")),
255
+ ),
256
+ # Only a real escaper of the template *source*. `re.escape` / `html.escape`
257
+ # are deliberately absent (#239): neither neutralizes `{{ ... }}`, and their
258
+ # presence elsewhere in a file used to silence real SSTI chains.
259
+ "ssti": (
260
+ ("markupsafe.escape", re.compile(r"\b(?:markupsafe|Markup)\.escape\s*\(|(?<![\w.])escape\s*\(")),
261
+ ("bleach.clean", re.compile(r"\bbleach\.clean\s*\(")),
262
+ ),
263
+ "nosql_injection": (
264
+ ("mongo-sanitize", re.compile(r"\b(?:mongoSanitize|mongo_sanitize|sanitize)\s*\(")),
265
+ ),
266
+ "open_redirect": (
267
+ ("url_for", re.compile(r"\burl_for\s*\(")),
268
+ ),
269
+ # #240 kinds
270
+ "regex_injection": (
271
+ ("regex escape", re.compile(
272
+ r"\bre\.escape\s*\(|\b(?:_|lodash)\.escapeRegExp\s*\(|\bescapeStringRegexp\s*\(|\bescapeRegExp\s*\("
273
+ r"|\bPattern\s*\.\s*quote\s*\(|\bregexp\.QuoteMeta\s*\(|\bpreg_quote\s*\("
274
+ )),
275
+ ),
276
+ "ldap_injection": (
277
+ ("LDAP filter escape", re.compile(
278
+ r"\bescape_filter_chars\s*\(|\bldap_escape\s*\(|\bLdapEncoder\s*\.\s*filterEncode\s*\("
279
+ r"|\bEncode\s*\.\s*forLdap\s*\(|\bescape_filter\s*\("
280
+ )),
281
+ ),
282
+ "xpath_injection": (
283
+ ("XPath escape", re.compile(r"\bEncode\s*\.\s*forXPath\s*\(|\bxpath_escape\s*\(")),
284
+ ),
285
+ "header_injection": (
286
+ ("URL-encode", re.compile(r"\b(?:encodeURIComponent|urllib\.parse\.quote|quote|rawurlencode|urlencode|url\.QueryEscape|URLEncoder\s*\.\s*encode)\s*\(")),
287
+ ),
288
+ }
289
+ SANITIZERS["path_traversal"] = SANITIZERS["dynamic_open"]
290
+
291
+ # Type coercions neutralize every string-injection kind: an int can't carry
292
+ # a payload. Applied for all sink kinds.
293
+ CAST_SANITIZERS: tuple[tuple[str, re.Pattern[str]], ...] = (
294
+ ("int() cast", re.compile(r"(?<![\w.])(?:int|float|bool)\s*\(")),
295
+ ("UUID cast", re.compile(r"\b(?:uuid\.)?UUID\s*\(|\bUUID\s*\.\s*fromString\s*\(")),
296
+ (
297
+ "Java numeric parse",
298
+ re.compile(r"\b(?:Integer|Long|Short|Double|Float)\s*\.\s*(?:parseInt|parseLong|parseShort|parseDouble|parseFloat|valueOf)\s*\("),
299
+ ),
300
+ ("parseInt/Number cast", re.compile(r"\b(?:parseInt|parseFloat|Number)\s*\(")),
301
+ ("strconv cast", re.compile(r"\bstrconv\.(?:Atoi|ParseInt|ParseUint|ParseFloat|ParseBool)\s*\(")),
302
+ ("intval cast", re.compile(r"\b(?:intval|floatval|boolval)\s*\(|\(\s*(?:int|float)\s*\)\s*\$")),
303
+ )
304
+
305
+
306
+ # --- Guards (flow-bound checks dominating the sink) --------------------------
307
+ # A guard neutralizes when its condition references a variable derived from
308
+ # the same source as the sink argument AND it dominates the sink: either an
309
+ # early exit (`if <cond>: abort/return/raise`) before the sink, or an `if`
310
+ # whose body contains the sink.
311
+
312
+ GUARDS: dict[str, tuple[tuple[str, re.Pattern[str]], ...]] = {
313
+ # SSRF needs an actual address-class or allow-list decision — the mere
314
+ # presence of `ipaddress.ip_address(` (often just logging) is not enough.
315
+ "ssrf": (
316
+ (
317
+ "private/loopback/link-local address check",
318
+ re.compile(
319
+ r"\.is_(?:private|loopback|link_local|reserved|multicast|unspecified|global)\b"
320
+ r"|\.Is(?:Private|Loopback|LinkLocalUnicast|LinkLocalMulticast|Unspecified|GlobalUnicast)\s*\("
321
+ r"|\.is(?:SiteLocal|Loopback|LinkLocal|AnyLocal)Address\s*\("
322
+ ),
323
+ ),
324
+ ("outbound URL allow-list", re.compile(r"\b(?:is_allowed_url|is_safe_url|isAllowedUrl|isSafeUrl)\s*\(")),
325
+ ),
326
+ "open_redirect": (
327
+ ("is_safe_url", re.compile(r"\bis_safe_url\s*\(")),
328
+ ("url_has_allowed_host_and_scheme", re.compile(r"\burl_has_allowed_host_and_scheme\s*\(")),
329
+ ("relative-path check", re.compile(r"\.startswith\s*\(\s*[\"']/[\"']|\.startsWith\s*\(\s*[\"']/[\"']")),
330
+ ),
331
+ "dynamic_open": (
332
+ (
333
+ "path containment check",
334
+ re.compile(r"\b(?:startswith|startsWith|HasPrefix|str_starts_with|is_relative_to|commonpath)\s*\("),
335
+ ),
336
+ ),
337
+ "subprocess_shell": (),
338
+ "sql_execute": (),
339
+ }
340
+ GUARDS["path_traversal"] = GUARDS["dynamic_open"]
341
+ GUARDS["zip_slip"] = GUARDS["dynamic_open"]
342
+
343
+ # Guards valid for every sink kind: an explicit allow-list membership test or a
344
+ # full-match regex validation of the value.
345
+ GENERIC_GUARDS: tuple[tuple[str, re.Pattern[str]], ...] = (
346
+ (
347
+ "allow-list check",
348
+ re.compile(
349
+ r"\b(?:ALLOWED_\w+|ALLOW_?LIST\w*|allowed_\w+|allowlist\w*|allowList\w*|whitelist\w*|"
350
+ r"allowedHosts|allowedDomains|allowedCommands|allowedFiles)\b"
351
+ ),
352
+ ),
353
+ (
354
+ "regex validation",
355
+ re.compile(r"\bre\.fullmatch\s*\(|\bpreg_match\s*\(|\.MatchString\s*\(|\.matches\s*\(\s*\"\^"),
356
+ ),
357
+ )
358
+
359
+ # Tokens that make a guard's branch an early exit.
360
+ EARLY_EXIT = re.compile(
361
+ r"\b(?:return|throw|raise|abort|exit|die|panic|continue|break)\b"
362
+ r"|\bhttp\.Error\s*\(|\.sendStatus\s*\(|\.AbortWith\w*\s*\(|\bHttpResponseBadRequest\b"
363
+ )
364
+
365
+
366
+ def sanitizer_patterns(kind: str) -> tuple[tuple[str, re.Pattern[str]], ...]:
367
+ """Flow-bound wrappers that neutralize ``kind`` (kind-specific + casts)."""
368
+ return SANITIZERS.get(kind, ()) + CAST_SANITIZERS
369
+
370
+
371
+ def guard_patterns(kind: str) -> tuple[tuple[str, re.Pattern[str]], ...]:
372
+ """Dominating checks that neutralize ``kind`` (kind-specific + generic)."""
373
+ return GUARDS.get(kind, ()) + GENERIC_GUARDS
374
+
375
+
376
+ ALL_SANITIZER_KINDS: tuple[str, ...] = (
377
+ "sql_execute",
378
+ "subprocess_shell",
379
+ "eval",
380
+ "exec",
381
+ "dynamic_open",
382
+ "unsafe_deserialization",
383
+ "ssti",
384
+ "ssrf",
385
+ "nosql_injection",
386
+ "open_redirect",
387
+ "path_traversal",
388
+ "zip_slip",
389
+ "code_injection",
390
+ "expression_injection",
391
+ "jndi_injection",
392
+ "ldap_injection",
393
+ "xpath_injection",
394
+ "header_injection",
395
+ "regex_injection",
396
+ )