dxa-research-toolkit 0.2.0__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.
dxa.py ADDED
@@ -0,0 +1,1124 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ dxa - a source-to-sink XSS analyzer for full-stack code.
4
+
5
+ A heuristic static linter that flags XSS *sources*, *sinks*, and the likely
6
+ *source -> sink* flows between them, on both sides of a web app:
7
+
8
+ * client side - JavaScript / TypeScript (DOM XSS)
9
+ * server side - C# / ASP.NET & Razor (server-rendered XSS)
10
+
11
+ It is the source->sink methodology documented in this repository, expressed as
12
+ code. For JavaScript it also runs a light taint pass so a variable assigned from
13
+ a source and later used in a sink is raised to HIGH confidence.
14
+
15
+ What it is NOT
16
+ --------------
17
+ A *heuristic* built on regular expressions (plus a small taint pass for JS), not
18
+ a sound program analysis. No AST, no precise data-flow graph -> it has false
19
+ positives (matches in comments/strings) and false negatives (taint through
20
+ calls, aliasing, complex expressions). Use the output to prioritise, then confirm
21
+ each finding by hand.
22
+
23
+ Usage
24
+ -----
25
+ python dxa.py <file-or-directory> [--json] [--min-confidence low|medium|high]
26
+
27
+ Exit code is non-zero when findings are reported, so it can gate a CI pipeline.
28
+ """
29
+
30
+ import argparse
31
+ import ast
32
+ import dxa_evidence as evidence
33
+ import html
34
+ import json
35
+ import os
36
+ import re
37
+ import sys
38
+ REPORT_EVENTS = []
39
+
40
+ # --- JavaScript / TypeScript sinks ------------------------------------------
41
+ JS_SINKS = [
42
+ ("innerHTML", re.compile(r'\.(?:inner|outer)HTML\s*='),
43
+ "high", "value assigned to (inner|outer)HTML is parsed as HTML"),
44
+ ("insertAdjacentHTML", re.compile(r'\.insertAdjacentHTML\s*\('),
45
+ "high", "insertAdjacentHTML() parses its argument as HTML"),
46
+ ("document.write", re.compile(r'\bdocument\.write(?:ln)?\s*\('),
47
+ "high", "document.write(ln)() writes raw markup into the page"),
48
+ ("eval", re.compile(r'\beval\s*\('),
49
+ "high", "eval() executes its argument as JavaScript"),
50
+ ("Function", re.compile(r'\b(?:new\s+)?Function\s*\('),
51
+ "high", "the Function() constructor executes a string as code"),
52
+ ("timer-string", re.compile(r'\b(?:setTimeout|setInterval)\s*\(\s*[\'"`]'),
53
+ "high", "setTimeout/setInterval with a string argument runs it as code"),
54
+ ("angular-bypass", re.compile(r'bypassSecurityTrust\w*\s*\('),
55
+ "high", "Angular DomSanitizer bypass disables the framework's escaping"),
56
+ ("react-dangerous", re.compile(r'dangerouslySetInnerHTML'),
57
+ "high", "React dangerouslySetInnerHTML injects raw HTML"),
58
+ ("jquery-html", re.compile(r'\.(?:html|append|prepend|after|before|replaceWith|wrapAll|wrapInner|wrap)\s*\('),
59
+ "medium", "jQuery HTML sink - inserts its argument as markup"),
60
+ ("jquery-selector", re.compile(r'\$\(\s*(?![\'"#.\[\]])[A-Za-z_$]'),
61
+ "medium", "$() on a non-literal value can build and run HTML"),
62
+ ("navigation", re.compile(r'\blocation(?:\.href)?\s*=|\blocation\.(?:assign|replace)\s*\(|\bwindow\.open\s*\('),
63
+ "medium", "navigation sink - a javascript: URL here executes"),
64
+ ("setAttribute", re.compile(r'\.setAttribute\s*\(\s*[\'"`](?:href|src|on\w+|formaction|xlink:href|data|style)[\'"`]'),
65
+ "medium", "setAttribute() on a dangerous attribute"),
66
+ ("src-href", re.compile(r'\.(?:src|href)\s*='),
67
+ "low", "src/href assignment - javascript:/data: URLs may execute"),
68
+ ]
69
+ JS_SOURCES = [
70
+ ("location.hash", re.compile(r'\blocation\.hash\b')),
71
+ ("location.search", re.compile(r'\blocation\.search\b')),
72
+ ("location.href", re.compile(r'\blocation\.href\b')),
73
+ ("location.pathname", re.compile(r'\blocation\.pathname\b')),
74
+ ("document.URL", re.compile(r'\bdocument\.(?:URL|documentURI|baseURI)\b')),
75
+ ("document.referrer", re.compile(r'\bdocument\.referrer\b')),
76
+ ("window.name", re.compile(r'\bwindow\.name\b')),
77
+ ("document.cookie", re.compile(r'\bdocument\.cookie\b')),
78
+ ("web-storage", re.compile(r'\b(?:local|session)Storage\b')),
79
+ ("URL-params", re.compile(r'\bURLSearchParams\b|\.searchParams\b')),
80
+ ("history.state", re.compile(r'\bhistory\.state\b')),
81
+ ]
82
+ MSG_LISTENER = re.compile(r'addEventListener\s*\(\s*[\'"`]message[\'"`]|\.onmessage\s*=')
83
+ MSG_DATA = re.compile(r'\b[A-Za-z_$][\w$]*\.data\b')
84
+
85
+ # --- C# / ASP.NET & Razor sinks (server-rendered XSS) -----------------------
86
+ CS_SINKS = [
87
+ ("Html.Raw", re.compile(r'@?Html\.Raw\s*\('),
88
+ "high", "@Html.Raw() emits its argument as unescaped HTML"),
89
+ ("Response.Write", re.compile(r'\bResponse\.Write\s*\('),
90
+ "high", "Response.Write() writes raw output straight into the response"),
91
+ ("HtmlString", re.compile(r'\bnew\s+(?:Mvc)?HtmlString\s*\('),
92
+ "high", "HtmlString/MvcHtmlString marks a string as trusted, un-encoded HTML"),
93
+ ("MarkupString", re.compile(r'\bnew\s+MarkupString\s*\(|\(\s*MarkupString\s*\)'),
94
+ "high", "Blazor MarkupString renders a string as raw HTML"),
95
+ ("InnerHtml", re.compile(r'\.InnerHtml\s*='),
96
+ "high", "control.InnerHtml assignment renders raw HTML"),
97
+ ("Literal.Text", re.compile(r'\.Text\s*=\s*(?![\'"])'),
98
+ "low", "Literal/Label .Text set from a dynamic value (raw when Mode=PassThrough)"),
99
+ ]
100
+ CS_SOURCES = [
101
+ ("Request.Query", re.compile(r'\bRequest\.(?:Query|QueryString)\b')),
102
+ ("Request.Form", re.compile(r'\bRequest\.Form\b')),
103
+ ("Request.Params", re.compile(r'\bRequest\.Params\b|\bRequest\s*\[')),
104
+ ("Request.Cookies", re.compile(r'\bRequest\.Cookies\b')),
105
+ ("Request.Headers", re.compile(r'\bRequest\.Headers\b')),
106
+ ("Request.Route", re.compile(r'\bRequest\.RouteValues\b|\bRouteData\b')),
107
+ ("Request.Body", re.compile(r'\bRequest\.Body\b')),
108
+ ]
109
+
110
+ # --- PHP sinks (server-side XSS: unescaped output) --------------------------
111
+ PHP_SINKS = [
112
+ ("echo", re.compile(r'\becho\s+[^;]*\$'),
113
+ "high", "echo of a variable - unescaped output is XSS unless htmlspecialchars() is applied"),
114
+ ("print", re.compile(r'\bprint\s+[^;]*\$'),
115
+ "high", "print of a variable - same class as echo"),
116
+ ("short-echo", re.compile(r'<\?=[^?]*\$'),
117
+ "high", "<?= $var ?> renders raw HTML; wrap with htmlspecialchars()"),
118
+ ("printf-family", re.compile(r'\b(?:v?printf)\s*\('),
119
+ "medium", "printf/vprintf can render dynamic content unescaped"),
120
+ ("blade-raw", re.compile(r'\{!!'),
121
+ "high", "Laravel Blade {!! !!} disables escaping (the safe form is {{ }})"),
122
+ ("twig-raw", re.compile(r'\|\s*raw\b'),
123
+ "medium", "Twig |raw filter disables escaping"),
124
+ ("file_put_contents", re.compile(r'\bfile_put_contents\s*\('),
125
+ "low", "file_put_contents may store attacker HTML that is later rendered raw"),
126
+ ]
127
+ PHP_SOURCES = [
128
+ ("$_GET", re.compile(r'\$_GET\b')),
129
+ ("$_POST", re.compile(r'\$_POST\b')),
130
+ ("$_REQUEST", re.compile(r'\$_REQUEST\b')),
131
+ ("$_COOKIE", re.compile(r'\$_COOKIE\b')),
132
+ ("$_SERVER", re.compile(r'\$_SERVER\b')), # incl. HTTP_* headers
133
+ ("$_FILES", re.compile(r'\$_FILES\b')),
134
+ ("php://input", re.compile(r'php://input')),
135
+ ("Laravel-Request", re.compile(r'\bRequest::(?:input|all|get|post|query|cookie|header)\b|'
136
+ r'\brequest\(\)\s*->\s*(?:input|all|get|post|query|cookie|header)\b')),
137
+ ("Symfony-Request", re.compile(r'\$request\s*->\s*(?:query|request|cookies|headers|files|attributes)\b')),
138
+ ]
139
+
140
+ # --- Python sinks (Flask/Jinja + FastAPI + Django) --------------------------
141
+ PY_SINKS = [
142
+ ("markupsafe-markup", re.compile(r'\bMarkup\s*\('),
143
+ "high", "markupsafe.Markup(x) marks the string as safe HTML - Jinja will render it raw"),
144
+ ("render-template-str", re.compile(r'\brender_template_string\s*\('),
145
+ "high", "render_template_string(x) treats x itself as a Jinja template - full server-side template injection surface"),
146
+ ("django-mark-safe", re.compile(r'\bmark_safe\s*\(|SafeString\s*\('),
147
+ "high", "Django mark_safe / SafeString bypasses auto-escaping"),
148
+ ("django-format-html", re.compile(r'\bformat_html(?:_join)?\s*\(\s*[\'"][^\'"]*%s'),
149
+ "medium", "format_html with a raw %s in the template placeholder"),
150
+ ("fastapi-html", re.compile(r'\bHTMLResponse\s*\('),
151
+ "medium", "FastAPI HTMLResponse renders its argument as raw HTML"),
152
+ ("flask-response-html", re.compile(r'\bResponse\s*\([^)]*mimetype\s*=\s*[\'"]text/html[\'"]|make_response\s*\('),
153
+ "medium", "Flask Response/make_response with text/html mimetype and a dynamic body"),
154
+ ("jinja-safe-filter", re.compile(r'\|\s*safe\b'),
155
+ "high", "Jinja |safe filter disables auto-escaping (used on a value = raw HTML)"),
156
+ ("html-tostring", re.compile(r'\blxml\.html\.tostring\s*\(|\bBeautifulSoup\s*\('),
157
+ "low", "lxml/BeautifulSoup HTML build - source injection depends on where the result flows"),
158
+ ("os-system", re.compile(r'\b(?:os\.system|os\.popen|subprocess\.(?:call|run|Popen))\s*\(\s*[a-zA-Z_]'),
159
+ "medium", "OS command sink - not XSS but a Python-only injection class worth flagging"),
160
+ ]
161
+ PY_SOURCES = [
162
+ ("flask-request-arg", re.compile(r'\brequest\s*\.\s*args\s*(?:\.\s*get\s*\(|\[)')),
163
+ ("flask-request-form", re.compile(r'\brequest\s*\.\s*form\s*(?:\.\s*get\s*\(|\[)')),
164
+ ("flask-request-values",re.compile(r'\brequest\s*\.\s*values\s*(?:\.\s*get\s*\(|\[)')),
165
+ ("flask-request-json", re.compile(r'\brequest\s*\.\s*get_json\s*\(|\brequest\s*\.\s*json\b')),
166
+ ("flask-request-cookie",re.compile(r'\brequest\s*\.\s*cookies\s*(?:\.\s*get\s*\(|\[)')),
167
+ ("flask-request-header",re.compile(r'\brequest\s*\.\s*headers\s*(?:\.\s*get\s*\(|\[)')),
168
+ ("flask-view-args", re.compile(r'\brequest\s*\.\s*view_args\b')),
169
+ ("fastapi-param", re.compile(r'=\s*(?:Query|Body|Header|Cookie|Path|Form|File)\s*\(')),
170
+ ("django-request-get", re.compile(r'\brequest\s*\.\s*GET\s*(?:\.\s*get\s*\(|\[)')),
171
+ ("django-request-post", re.compile(r'\brequest\s*\.\s*POST\s*(?:\.\s*get\s*\(|\[)')),
172
+ ("django-request-meta", re.compile(r'\brequest\s*\.\s*META\s*(?:\.\s*get\s*\(|\[)')),
173
+ ("environ", re.compile(r'\bos\.environ(?:\.\s*get\s*\(|\[)')),
174
+ ("input-stdin", re.compile(r'\bsys\.stdin\.(?:read|readline)\b|\binput\s*\(')),
175
+ ]
176
+
177
+
178
+ # --- Java / JSP / Thymeleaf sinks (server-side XSS: unescaped output) -------
179
+ JAVA_SINKS = [
180
+ ("servlet-writer", re.compile(r'\b(?:getWriter\(\)|PrintWriter\s*\.\s*\w+)\s*\.\s*(?:print(?:ln)?|write|append)\s*\('),
181
+ "high", "Servlet PrintWriter print/println/write emits raw response body"),
182
+ ("servlet-output", re.compile(r'\bServletOutputStream\b.*\.(?:print|write)\s*\('),
183
+ "high", "ServletOutputStream writes raw bytes to the response"),
184
+ ("response-write", re.compile(r'\bresponse\s*\.\s*getWriter\(\)\s*\.\s*(?:print(?:ln)?|write|append)\s*\('),
185
+ "high", "response.getWriter() writes raw output"),
186
+ ("jsp-expr", re.compile(r'<%=[^%]*(?:request|param|session|cookie|\bvar\b|\$)'),
187
+ "high", "JSP <%= %> scriptlet emits value unescaped (unless htmlEscape wraps it)"),
188
+ ("jsp-el-unescape", re.compile(r'<c:out[^>]+escapeXml\s*=\s*"false"'),
189
+ "high", "<c:out escapeXml=\"false\"> disables the default JSP escaping"),
190
+ ("th-utext", re.compile(r'\bth:utext\b'),
191
+ "high", "Thymeleaf th:utext renders content as raw HTML (th:text is the safe form)"),
192
+ ("th-inline-unesc", re.compile(r'\[\(\$\{[^}]+\}\)\]'),
193
+ "high", "Thymeleaf [(${...})] inline-unescape; [[${...}]] is the escaped form"),
194
+ ("jsoup-html", re.compile(r'\.html\s*\(\s*(?![\'"`])'),
195
+ "medium", "jsoup Element.html(x) parses its argument as HTML"),
196
+ ("response-header", re.compile(r'\bresponse\s*\.\s*(?:setHeader|addHeader)\s*\('),
197
+ "low", "response header write - reflecting user input into a header can enable XSS in old browsers or via error pages"),
198
+ ]
199
+ JAVA_SOURCES = [
200
+ ("request.param", re.compile(r'\brequest\s*\.\s*getParameter(?:Values|Map)?\s*\(')),
201
+ ("request.header", re.compile(r'\brequest\s*\.\s*getHeader(?:Names|s)?\s*\(')),
202
+ ("request.cookies", re.compile(r'\brequest\s*\.\s*getCookies\s*\(|\bCookie\s*\.\s*getValue\s*\(')),
203
+ ("request.body", re.compile(r'\brequest\s*\.\s*getReader\s*\(|\bgetInputStream\s*\(')),
204
+ ("request.uri", re.compile(r'\brequest\s*\.\s*(?:getRequestURI|getRequestURL|getQueryString|getPathInfo)\s*\(')),
205
+ ("spring-param", re.compile(r'@RequestParam\b|@RequestHeader\b|@PathVariable\b|@CookieValue\b|@RequestBody\b|@ModelAttribute\b')),
206
+ ("session-attr", re.compile(r'\bsession\s*\.\s*getAttribute\s*\(')),
207
+ ("system-in-input", re.compile(r'\bSystem\s*\.\s*in\b|\bnew\s+Scanner\s*\(\s*System\s*\.\s*in\s*\)')),
208
+ ]
209
+
210
+ ASSIGN = re.compile(r'^\s*(?:var|let|const)?\s*([A-Za-z_$][\w$]*)\s*=\s*(.+?)\s*;?\s*$')
211
+ PHP_ASSIGN = re.compile(r'^\s*(\$[A-Za-z_]\w*)\s*=\s*(.+?)\s*;?\s*$')
212
+ # Java: `Type name = expr;` or `name = expr;` (type is optional, may be generic)
213
+ JAVA_ASSIGN = re.compile(
214
+ r'^\s*(?:(?:final|static|public|private|protected|volatile|synchronized)\s+)*'
215
+ r'(?:[\w<>\[\],?.\s]{1,80}?\s+)?'
216
+ r'([A-Za-z_]\w*)\s*=\s*(.+?)\s*;?\s*$'
217
+ )
218
+ # Python: `name = expr` or `name: type = expr` (no ; terminator, no let/var)
219
+ PYTHON_ASSIGN = re.compile(
220
+ r'^\s*([A-Za-z_]\w*)\s*(?::\s*[\w\[\], .]+?\s*)?=\s*(.+?)\s*$'
221
+ )
222
+ CONF_RANK = {"low": 0, "medium": 1, "high": 2}
223
+ JS_EXT = (".js", ".ts", ".jsx", ".tsx", ".mjs")
224
+ CS_EXT = (".cs", ".cshtml", ".razor")
225
+ PHP_EXT = (".php", ".phtml", ".php3", ".php4", ".php5", ".phps", ".inc")
226
+ JAVA_EXT = (".java", ".jsp", ".jspx", ".tag")
227
+ PY_EXT = (".py", ".pyw")
228
+ TEMPLATE_EXT = (".html", ".htm", ".twig", ".jinja", ".jinja2", ".vue")
229
+ TEMPLATE_SINKS = [s for s in JAVA_SINKS if s[0] in ("th-utext", "th-inline-unesc")]
230
+ TEMPLATE_SINKS += [s for s in PHP_SINKS if s[0] == "twig-raw"]
231
+ TEMPLATE_SINKS += [s for s in PY_SINKS if s[0] == "jinja-safe-filter"]
232
+ TEMPLATE_SINKS += [("vue-html", re.compile(r'\bv-html\s*='), "high",
233
+ "Vue v-html renders raw HTML; template data flow is not resolved")]
234
+
235
+
236
+ def source_hits(text, sources, msg_active):
237
+ """Names of sources *read* in `text`. A source that is the target of an
238
+ assignment (e.g. `location.href = x`) is a write, not a read, so skip it."""
239
+ hits = []
240
+ for name, rx in sources:
241
+ for m in rx.finditer(text):
242
+ after = text[m.end():].lstrip()
243
+ if after[:1] == "=" and after[1:2] != "=":
244
+ continue # write target, not a read
245
+ hits.append(name)
246
+ break
247
+ if msg_active and MSG_DATA.search(text):
248
+ hits.append("postMessage.data")
249
+ return hits
250
+
251
+
252
+ # v3.9 finding dedup. When the same line matches multiple sink patterns that
253
+ # overlap semantically (a specific one is a subset of a general one), report
254
+ # ONLY the general - the specific was there for extra clarity but a single
255
+ # HIGH row per line is what the operator wants.
256
+ # Pairs are (specific, general): if `general` also matched on this line,
257
+ # drop the specific finding.
258
+ SINK_SUPPRESSIONS = [
259
+ ("src-href", "navigation"), # location.href/src writes -> navigation covers it
260
+ ("response-write", "servlet-writer"), # response.getWriter().*() also matches servlet-writer
261
+
262
+ # Phase 0.1 widening. Each pair below is (loser, winner): when both regexes
263
+ # match on the same line, drop the LOSER's finding. Verified against the
264
+ # regex sources - each pair has a real-world source shape that trips both.
265
+ ("innerHTML", "angular-bypass"), # elem.innerHTML = this.sanitizer.bypassSecurityTrustHtml(x)
266
+ # -> the bypass call is the actionable signal; the .innerHTML=
267
+ # assignment is just its target. Report once, as angular-bypass.
268
+ ("jsp-expr", "jsp-el-unescape"), # <c:out escapeXml="false"><%=request.getParameter("x")%></c:out>
269
+ # -> the c:out escapeXml="false" tells the operator WHY the JSP
270
+ # expression is dangerous. Keep the more informative one.
271
+ ("markupsafe-markup", "django-mark-safe"), # mark_safe(Markup(user_input))
272
+ # -> the outer django-mark-safe is idiomatic-Django context;
273
+ # report as django-mark-safe, drop the inner Markup() noise.
274
+ ("th-utext", "th-inline-unesc"), # <span th:utext="${x}">[(${x})]</span> mixes both Thymeleaf
275
+ # unescape mechanisms on one node; the [( ... )] inline form
276
+ # is the less-known one worth surfacing.
277
+ ("jinja-safe-filter", "render-template-str"), # render_template_string("...{{ x | safe }}...")
278
+ # -> render_template_string on user data is the whole-class
279
+ # attack; the |safe filter inside is a symptom. Report the class.
280
+ ]
281
+
282
+
283
+ def _joined_for_taint(lines, terminator=";", max_join=8):
284
+ """Return a list the same length as `lines`. Each entry is the original
285
+ line concatenated with continuation lines up to the next `terminator`
286
+ (default `;`). Preserves indexing so tainted-set computation sees complete
287
+ multi-line statements (Java/C# ternaries, long argument lists) without
288
+ breaking scan_file's per-line reporting. v3.8 addition - needed to catch
289
+ the exact hotel-platform shape:
290
+ String cid = (inbound != null && ...)
291
+ ? sanitize(inbound)
292
+ : shortUuid();
293
+ which otherwise splits across 3 lines and hides the sanitize()."""
294
+ joined = list(lines)
295
+ for i, line in enumerate(lines):
296
+ s = line.strip()
297
+ # already terminated on this line, or a block delimiter, or empty
298
+ if terminator in line or not s or s.endswith(("{", "}")) or s.startswith(("//", "#")):
299
+ continue
300
+ buf = line
301
+ for k in range(1, max_join + 1):
302
+ if i + k >= len(lines):
303
+ break
304
+ nxt = lines[i + k]
305
+ buf = buf + " " + nxt.strip()
306
+ if terminator in nxt:
307
+ break
308
+ joined[i] = buf
309
+ return joined
310
+
311
+
312
+ # Phase 0.4 (2026-09-26): cross-file taint pre-pass. When the project
313
+ # scan starts, walk every file once and build a `set()` of function
314
+ # names whose body includes `return <raw source>` (returning a request /
315
+ # location / superglobal directly). During compute_taint, a call to any
316
+ # name in that set is treated the same as a direct source access - the
317
+ # assignment target becomes tainted. Regex-based, no AST, no import
318
+ # resolution: cross-file taint is proven by name-shape only, which is
319
+ # imprecise but catches the common util.py -> app.py shape.
320
+ # Java / C# skipped here (class + method resolution needs more machinery
321
+ # than regex; scheduled for Phase 4 tree-sitter work).
322
+ _FUNC_DEF_RE = {
323
+ "py": re.compile(r'^\s*def\s+(\w+)\s*\('),
324
+ "js": re.compile(r'^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\('),
325
+ "php": re.compile(r'^\s*(?:public\s+|private\s+|protected\s+|static\s+)*'
326
+ r'function\s+(\w+)\s*\('),
327
+ }
328
+ _RETURN_TAINTED_RE = {
329
+ "py": re.compile(r'\breturn\b[^#]*?('
330
+ r'request\s*\.\s*(?:args|form|values|json|cookies|'
331
+ r'headers|view_args|GET|POST|META)|'
332
+ r'os\.environ|sys\.stdin|input\s*\(|'
333
+ r'flask\.request|starlette.*request)'),
334
+ "js": re.compile(r'\breturn\b[^;]*?('
335
+ r'document\s*\.\s*(?:location|URL|referrer|cookie|'
336
+ r'documentURI|baseURI)|'
337
+ r'window\s*\.\s*(?:location|name)|'
338
+ r'\blocation\s*\.\s*(?:hash|search|href|pathname)|'
339
+ r'(?:local|session)Storage)'),
340
+ "php": re.compile(r'\breturn\b[^;]*?\$_(?:GET|POST|REQUEST|COOKIE|'
341
+ r'SERVER|FILES|SESSION|ENV)\b'),
342
+ }
343
+
344
+
345
+ def _raw_return_names(paths):
346
+ """Return `set()` of function names whose body includes `return <source>`.
347
+ Function-body tracking is language-shaped:
348
+ - Python: indent-based (body ends when indent <= def's own indent).
349
+ - JS / PHP: brace-depth counter from the opening `{`.
350
+ Both approaches are approximations - nested defs, decorators that eat
351
+ the body, and single-expression arrow functions can be missed. Anything
352
+ the pre-pass misses stays medium/low; nothing here can UPGRADE a wrong
353
+ signal (misclassifying a benign function as tainted-returning would
354
+ just raise a MEDIUM to HIGH for its callers - still triage-worthy)."""
355
+ tainted_funcs = set()
356
+ for path in paths:
357
+ lang = _lang_for(path)[0]
358
+ if lang not in _FUNC_DEF_RE:
359
+ continue
360
+ try:
361
+ with open(path, encoding="utf-8", errors="ignore") as fh:
362
+ lines = fh.read().split("\n")
363
+ except OSError:
364
+ continue
365
+ def_re = _FUNC_DEF_RE[lang]
366
+ ret_re = _RETURN_TAINTED_RE[lang]
367
+ if lang == "py":
368
+ current, base_indent = None, -1
369
+ for line in lines:
370
+ m = def_re.match(line)
371
+ if m:
372
+ current = m.group(1)
373
+ base_indent = len(line) - len(line.lstrip())
374
+ continue
375
+ if current is None:
376
+ continue
377
+ stripped = line.strip()
378
+ if not stripped or stripped.startswith("#"):
379
+ continue
380
+ line_indent = len(line) - len(line.lstrip())
381
+ if line_indent <= base_indent:
382
+ current, base_indent = None, -1
383
+ continue
384
+ if ret_re.search(line):
385
+ tainted_funcs.add(current)
386
+ else: # js / php - brace depth
387
+ current, depth = None, 0
388
+ for line in lines:
389
+ # Comments strip - shallow but avoids most FP inside a /* */
390
+ stripped_line = re.sub(r'//.*$', '', line)
391
+ if current is None:
392
+ m = def_re.match(line)
393
+ if not m:
394
+ continue
395
+ current = m.group(1)
396
+ depth = (stripped_line.count("{") -
397
+ stripped_line.count("}"))
398
+ if depth == 0:
399
+ # single-line function - inspect same line for return
400
+ if ret_re.search(line):
401
+ tainted_funcs.add(current)
402
+ current = None
403
+ continue
404
+ if ret_re.search(line):
405
+ tainted_funcs.add(current)
406
+ depth += stripped_line.count("{") - stripped_line.count("}")
407
+ if depth <= 0:
408
+ current, depth = None, 0
409
+ return tainted_funcs
410
+
411
+
412
+ def _closing_paren(text, opening):
413
+ """Small lexical helper, not a language parser. Fail closed on truncation."""
414
+ depth, quote, escaped = 0, None, False
415
+ for i in range(opening, len(text)):
416
+ char = text[i]
417
+ if quote:
418
+ if escaped:
419
+ escaped = False
420
+ elif char == '\\':
421
+ escaped = True
422
+ elif char == quote:
423
+ quote = None
424
+ continue
425
+ if char in ('"', "'", '`'):
426
+ quote = char
427
+ elif char == '(':
428
+ depth += 1
429
+ elif char == ')':
430
+ depth -= 1
431
+ if depth == 0:
432
+ return i
433
+ return None
434
+
435
+
436
+ # HTML-body mitigation hints only. A matching spelling is not a guarantee that
437
+ # the library has not been rebound/configured insecurely. Never trust local
438
+ # sanitize/clean/validate names, Markup or SafeString as encoders.
439
+ _HTML_ENCODER_NAMES = (
440
+ r'html\.escape|markupsafe\.escape|bleach\.clean|nh3\.clean|'
441
+ r'htmlspecialchars|htmlentities|StringEscapeUtils\.escapeHtml[34]?|'
442
+ r'HtmlUtils\.htmlEscape|Encode\.forHtml(?:Content)?|'
443
+ r'ESAPI\.encoder\(\)\.encodeForHTML|HttpUtility\.HtmlEncode|'
444
+ r'WebUtility\.HtmlEncode|HtmlEncoder\.(?:Default\.)?Encode|Html\.Encode|'
445
+ r'DOMPurify\.sanitize|he\.encode|_\.escape'
446
+ )
447
+ _HTML_ENCODER_CALL = re.compile(r'(?<![\w.])(?:' + _HTML_ENCODER_NAMES + r')\s*\(')
448
+ _HTML_ENCODER_REBIND = re.compile(r'^\s*(' + _HTML_ENCODER_NAMES + r')\s*=(?!=)')
449
+
450
+
451
+ def _rebound_html_encoders(lines):
452
+ """Track explicit local rebinding; dynamic imports/aliases remain unsupported."""
453
+ return {match.group(1) for line in lines
454
+ if (match := _HTML_ENCODER_REBIND.match(line))}
455
+
456
+
457
+ def _ambiguous_html_context(text):
458
+ return bool(re.search(r'<\s*(?:script|style)\b|\b[\w:-]+\s*=\s*[\'"]', text, re.I))
459
+
460
+
461
+ def _html_residual(text, rebound=frozenset()):
462
+ """Remove just recognized HTML encoder calls, retaining every other term."""
463
+ # Embedded script/style, URL and attribute contexts require other encoders.
464
+ # This deliberately declines mitigation for ambiguous string constructions.
465
+ if _ambiguous_html_context(text):
466
+ return text
467
+ out, pos = [], 0
468
+ for match in _HTML_ENCODER_CALL.finditer(text):
469
+ if match.start() < pos:
470
+ continue
471
+ if match.group()[:-1].strip() in rebound:
472
+ continue
473
+ end = _closing_paren(text, match.end() - 1)
474
+ if end is None:
475
+ return text
476
+ out.append(text[pos:match.start()])
477
+ out.append('0')
478
+ pos = end + 1
479
+ out.append(text[pos:])
480
+ return ''.join(out)
481
+
482
+
483
+ def _sink_expression(line, match, sid):
484
+ """Isolate common single-line sink arguments; unknown shapes stay broad."""
485
+ if sid in ('innerHTML', 'InnerHtml', 'navigation', 'src-href', 'Literal.Text') and '=' in match.group():
486
+ expr = line[match.end():]
487
+ depth, quote, escaped = 0, None, False
488
+ for i, char in enumerate(expr):
489
+ if quote:
490
+ if escaped:
491
+ escaped = False
492
+ elif char == '\\':
493
+ escaped = True
494
+ elif char == quote:
495
+ quote = None
496
+ elif char in ('"', "'", '`'):
497
+ quote = char
498
+ elif char in '([{':
499
+ depth += 1
500
+ elif char in ')]}':
501
+ depth -= 1
502
+ elif char == ';' and depth == 0:
503
+ return expr[:i]
504
+ return expr
505
+ if match.group().endswith('('):
506
+ start = match.end()
507
+ end = _closing_paren(line, start - 1)
508
+ if end is not None:
509
+ expr = line[start:end]
510
+ if sid == 'render-template-str' and not re.search(r'\|\s*safe\b', expr):
511
+ # Only the template source (first argument) is an SSTI sink.
512
+ # Keyword values rendered by autoescape are not template source.
513
+ depth, quote, escaped = 0, None, False
514
+ for i, char in enumerate(expr):
515
+ if quote:
516
+ if escaped:
517
+ escaped = False
518
+ elif char == '\\':
519
+ escaped = True
520
+ elif char == quote:
521
+ quote = None
522
+ elif char in ('"', "'", '`'):
523
+ quote = char
524
+ elif char in '([{':
525
+ depth += 1
526
+ elif char in ')]}':
527
+ depth -= 1
528
+ elif char == ',' and depth == 0:
529
+ expr = expr[:i]
530
+ break
531
+ if re.fullmatch(r'\s*([\'"])(?:\\.|(?!\1).)*\1\s*', expr):
532
+ return '0'
533
+ return expr
534
+ return line
535
+
536
+
537
+ _HTML_BODY_SINKS = {'innerHTML', 'insertAdjacentHTML', 'document.write',
538
+ 'jquery-html', 'fastapi-html', 'flask-response-html',
539
+ 'markupsafe-markup', 'django-mark-safe', 'echo', 'print',
540
+ 'short-echo', 'servlet-writer', 'response-write', 'Html.Raw',
541
+ 'Response.Write', 'HtmlString', 'MarkupString', 'InnerHtml'}
542
+
543
+
544
+ def _scope_lines(lines, index, lang):
545
+ """Isolate ordinary Python function locals. Complex scopes remain limited.
546
+
547
+ Nested closures/classes are deliberately not inferred across boundaries.
548
+ The returned prefix also avoids a later source assignment tainting an
549
+ earlier sink. Brace languages retain file-local conservative scope.
550
+ """
551
+ if lang != 'py':
552
+ return lines[:index + 1]
553
+ scopes = []
554
+ active = []
555
+ for i, line in enumerate(lines):
556
+ stripped = line.strip()
557
+ if not stripped or stripped.startswith('#'):
558
+ continue
559
+ indent = len(line) - len(line.lstrip())
560
+ while active and indent <= active[-1][1]:
561
+ start, _ = active.pop()
562
+ scopes.append((start, i))
563
+ if re.match(r'\s*(?:async\s+)?def\s+\w+\s*\(', line):
564
+ active.append((i, indent))
565
+ scopes.extend((start, len(lines)) for start, _ in active)
566
+ containing = [(start, end) for start, end in scopes if start <= index < end]
567
+ if containing:
568
+ start, end = max(containing)
569
+ # An explicit module-level encoder override is visible inside a later
570
+ # function. Carry only those bindings, not unrelated module taint or
571
+ # assignments inside a sibling function.
572
+ module_rebindings = [line for i, line in enumerate(lines[:start])
573
+ if line == line.lstrip()
574
+ and _HTML_ENCODER_REBIND.match(line)
575
+ and not any(child <= i < stop for child, stop in scopes)]
576
+ return module_rebindings + [line for i, line in enumerate(lines[start:index + 1], start)
577
+ if not any(start < child <= i < stop for child, stop in scopes)]
578
+ return [line for i, line in enumerate(lines[:index + 1])
579
+ if not any(start <= i < end for start, end in scopes)]
580
+
581
+
582
+ class ScopedTaintMap(set):
583
+ """Set-compatible inventory; scan_file uses only explicitly bound file names."""
584
+ def __init__(self, by_file):
585
+ super().__init__(name for names in by_file.values() for name in names)
586
+ self.by_file = by_file
587
+
588
+ def for_path(self, path):
589
+ return self.by_file.get(os.path.normcase(os.path.abspath(path)), set())
590
+
591
+
592
+ def build_cross_file_taint_map(paths):
593
+ """Resolve only simple, explicit sibling imports/includes. No global names.
594
+
595
+ Unsupported dynamic/module/class resolution stays a candidate. This is not
596
+ an interprocedural proof; see STATIC-LIMITS.md.
597
+ """
598
+ paths = [os.path.normcase(os.path.abspath(p)) for p in paths]
599
+ providers = {p: _raw_return_names([p]) for p in paths}
600
+ result = {}
601
+ for path in paths:
602
+ try:
603
+ with open(path, encoding="utf-8", errors="ignore") as fh:
604
+ code = fh.read()
605
+ except OSError:
606
+ continue
607
+ lang = _lang_for(path)[0]
608
+ bound = set(providers[path])
609
+ directory = os.path.dirname(path)
610
+ imports = []
611
+ if lang == "py":
612
+ for module, names in re.findall(r'^from\s+([\w.]+)\s+import\s+([^\n#]+)', code, re.M):
613
+ # Only a single sibling module is deliberately supported.
614
+ if '.' not in module:
615
+ imports.append((module + '.py', names))
616
+ elif lang == "js":
617
+ for names, module in re.findall(r'(?:const|let|var)\s*\{([^}]+)\}\s*=\s*require\([\'"](\./[^\'"]+)[\'"]\)', code):
618
+ imports.append((module if os.path.splitext(module)[1] else module + '.js', names))
619
+ for names, module in re.findall(r'import\s*\{([^}]+)\}\s*from\s*[\'"](\./[^\'"]+)[\'"]', code):
620
+ imports.append((module if os.path.splitext(module)[1] else module + '.js', names))
621
+ elif lang == "php":
622
+ for module in re.findall(r'\b(?:require|include)(?:_once)?\s*[\'"]([^\'"]+)[\'"]', code):
623
+ imports.append((module, None))
624
+ for module, names in imports:
625
+ provider = os.path.normcase(os.path.abspath(os.path.join(directory, module)))
626
+ available = providers.get(provider, set())
627
+ if names is None:
628
+ bound.update(available)
629
+ else:
630
+ for name in names.split(','):
631
+ parts = re.split(r'\s+as\s+|\s*:\s*', name.strip())
632
+ if parts[0] in available and all(re.fullmatch(r'\w+', p) for p in parts):
633
+ bound.add(parts[-1])
634
+ # A local declaration can shadow an import. Do not claim that binding.
635
+ def_re = _FUNC_DEF_RE.get(lang)
636
+ if def_re:
637
+ declarations = [m.group(1) for line in code.splitlines() if (m := def_re.match(line))]
638
+ for name in declarations:
639
+ if name not in providers[path] or declarations.count(name) > 1:
640
+ bound.discard(name)
641
+ result[path] = bound
642
+ return ScopedTaintMap(result)
643
+
644
+
645
+ def compute_taint(lines, sources, msg_active, assign_re=ASSIGN,
646
+ cross_file_funcs=None, html_context=False):
647
+ """Bounded regex provenance; HTML mitigation is a separate sink-specific view.
648
+
649
+ Encoder spelling never erases raw provenance needed for JS/template sinks.
650
+ html_context removes only recognized HTML call spans, never adjacent values.
651
+ """
652
+ tainted = set()
653
+ raw_tainted = (compute_taint(lines, sources, msg_active, assign_re, cross_file_funcs)
654
+ if html_context else set())
655
+ xfuncs = cross_file_funcs or set()
656
+ # Precompile a single alternation regex for the cross-file call check -
657
+ # much cheaper than N separate re.search() calls per RHS in the fix-point.
658
+ xfunc_call_re = None
659
+ if xfuncs:
660
+ xfunc_call_re = re.compile(
661
+ r'(?<!\w)(?:' + "|".join(re.escape(n) for n in xfuncs) +
662
+ r')\s*\(')
663
+ for _ in range(6):
664
+ changed = False
665
+ rebound = set()
666
+ for line in lines:
667
+ if html_context and (binding := _HTML_ENCODER_REBIND.match(line)):
668
+ rebound.add(binding.group(1))
669
+ m = assign_re.match(line)
670
+ if not m:
671
+ continue
672
+ lhs, rhs = m.group(1), m.group(2)
673
+ # Raw provenance always survives encoders. Only the separate HTML
674
+ # view removes recognized call spans; concatenated raw data remains.
675
+ context_tainted = raw_tainted if html_context and _ambiguous_html_context(rhs) else tainted
676
+ if html_context:
677
+ rhs = _html_residual(rhs, rebound)
678
+ if (source_hits(rhs, sources, msg_active)
679
+ or any(re.search(r'(?<!\w)' + re.escape(v) + r'\b', rhs)
680
+ for v in context_tainted)
681
+ or (xfunc_call_re is not None and xfunc_call_re.search(rhs))):
682
+ if lhs not in tainted:
683
+ tainted.add(lhs)
684
+ changed = True
685
+ if not changed:
686
+ break
687
+ return tainted
688
+
689
+
690
+ def _lang_for(path):
691
+ """Return (lang, sinks, sources, assign_re, wants_taint). lang is one of
692
+ 'js', 'cs', 'php', 'java', 'py'; wants_taint tells scan_file whether to run
693
+ compute_taint (JS+PHP+Java+Python yes, C# no - stays sink-only)."""
694
+ ext = os.path.splitext(path)[1].lower()
695
+ if ext in JS_EXT:
696
+ return "js", JS_SINKS, JS_SOURCES, ASSIGN, True
697
+ if ext in PHP_EXT:
698
+ return "php", PHP_SINKS, PHP_SOURCES, PHP_ASSIGN, True
699
+ if ext in JAVA_EXT:
700
+ return "java", JAVA_SINKS, JAVA_SOURCES, JAVA_ASSIGN, True
701
+ if ext in PY_EXT:
702
+ return "py", PY_SINKS, PY_SOURCES, PYTHON_ASSIGN, True
703
+ if ext in TEMPLATE_EXT:
704
+ return "template", TEMPLATE_SINKS + JS_SINKS, JS_SOURCES, ASSIGN, True
705
+ return "cs", CS_SINKS, CS_SOURCES, ASSIGN, False
706
+
707
+
708
+ def _python_ast_analysis(source):
709
+ """Return bounded Python taint snapshots and syntax-level sources.
710
+
711
+ This is a single-language pilot, not a sound data-flow analysis. It handles
712
+ ordinary assignments (including matching tuple/list unpacking) in lexical
713
+ statement order. Conditional bodies are merged monotonically; aliases,
714
+ interprocedural flow, class/closure scopes and dynamic imports are not
715
+ resolved. Invalid Python returns None so the legacy heuristic remains the
716
+ fallback.
717
+ """
718
+ try:
719
+ tree = ast.parse(source)
720
+ except (SyntaxError, ValueError, TypeError):
721
+ return None
722
+
723
+ source_lines = {}
724
+
725
+ def source_names_in(node, text):
726
+ found = set()
727
+ for child in ast.walk(node):
728
+ # Inspect the syntactic receiver/callee only. Looking at an outer
729
+ # call's full source text would mistake a quoted example for input.
730
+ if isinstance(child, ast.Call):
731
+ segment = ast.get_source_segment(text, child.func)
732
+ if segment:
733
+ found.update(source_hits(segment + "(", PY_SOURCES, False))
734
+ elif isinstance(child, (ast.Subscript, ast.Attribute)):
735
+ segment = ast.get_source_segment(text, child)
736
+ if segment:
737
+ found.update(source_hits(segment, PY_SOURCES, False))
738
+ return found
739
+
740
+ def source_names(node):
741
+ return source_names_in(node, source)
742
+
743
+ for node in ast.walk(tree):
744
+ found = set()
745
+ if isinstance(node, ast.Call):
746
+ segment = ast.get_source_segment(source, node.func)
747
+ if segment:
748
+ found.update(source_hits(segment + "(", PY_SOURCES, False))
749
+ elif isinstance(node, (ast.Subscript, ast.Attribute)):
750
+ segment = ast.get_source_segment(source, node)
751
+ if segment:
752
+ found.update(source_hits(segment, PY_SOURCES, False))
753
+ if found:
754
+ source_lines.setdefault(node.lineno, set()).update(found)
755
+
756
+ raw_at_line = {}
757
+ html_at_line = {}
758
+ rebound_at_line = {}
759
+ tainted = set()
760
+ html_tainted = set()
761
+ source_lines_text = source.splitlines()
762
+
763
+ def expr_tainted(node, names):
764
+ if source_names(node):
765
+ return True
766
+ return any(isinstance(item, ast.Name) and isinstance(item.ctx, ast.Load)
767
+ and item.id in names for item in ast.walk(node))
768
+
769
+ def bind(target, value, raw_value, html_value, rebound_names):
770
+ if isinstance(target, (ast.Tuple, ast.List)):
771
+ if isinstance(value, (ast.Tuple, ast.List)) and len(target.elts) == len(value.elts):
772
+ for lhs, rhs in zip(target.elts, value.elts):
773
+ bind(lhs, rhs, expr_tainted(rhs, tainted),
774
+ expr_tainted_text(rhs, html_tainted, tainted, rebound_names), rebound_names)
775
+ return
776
+ for lhs in target.elts:
777
+ bind(lhs, value, raw_value, html_value, rebound_names)
778
+ return
779
+ if isinstance(target, ast.Name):
780
+ # Taint is monotonic, matching the existing conservative behavior.
781
+ if raw_value:
782
+ tainted.add(target.id)
783
+ if html_value:
784
+ html_tainted.add(target.id)
785
+
786
+ def expr_tainted_text(node, names, raw_names, rebound_names):
787
+ segment = ast.get_source_segment(source, node) or ""
788
+ if _ambiguous_html_context(segment):
789
+ return expr_tainted(node, raw_names)
790
+ residual = _html_residual(segment, rebound_names)
791
+ try:
792
+ residual_ast = ast.parse(residual, mode="eval")
793
+ except SyntaxError:
794
+ return expr_tainted(node, names)
795
+ if source_names_in(residual_ast, residual):
796
+ return True
797
+ return any(isinstance(item, ast.Name) and isinstance(item.ctx, ast.Load)
798
+ and item.id in names for item in ast.walk(residual_ast))
799
+
800
+ def visit_statements(statements, inherited_rebound=None):
801
+ local_rebound = set(inherited_rebound or ())
802
+ for statement in statements:
803
+ start = getattr(statement, "lineno", 1)
804
+ end = getattr(statement, "end_lineno", start)
805
+ for line in range(start, end + 1):
806
+ if 1 <= line <= len(source_lines_text):
807
+ binding = _HTML_ENCODER_REBIND.match(source_lines_text[line - 1])
808
+ if binding:
809
+ local_rebound.add(binding.group(1))
810
+ for line in range(start, end + 1):
811
+ raw_at_line[line] = set(tainted)
812
+ html_at_line[line] = set(html_tainted)
813
+ rebound_at_line[line] = set(local_rebound)
814
+
815
+ if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
816
+ # Function bodies are independent scopes. Nested definitions
817
+ # are intentionally not traversed by this pilot.
818
+ saved_raw, saved_html = tainted.copy(), html_tainted.copy()
819
+ tainted.clear()
820
+ html_tainted.clear()
821
+ visit_statements(statement.body, local_rebound)
822
+ tainted.clear(); tainted.update(saved_raw)
823
+ html_tainted.clear(); html_tainted.update(saved_html)
824
+ continue
825
+ if isinstance(statement, (ast.ClassDef,)):
826
+ continue
827
+
828
+ value = None
829
+ targets = []
830
+ if isinstance(statement, ast.Assign):
831
+ value, targets = statement.value, statement.targets
832
+ elif isinstance(statement, ast.AnnAssign) and statement.value is not None:
833
+ value, targets = statement.value, [statement.target]
834
+ elif isinstance(statement, ast.NamedExpr):
835
+ value, targets = statement.value, [statement.target]
836
+ elif isinstance(statement, ast.AugAssign):
837
+ value, targets = statement.value, [statement.target]
838
+ if isinstance(statement.target, ast.Name) and statement.target.id in tainted:
839
+ tainted.add(statement.target.id)
840
+ html_tainted.add(statement.target.id)
841
+
842
+ if value is not None:
843
+ raw_value = expr_tainted(value, tainted)
844
+ html_value = expr_tainted_text(value, html_tainted, tainted, local_rebound)
845
+ for target in targets:
846
+ bind(target, value, raw_value, html_value, local_rebound)
847
+
848
+ # Merge control-flow blocks into a conservative monotonic view.
849
+ # This deliberately makes no claim about branch feasibility.
850
+ child_blocks = []
851
+ if isinstance(statement, (ast.If, ast.For, ast.AsyncFor, ast.While)):
852
+ child_blocks.extend((statement.body, statement.orelse))
853
+ elif isinstance(statement, (ast.With, ast.AsyncWith)):
854
+ child_blocks.append(statement.body)
855
+ elif isinstance(statement, (ast.Try,)):
856
+ child_blocks.extend([statement.body, statement.orelse, statement.finalbody])
857
+ child_blocks.extend(handler.body for handler in statement.handlers)
858
+ for block in child_blocks:
859
+ visit_statements(block, local_rebound)
860
+
861
+ visit_statements(tree.body)
862
+ return {"raw": raw_at_line, "html": html_at_line,
863
+ "sources": source_lines, "rebound": rebound_at_line}
864
+
865
+
866
+ def scan_file(path, cross_file_funcs=None):
867
+ """Scan a single file for XSS-family sinks. Phase 0.4: optional
868
+ `cross_file_funcs` set is passed through to compute_taint AND used to
869
+ upgrade sink-line confidence when the sink argument is a call to a
870
+ known tainted-returning function from another file in the project."""
871
+ lang, sinks, sources, assign_re, wants_taint = _lang_for(path)
872
+ if isinstance(cross_file_funcs, ScopedTaintMap):
873
+ cross_file_funcs = cross_file_funcs.for_path(path)
874
+
875
+ try:
876
+ with open(path, "r", encoding="utf-8", errors="ignore") as fh:
877
+ source_text = fh.read()
878
+ lines = source_text.split("\n")
879
+ except OSError as exc:
880
+ REPORT_EVENTS.append(evidence.ReportEvent("static-read", "error", type(exc).__name__, str(path)))
881
+ return []
882
+
883
+ msg_active = lang == "js" and bool(MSG_LISTENER.search("\n".join(lines)))
884
+ python_ast = _python_ast_analysis(source_text) if lang == "py" else None
885
+ # Java/C# statements often span lines (ternaries, long argument lists,
886
+ # generic types); join by `;` before taint so multi-line sanitize()
887
+ # wraps are visible. JS/PHP/Python usually single-line - default OK.
888
+ taint_view = _joined_for_taint(lines) if lang in ("java", "cs") else lines
889
+ dynamic = re.compile(r'[A-Za-z_$@][\w$]*')
890
+
891
+ # Phase 0.4: precompiled sink-line xfunc detector (empty regex if map empty).
892
+ xfuncs = cross_file_funcs or set()
893
+ xfunc_call_re = (re.compile(
894
+ r'(?<!\w)(?:' + "|".join(re.escape(n) for n in xfuncs) + r')\s*\(')
895
+ if xfuncs else None)
896
+
897
+ findings = []
898
+ for lineno, line in enumerate(lines, 1):
899
+ matched_here = {sid for sid, rx, *_ in sinks if rx.search(line)}
900
+ if not matched_here:
901
+ continue
902
+ scope = _scope_lines(taint_view, lineno - 1, lang)
903
+ tainted = (compute_taint(scope, sources, msg_active, assign_re,
904
+ cross_file_funcs=cross_file_funcs) if wants_taint else set())
905
+ html_tainted = (compute_taint(scope, sources, msg_active, assign_re,
906
+ cross_file_funcs=cross_file_funcs, html_context=True)
907
+ if wants_taint else set())
908
+ rebound = _rebound_html_encoders(scope)
909
+ # v3.9 dedup: drop specific sinks when the general one already matched
910
+ suppressed = {specific for specific, general in SINK_SUPPRESSIONS
911
+ if specific in matched_here and general in matched_here}
912
+ for sid, rx, severity, desc in sinks:
913
+ if sid not in matched_here or sid in suppressed:
914
+ continue
915
+ expr = _sink_expression(line, rx.search(line), sid)
916
+ html_body = sid in _HTML_BODY_SINKS and not _ambiguous_html_context(expr)
917
+ evaluated = _html_residual(expr, rebound) if html_body else expr
918
+ srcs = source_hits(evaluated, sources, msg_active)
919
+ tvars = sorted(v for v in (html_tainted if html_body else tainted)
920
+ if re.search(r'(?<!\w)' + re.escape(v) + r'\b', evaluated))
921
+ legacy_tvars = tvars
922
+ # Python AST does not resolve the separate sibling-import provider map.
923
+ xfunc_hits = ([fname for fname in xfuncs
924
+ if re.search(r'(?<!\w)' + re.escape(fname) + r'\s*\(', evaluated)]
925
+ if xfunc_call_re and xfunc_call_re.search(line) else [])
926
+ if python_ast is not None:
927
+ srcs = sorted(python_ast["sources"].get(lineno, set()))
928
+ ast_names = (python_ast["html"] if html_body else python_ast["raw"]).get(lineno, set())
929
+ tvars = sorted({
930
+ name for name in ast_names
931
+ if re.search(r'(?<!\w)' + re.escape(name) + r'\b', evaluated)
932
+ })
933
+ if xfuncs:
934
+ tvars = sorted(set(tvars) | set(legacy_tvars))
935
+ # Phase 0.4: cross-file call directly on the sink line - upgrade
936
+ # confidence too. E.g. `res.send(getUserInput())` where
937
+ # getUserInput lives in util.js and returns req.query raw.
938
+ if srcs or tvars or xfunc_hits:
939
+ confidence = "high"
940
+ elif dynamic.search(line.split("//", 1)[0]):
941
+ confidence = "medium"
942
+ else:
943
+ confidence = "low"
944
+ findings.append({
945
+ "file": path, "line": lineno, "sink": sid, "lang": lang,
946
+ "severity": severity, "confidence": confidence, "description": desc,
947
+ "code": line.strip()[:200], "sources": srcs, "tainted_vars": tvars,
948
+ "xfunc_calls": xfunc_hits, # Phase 0.4: which cross-file
949
+ # tainted funcs this sink calls
950
+ })
951
+ return findings
952
+
953
+
954
+ def iter_files(target):
955
+ if os.path.isfile(target):
956
+ yield target
957
+ return
958
+ for root, _, files in os.walk(target, onerror=lambda exc: REPORT_EVENTS.append(
959
+ evidence.ReportEvent("static-discovery", "error", type(exc).__name__, str(exc.filename or target)))):
960
+ if "node_modules" in root or "vendor" in root or os.sep + ".git" in root:
961
+ REPORT_EVENTS.append(evidence.ReportEvent("static-discovery", "skip", "excluded directory", root))
962
+ continue
963
+ for name in files:
964
+ if name.lower().endswith(JS_EXT + CS_EXT + PHP_EXT + JAVA_EXT + PY_EXT + TEMPLATE_EXT):
965
+ yield os.path.join(root, name)
966
+
967
+
968
+ def render_html(findings, target):
969
+ return evidence.render_report(evidence.report(evidence.static_observations(findings), "static", REPORT_EVENTS))
970
+
971
+
972
+ def _load_cli_config(path, parser):
973
+ """Load the small, versioned static CLI config without echoing its contents."""
974
+ if path is None:
975
+ return {}
976
+
977
+ def unique_object(pairs):
978
+ config = {}
979
+ for key, value in pairs:
980
+ if key in config:
981
+ raise ValueError("duplicate config key")
982
+ config[key] = value
983
+ return config
984
+
985
+ try:
986
+ with open(path, encoding="utf-8-sig") as stream:
987
+ config = json.load(stream, object_pairs_hook=unique_object)
988
+ except (OSError, UnicodeError, ValueError):
989
+ parser.error("--config must name a readable UTF-8 JSON file")
990
+ if (not isinstance(config, dict)
991
+ or type(config.get("schema_version")) is not int
992
+ or config["schema_version"] != 1
993
+ or not set(config) <= {"schema_version", "min_confidence"}
994
+ or not isinstance(config.get("min_confidence", "low"), str)
995
+ or config.get("min_confidence", "low") not in CONF_RANK):
996
+ parser.error("--config requires schema_version 1 and min_confidence low|medium|high")
997
+ return config
998
+
999
+
1000
+ _CREDENTIAL_SOURCE_KEY = (
1001
+ r"(?:x[-_]?api[-_]?key|api[-_]?key|authorization|proxy[-_]authorization|"
1002
+ r"access[-_]token|refresh[-_]token|id[-_]token|client[-_]secret|"
1003
+ r"auth[-_]token|csrf[-_]token|x[-_]csrf[-_]token|x[-_]xsrf[-_]token|"
1004
+ r"set[-_]cookie|(?:document\.)?cookie)"
1005
+ )
1006
+ _QUOTED_CREDENTIAL_ASSIGNMENT = re.compile(
1007
+ rf'''(?ix)(?P<prefix>(?:["'`]?{_CREDENTIAL_SOURCE_KEY}["'`]?)\s*[:=]\s*)'''
1008
+ rf'''(?P<quote>["'`])(?:\\.|(?! (?P=quote) ).)*(?P=quote)'''
1009
+ )
1010
+ _UNQUOTED_BEARER_ASSIGNMENT = re.compile(
1011
+ rf'''(?ix)(?P<prefix>(?:["'`]?{_CREDENTIAL_SOURCE_KEY}["'`]?)\s*[:=]\s*)'''
1012
+ r'''Bearer\s+[^\s,;}]+'''
1013
+ )
1014
+
1015
+
1016
+ def _redact_raw_json_source(findings):
1017
+ """Mask obvious credential literals in legacy JSON without mutating scans.
1018
+
1019
+ Only literal assignments with credential-specific field names are changed.
1020
+ Arbitrary source snippets remain intact; common sharing reports use the
1021
+ separate fingerprint-only adapter.
1022
+ """
1023
+ exported = []
1024
+ for finding in findings:
1025
+ row = dict(finding)
1026
+ code = row.get("code")
1027
+ if isinstance(code, str):
1028
+ row["code"] = _redact_source_code(code)
1029
+ exported.append(row)
1030
+ return exported
1031
+
1032
+
1033
+ def _redact_source_code(code):
1034
+ """Redact only credential-labeled source literals in a displayed snippet."""
1035
+ code = _QUOTED_CREDENTIAL_ASSIGNMENT.sub(
1036
+ lambda match: (match.group("prefix") + match.group("quote")
1037
+ + "[redacted]" + match.group("quote")), code)
1038
+ return _UNQUOTED_BEARER_ASSIGNMENT.sub(
1039
+ lambda match: match.group("prefix") + "[redacted]", code)
1040
+
1041
+
1042
+ def main():
1043
+ ap = argparse.ArgumentParser(description="source-to-sink XSS analyzer (JS + C#/.NET)")
1044
+ ap.add_argument("target", help="file or directory to scan")
1045
+ ap.add_argument("--json", action="store_true", help="emit JSON instead of text")
1046
+ ap.add_argument("--json-out", metavar="FILE", help="write common R1 evidence report")
1047
+ ap.add_argument("--html", metavar="FILE", help="write a self-contained HTML report to FILE")
1048
+ ap.add_argument("--sarif-out", metavar="FILE", help="write a SARIF 2.1.0 report")
1049
+ ap.add_argument("--config", metavar="FILE", help="versioned JSON static scan settings")
1050
+ ap.add_argument("--min-confidence", choices=["low", "medium", "high"],
1051
+ help="hide findings below this confidence (overrides config; default low)")
1052
+ args = ap.parse_args()
1053
+ config = _load_cli_config(args.config, ap)
1054
+ REPORT_EVENTS.clear()
1055
+ output_paths = [path for path in (args.json_out, args.html, args.sarif_out) if path]
1056
+ if len({os.path.abspath(path) for path in output_paths}) != len(output_paths):
1057
+ ap.error("--json-out, --html, and --sarif-out must have distinct paths")
1058
+ if not os.path.exists(args.target):
1059
+ REPORT_EVENTS.append(evidence.ReportEvent("static-discovery", "error", "target does not exist", args.target))
1060
+
1061
+ floor = CONF_RANK[args.min_confidence or config.get("min_confidence", "low")]
1062
+ # Phase 0.4: two-pass scan. First collect the full file list (one os.walk
1063
+ # traversal), then pre-build the cross-file tainted-return function map
1064
+ # over Python + JS + PHP files. Java / C# skipped in Phase 0.4. The map is
1065
+ # then handed to every per-file scan so `foo(request.args['q'])`-shaped
1066
+ # helpers in util.py taint their callers' locals in app.py.
1067
+ all_files = list(iter_files(args.target))
1068
+ if not all_files and not REPORT_EVENTS:
1069
+ REPORT_EVENTS.append(evidence.ReportEvent("static-discovery", "skip", "no supported source files", args.target))
1070
+ xfunc_map = build_cross_file_taint_map(all_files) if len(all_files) > 1 else set()
1071
+ findings = []
1072
+ for f in all_files:
1073
+ findings.extend(scan_file(f, cross_file_funcs=xfunc_map))
1074
+ filtered = [f for f in findings if CONF_RANK[f["confidence"]] >= floor]
1075
+ if len(filtered) != len(findings):
1076
+ REPORT_EVENTS.append(evidence.ReportEvent("static-filter", "skip", f"{len(findings)-len(filtered)} candidates below confidence threshold"))
1077
+ findings = filtered
1078
+ findings.sort(key=lambda f: (-CONF_RANK[f["confidence"]], f["file"], f["line"]))
1079
+ document = (evidence.report(evidence.static_observations(findings), "static", REPORT_EVENTS)
1080
+ if args.json_out or args.html or args.sarif_out else None)
1081
+ if args.json_out:
1082
+ with open(args.json_out, "w", encoding="utf-8") as fh:
1083
+ json.dump(document, fh, indent=2)
1084
+ if args.sarif_out:
1085
+ import dxa_sarif
1086
+ sarif_document = dxa_sarif.to_sarif(document)
1087
+ with open(args.sarif_out, "w", encoding="utf-8") as fh:
1088
+ json.dump(sarif_document, fh, ensure_ascii=False, indent=2)
1089
+ result_code = 2 if any(event.kind == "error" for event in REPORT_EVENTS) else (1 if findings else 0)
1090
+
1091
+ if args.html:
1092
+ with open(args.html, "w", encoding="utf-8") as fh:
1093
+ fh.write(evidence.render_report(document))
1094
+ print(f"HTML report written to {args.html} ({len(findings)} finding(s))")
1095
+ sys.exit(result_code)
1096
+
1097
+ if args.json:
1098
+ print(json.dumps(_redact_raw_json_source(findings), indent=2))
1099
+ sys.exit(result_code)
1100
+
1101
+ if not findings:
1102
+ print("No XSS source/sink patterns found (at the chosen confidence).")
1103
+ sys.exit(result_code)
1104
+
1105
+ for f in findings:
1106
+ flow = ""
1107
+ if f["sources"]:
1108
+ flow = " <- source: " + ", ".join(f["sources"])
1109
+ elif f["tainted_vars"]:
1110
+ flow = " <- tainted var: " + ", ".join(f["tainted_vars"])
1111
+ print(f"{f['file']}:{f['line']} [{f['severity'].upper()}/{f['confidence']} "
1112
+ f"confidence, {f['lang']}] sink: {f['sink']}{flow}")
1113
+ print(f" {f['description']}")
1114
+ print(f" | {_redact_source_code(f['code'])}")
1115
+ print()
1116
+
1117
+ highs = sum(1 for f in findings if f["confidence"] == "high")
1118
+ print(f"{len(findings)} finding(s) - {highs} at HIGH confidence "
1119
+ f"(a source or tainted value reaches the sink).")
1120
+ sys.exit(1)
1121
+
1122
+
1123
+ if __name__ == "__main__":
1124
+ main()