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 +1124 -0
- dxa2dyn.py +196 -0
- dxa_attempts.py +228 -0
- dxa_callback.py +479 -0
- dxa_evidence.py +244 -0
- dxa_proof_lab.py +153 -0
- dxa_research_toolkit-0.2.0.dist-info/METADATA +294 -0
- dxa_research_toolkit-0.2.0.dist-info/RECORD +17 -0
- dxa_research_toolkit-0.2.0.dist-info/WHEEL +5 -0
- dxa_research_toolkit-0.2.0.dist-info/entry_points.txt +5 -0
- dxa_research_toolkit-0.2.0.dist-info/licenses/LICENSE +21 -0
- dxa_research_toolkit-0.2.0.dist-info/top_level.txt +11 -0
- dxa_sarif.py +228 -0
- dxa_sarif_cli.py +45 -0
- dxadom.py +819 -0
- dxadyn.py +3182 -0
- dxaprove.py +300 -0
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()
|