progmune-runtime 3.7.8 → 3.7.10
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.
- package/CHANGELOG.md +20 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/dist/frameworks/fastify-detector.js +137 -0
- package/dist/frameworks/fastify-detector.test.js +62 -0
- package/dist/frameworks/flask-detector.js +61 -0
- package/dist/frameworks/flask-detector.test.js +71 -0
- package/dist/frameworks/index.js +15 -3
- package/dist/frameworks/nextjs-detector.js +172 -0
- package/dist/frameworks/nextjs-detector.test.js +113 -0
- package/dist/sdk.js +1 -1
- package/dist/trust/engine.js +159 -0
- package/package.json +7 -3
- package/tools/extract_framework_django.py +269 -0
- package/tools/extract_framework_flask.py +169 -0
- package/tools/extract_framework_py.py +237 -0
- package/tools/extract_ir.py +1109 -0
|
@@ -0,0 +1,1109 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Python IR Extractor (V5) — matches TypeScript FunctionInfo interface.
|
|
4
|
+
|
|
5
|
+
Extracts: function signatures, type annotations, call graphs, decorator-based
|
|
6
|
+
protocol annotations, docstring-based metadata, class methods, and exports.
|
|
7
|
+
|
|
8
|
+
Usage: python tools/extract_ir.py <project_root> [output_path]
|
|
9
|
+
Output: ir.json (array of FunctionInfo-compatible objects)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
import sys
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
# ── Type annotation parser ──
|
|
20
|
+
|
|
21
|
+
def get_annotation(node):
|
|
22
|
+
"""Parse type annotation node to string. Returns 'any' if unannotated."""
|
|
23
|
+
if node is None:
|
|
24
|
+
return "any"
|
|
25
|
+
if isinstance(node, ast.Name):
|
|
26
|
+
return node.id
|
|
27
|
+
if isinstance(node, ast.Constant):
|
|
28
|
+
return str(node.value)
|
|
29
|
+
if isinstance(node, ast.Subscript):
|
|
30
|
+
value = get_annotation(node.value)
|
|
31
|
+
slice_ = get_annotation(node.slice)
|
|
32
|
+
return f"{value}[{slice_}]"
|
|
33
|
+
if isinstance(node, ast.Tuple):
|
|
34
|
+
return ", ".join(get_annotation(e) for e in node.elts)
|
|
35
|
+
if isinstance(node, ast.BinOp):
|
|
36
|
+
left = get_annotation(node.left)
|
|
37
|
+
right = get_annotation(node.right)
|
|
38
|
+
return f"{left} | {right}"
|
|
39
|
+
try:
|
|
40
|
+
return ast.unparse(node)
|
|
41
|
+
except Exception:
|
|
42
|
+
return "any"
|
|
43
|
+
|
|
44
|
+
# ── Call extraction ──
|
|
45
|
+
|
|
46
|
+
SQL_EXEC_ATTRS = {"execute", "executemany", "executescript", "execute_query", "raw", "extra"}
|
|
47
|
+
SQL_MARKER = "__progmune_sql_unparameterized__"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_dynamic_format(node):
|
|
51
|
+
"""True if the expression formats data into SQL text: f-string,
|
|
52
|
+
% formatting, .format() call, or string concatenation."""
|
|
53
|
+
if isinstance(node, ast.JoinedStr): # f-string
|
|
54
|
+
return True
|
|
55
|
+
if isinstance(node, ast.BinOp):
|
|
56
|
+
if isinstance(node.op, (ast.Mod, ast.Add)): # "%" formatting or "+" concat
|
|
57
|
+
return True
|
|
58
|
+
return is_dynamic_format(node.left) or is_dynamic_format(node.right)
|
|
59
|
+
if isinstance(node, ast.Call):
|
|
60
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr == "format":
|
|
61
|
+
return True
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def collect_assignments(node):
|
|
66
|
+
"""Variable name → assigned value node (single-hop, same function scope)."""
|
|
67
|
+
assigns = {}
|
|
68
|
+
for child in ast.walk(node):
|
|
69
|
+
if isinstance(child, ast.Assign):
|
|
70
|
+
for t in child.targets:
|
|
71
|
+
if isinstance(t, ast.Name):
|
|
72
|
+
assigns.setdefault(t.id, child.value)
|
|
73
|
+
elif isinstance(child, ast.AnnAssign) and isinstance(child.target, ast.Name):
|
|
74
|
+
assigns.setdefault(child.target.id, child.value)
|
|
75
|
+
return assigns
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def has_unparameterized_sql(node):
|
|
79
|
+
"""Source-level SQLi check: a SQL-executing call whose SQL text is built
|
|
80
|
+
with dynamic formatting (f-string / % / .format / concatenation), either
|
|
81
|
+
inline in the call args or in a single-hop local-variable assignment
|
|
82
|
+
(sql_query = "..." + user_input; cursor.execute(sql_query)).
|
|
83
|
+
Parameterized calls — execute("... %s ...", (args,)) — are NOT flagged."""
|
|
84
|
+
assigns = collect_assignments(node)
|
|
85
|
+
for child in ast.walk(node):
|
|
86
|
+
if isinstance(child, ast.Call):
|
|
87
|
+
name = None
|
|
88
|
+
if isinstance(child.func, ast.Name):
|
|
89
|
+
name = child.func.id
|
|
90
|
+
elif isinstance(child.func, ast.Attribute):
|
|
91
|
+
name = child.func.attr
|
|
92
|
+
if name in SQL_EXEC_ATTRS:
|
|
93
|
+
for a in child.args:
|
|
94
|
+
if is_dynamic_format(a):
|
|
95
|
+
return True
|
|
96
|
+
if isinstance(a, ast.Name) and is_dynamic_format(assigns.get(a.id)):
|
|
97
|
+
return True
|
|
98
|
+
for k in child.keywords:
|
|
99
|
+
if is_dynamic_format(k.value):
|
|
100
|
+
return True
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
SSRF_MARKER = "__progmune_ssrf_user_url__"
|
|
105
|
+
HTTP_FETCH_RECEIVERS = ("requests", "httpx", "urllib", "urllib2", "aiohttp", "http")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def is_request_rooted(node):
|
|
109
|
+
"""True if the expression derives from the request object
|
|
110
|
+
(request.POST.get(...), request.GET['x'], request.body ...)."""
|
|
111
|
+
if isinstance(node, ast.Name):
|
|
112
|
+
return node.id == "request"
|
|
113
|
+
if isinstance(node, ast.Attribute):
|
|
114
|
+
return is_request_rooted(node.value)
|
|
115
|
+
if isinstance(node, ast.Subscript):
|
|
116
|
+
return is_request_rooted(node.value)
|
|
117
|
+
if isinstance(node, ast.Call):
|
|
118
|
+
return (is_request_rooted(node.func)
|
|
119
|
+
or any(is_request_rooted(a) for a in node.args)
|
|
120
|
+
or any(is_request_rooted(k.value) for k in node.keywords))
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def is_tainted(node, assigns, depth=0):
|
|
125
|
+
"""Single-hop taint: request-rooted, variable assigned from a tainted
|
|
126
|
+
value, or dynamic formatting containing tainted parts."""
|
|
127
|
+
if node is None or depth > 2:
|
|
128
|
+
return False
|
|
129
|
+
if is_request_rooted(node):
|
|
130
|
+
return True
|
|
131
|
+
if isinstance(node, ast.Name):
|
|
132
|
+
return is_tainted(assigns.get(node.id), assigns, depth + 1)
|
|
133
|
+
if isinstance(node, ast.JoinedStr):
|
|
134
|
+
return any(is_tainted(v.value, assigns, depth + 1)
|
|
135
|
+
for v in node.values if isinstance(v, ast.FormattedValue))
|
|
136
|
+
if isinstance(node, ast.BinOp):
|
|
137
|
+
return (is_tainted(node.left, assigns, depth)
|
|
138
|
+
or is_tainted(node.right, assigns, depth))
|
|
139
|
+
if isinstance(node, ast.Call):
|
|
140
|
+
return (any(is_tainted(a, assigns, depth) for a in node.args)
|
|
141
|
+
or any(is_tainted(k.value, assigns, depth) for k in node.keywords))
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def is_http_fetch_call(node):
|
|
146
|
+
"""requests.get(...) / urllib.request.urlopen(...) / httpx.get(...) /
|
|
147
|
+
http.client.HTTPConnection.request(...) / bare urlopen(...)."""
|
|
148
|
+
if isinstance(node.func, ast.Name):
|
|
149
|
+
return node.func.id == "urlopen"
|
|
150
|
+
if isinstance(node.func, ast.Attribute):
|
|
151
|
+
parts = []
|
|
152
|
+
cur = node.func
|
|
153
|
+
while isinstance(cur, ast.Attribute):
|
|
154
|
+
parts.append(cur.attr)
|
|
155
|
+
cur = cur.value
|
|
156
|
+
if isinstance(cur, ast.Name):
|
|
157
|
+
parts.append(cur.id)
|
|
158
|
+
chain = parts[::-1]
|
|
159
|
+
if chain and chain[0] in HTTP_FETCH_RECEIVERS:
|
|
160
|
+
return chain[-1] in ("get", "post", "put", "delete", "head",
|
|
161
|
+
"patch", "request", "urlopen", "open", "fetch")
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def has_ssrf(node):
|
|
166
|
+
"""SSRF check: an HTTP fetch call whose URL argument is tainted by
|
|
167
|
+
request-derived user input (directly or via single-hop assignment)."""
|
|
168
|
+
assigns = collect_assignments(node)
|
|
169
|
+
for child in ast.walk(node):
|
|
170
|
+
if isinstance(child, ast.Call) and is_http_fetch_call(child):
|
|
171
|
+
if any(is_tainted(a, assigns) for a in child.args):
|
|
172
|
+
return True
|
|
173
|
+
if any(is_tainted(k.value, assigns) for k in child.keywords):
|
|
174
|
+
return True
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
PATH_MARKER = "__progmune_path_traversal__"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def is_file_sink_call(node):
|
|
182
|
+
"""open(...) / io.open(...) / os.open(...) / Path(...).read_text() — file
|
|
183
|
+
sinks whose path argument, when tainted, is a path traversal."""
|
|
184
|
+
if isinstance(node.func, ast.Name):
|
|
185
|
+
return node.func.id == "open"
|
|
186
|
+
if isinstance(node.func, ast.Attribute):
|
|
187
|
+
if node.func.attr in ("read_text", "read_bytes"):
|
|
188
|
+
# Any receiver — the taint verification happens in
|
|
189
|
+
# has_path_traversal (direct Path(...) call or Path-assigned name).
|
|
190
|
+
return True
|
|
191
|
+
parts = []
|
|
192
|
+
cur = node.func
|
|
193
|
+
while isinstance(cur, ast.Attribute):
|
|
194
|
+
parts.append(cur.attr)
|
|
195
|
+
cur = cur.value
|
|
196
|
+
if isinstance(cur, ast.Name):
|
|
197
|
+
parts.append(cur.id)
|
|
198
|
+
chain = parts[::-1]
|
|
199
|
+
return chain[0] in ("io", "os") and chain[-1] == "open"
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
XSS_MARKER = "__progmune_xss_unsafe_render__"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def scan_unsafe_template_vars(project_root):
|
|
207
|
+
"""Template-layer visibility: map template path (relative to project root)
|
|
208
|
+
→ set of variables rendered WITHOUT escaping — {{ var|safe }} filters or
|
|
209
|
+
anything inside {% autoescape off %} blocks."""
|
|
210
|
+
unsafe = {}
|
|
211
|
+
for path in Path(project_root).rglob("*.html"):
|
|
212
|
+
if 'node_modules' in path.parts or 'venv' in path.parts \
|
|
213
|
+
or any(p.startswith('.') for p in path.parts):
|
|
214
|
+
continue
|
|
215
|
+
try:
|
|
216
|
+
text = path.read_text(encoding='utf-8', errors='ignore')
|
|
217
|
+
except Exception:
|
|
218
|
+
continue
|
|
219
|
+
vars_ = set(re.findall(r'{{\s*(\w+)\s*\|\s*safe\s*}}', text))
|
|
220
|
+
for block in re.findall(
|
|
221
|
+
r'{%\s*autoescape\s+off\s*%}(.*?){%\s*endautoescape\s*%}',
|
|
222
|
+
text, re.S):
|
|
223
|
+
vars_ |= set(re.findall(r'{{\s*(\w+)\s*}}', block))
|
|
224
|
+
if vars_:
|
|
225
|
+
unsafe[path.relative_to(project_root).as_posix()] = vars_
|
|
226
|
+
return unsafe
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
SSTI_MARKER = "__progmune_ssti_template_injection__"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def has_ssti(node):
|
|
233
|
+
"""SSTI check: (S1) a template-string sink (render_template_string /
|
|
234
|
+
Template / from_string) receiving tainted input; (S2) tainted content
|
|
235
|
+
written to a file opened under a template path — the Django
|
|
236
|
+
dynamic-template pattern (user input becomes template source)."""
|
|
237
|
+
assigns = collect_assignments(node)
|
|
238
|
+
for child in ast.walk(node):
|
|
239
|
+
if not isinstance(child, ast.Call):
|
|
240
|
+
continue
|
|
241
|
+
name = None
|
|
242
|
+
if isinstance(child.func, ast.Name):
|
|
243
|
+
name = child.func.id
|
|
244
|
+
elif isinstance(child.func, ast.Attribute):
|
|
245
|
+
name = child.func.attr
|
|
246
|
+
if name in ("render_template_string", "from_string", "Template"):
|
|
247
|
+
if any(is_tainted(a, assigns) for a in child.args):
|
|
248
|
+
return True
|
|
249
|
+
# S2: file.write(tainted) where the file object traces to
|
|
250
|
+
# open(<template path>, ...)
|
|
251
|
+
if name == "write":
|
|
252
|
+
if not any(is_tainted(a, assigns) for a in child.args):
|
|
253
|
+
continue
|
|
254
|
+
recv = child.func.value
|
|
255
|
+
if isinstance(recv, ast.Name):
|
|
256
|
+
v = assigns.get(recv.id)
|
|
257
|
+
if isinstance(v, ast.Call) and isinstance(v.func, ast.Name) \
|
|
258
|
+
and v.func.id == "open" and v.args:
|
|
259
|
+
path_str = ""
|
|
260
|
+
p = v.args[0]
|
|
261
|
+
if isinstance(p, ast.Constant):
|
|
262
|
+
path_str = str(p.value)
|
|
263
|
+
elif isinstance(p, ast.Name):
|
|
264
|
+
pv = assigns.get(p.id)
|
|
265
|
+
if pv is not None:
|
|
266
|
+
path_str = ast.unparse(pv)
|
|
267
|
+
else:
|
|
268
|
+
path_str = ast.unparse(p)
|
|
269
|
+
if "template" in path_str.lower() or path_str.endswith(".html"):
|
|
270
|
+
return True
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
XXE_MARKER = "__progmune_xxe_external_entities__"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def has_xxe(node):
|
|
278
|
+
"""XXE check: BOTH signals required — (1) an explicitly unsafe parser
|
|
279
|
+
configuration (setFeature(feature_external_*, True) or
|
|
280
|
+
XMLParser(resolve_entities=True)), AND (2) parsing of tainted
|
|
281
|
+
request-derived XML (parse / parseString / fromstring / iterparse).
|
|
282
|
+
Config-only or taint-only alone is not flagged."""
|
|
283
|
+
assigns = collect_assignments(node)
|
|
284
|
+
unsafe_parser = False
|
|
285
|
+
tainted_parse = False
|
|
286
|
+
for child in ast.walk(node):
|
|
287
|
+
if not isinstance(child, ast.Call):
|
|
288
|
+
continue
|
|
289
|
+
name = None
|
|
290
|
+
if isinstance(child.func, ast.Name):
|
|
291
|
+
name = child.func.id
|
|
292
|
+
elif isinstance(child.func, ast.Attribute):
|
|
293
|
+
name = child.func.attr
|
|
294
|
+
if name == "setFeature" and len(child.args) >= 2:
|
|
295
|
+
arg0 = child.args[0]
|
|
296
|
+
is_external = (isinstance(arg0, ast.Name) and "external" in arg0.id.lower()) \
|
|
297
|
+
or (isinstance(arg0, ast.Constant) and "external" in str(arg0.value).lower())
|
|
298
|
+
arg1_true = isinstance(child.args[1], ast.Constant) \
|
|
299
|
+
and child.args[1].value is True
|
|
300
|
+
if is_external and arg1_true:
|
|
301
|
+
unsafe_parser = True
|
|
302
|
+
if name == "XMLParser":
|
|
303
|
+
for kw in child.keywords:
|
|
304
|
+
if kw.arg == "resolve_entities" and isinstance(kw.value, ast.Constant) \
|
|
305
|
+
and kw.value.value is True:
|
|
306
|
+
unsafe_parser = True
|
|
307
|
+
if name in ("parse", "parseString", "fromstring", "from_string", "iterparse"):
|
|
308
|
+
if any(is_tainted(a, assigns) for a in child.args):
|
|
309
|
+
tainted_parse = True
|
|
310
|
+
return unsafe_parser and tainted_parse
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
EVAL_MARKER = "__progmune_eval_user_input__"
|
|
314
|
+
SECRET_MARKER = "__progmune_hardcoded_secret__"
|
|
315
|
+
CMD_FLOW_MARKER = "__progmune_command_taint_flow__"
|
|
316
|
+
CSRF_MARKER = "__progmune_csrf_disabled__"
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def has_csrf_exempt(node):
|
|
320
|
+
"""@csrf_exempt decorator — Django CSRF protection explicitly disabled."""
|
|
321
|
+
for dec in getattr(node, 'decorator_list', []):
|
|
322
|
+
name = None
|
|
323
|
+
if isinstance(dec, ast.Name):
|
|
324
|
+
name = dec.id
|
|
325
|
+
elif isinstance(dec, ast.Attribute):
|
|
326
|
+
name = dec.attr
|
|
327
|
+
if name == "csrf_exempt":
|
|
328
|
+
return True
|
|
329
|
+
return False
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
GET_STATE_MARKER = "__progmune_get_state_change__"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def has_get_state_change(node):
|
|
336
|
+
"""CSRF shape #2: a `request.method == 'GET'` branch performs
|
|
337
|
+
state-changing calls (.save/.update/.delete/.create) — state change on
|
|
338
|
+
GET requests, exposed to CSRF even without @csrf_exempt."""
|
|
339
|
+
def is_get_compare(test):
|
|
340
|
+
if not isinstance(test, ast.Compare) or len(test.ops) != 1 \
|
|
341
|
+
or not isinstance(test.ops[0], ast.Eq):
|
|
342
|
+
return False
|
|
343
|
+
left, right = test.left, test.comparators[0]
|
|
344
|
+
const = None
|
|
345
|
+
attr = None
|
|
346
|
+
if isinstance(right, ast.Constant) and isinstance(right.value, str):
|
|
347
|
+
const = right.value
|
|
348
|
+
attr = left
|
|
349
|
+
elif isinstance(left, ast.Constant) and isinstance(left.value, str):
|
|
350
|
+
const = left.value
|
|
351
|
+
attr = right
|
|
352
|
+
if const and const.upper() == "GET":
|
|
353
|
+
return (isinstance(attr, ast.Attribute) and attr.attr == "method"
|
|
354
|
+
and isinstance(attr.value, ast.Name) and attr.value.id == "request")
|
|
355
|
+
return False
|
|
356
|
+
|
|
357
|
+
for child in ast.walk(node):
|
|
358
|
+
if isinstance(child, ast.If) and is_get_compare(child.test):
|
|
359
|
+
for stmt in child.body:
|
|
360
|
+
for sub in ast.walk(stmt):
|
|
361
|
+
if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute) \
|
|
362
|
+
and sub.func.attr in ("save", "update", "delete", "create"):
|
|
363
|
+
return True
|
|
364
|
+
return False
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def has_dynamic_eval(node):
|
|
368
|
+
"""eval/exec/__import__ called with tainted request-derived input."""
|
|
369
|
+
assigns = collect_assignments(node)
|
|
370
|
+
for child in ast.walk(node):
|
|
371
|
+
if isinstance(child, ast.Call) and isinstance(child.func, ast.Name) \
|
|
372
|
+
and child.func.id in ("eval", "exec", "__import__"):
|
|
373
|
+
if any(is_tainted(a, assigns) for a in child.args):
|
|
374
|
+
return True
|
|
375
|
+
return False
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _root_name(func_node):
|
|
379
|
+
cur = func_node
|
|
380
|
+
while isinstance(cur, ast.Attribute):
|
|
381
|
+
cur = cur.value
|
|
382
|
+
return cur.id if isinstance(cur, ast.Name) else None
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def has_hardcoded_secret(node, module_constants=None, imports=None, global_constants=None):
|
|
386
|
+
"""jwt.decode/encode with a literal string secret (positional or
|
|
387
|
+
key=/secret= keyword) — the key is in the source, not the environment.
|
|
388
|
+
Name arguments resolve through module-level constant assignments
|
|
389
|
+
(SECRET_COOKIE_KEY = '...'), including cross-module imports
|
|
390
|
+
(from pygoat.settings import SECRET_COOKIE_KEY → global constants map)."""
|
|
391
|
+
def is_literal(v):
|
|
392
|
+
if isinstance(v, ast.Constant) and isinstance(v.value, str):
|
|
393
|
+
return True
|
|
394
|
+
if isinstance(v, ast.Name):
|
|
395
|
+
if module_constants is not None:
|
|
396
|
+
cv = module_constants.get(v.id)
|
|
397
|
+
if isinstance(cv, ast.Constant) and isinstance(cv.value, str):
|
|
398
|
+
return True
|
|
399
|
+
if imports is not None and global_constants is not None:
|
|
400
|
+
mod = imports.get(v.id)
|
|
401
|
+
cv = global_constants.get(mod)
|
|
402
|
+
if isinstance(cv, ast.Constant) and isinstance(cv.value, str):
|
|
403
|
+
return True
|
|
404
|
+
return False
|
|
405
|
+
|
|
406
|
+
for child in ast.walk(node):
|
|
407
|
+
if not isinstance(child, ast.Call):
|
|
408
|
+
continue
|
|
409
|
+
name = None
|
|
410
|
+
if isinstance(child.func, ast.Name):
|
|
411
|
+
name = child.func.id
|
|
412
|
+
elif isinstance(child.func, ast.Attribute):
|
|
413
|
+
name = child.func.attr
|
|
414
|
+
if name in ("decode", "encode") and _root_name(child.func) in ("jwt", "jose", "itsdangerous"):
|
|
415
|
+
if len(child.args) >= 2 and is_literal(child.args[1]):
|
|
416
|
+
return True
|
|
417
|
+
for kw in child.keywords:
|
|
418
|
+
if kw.arg in ("key", "secret", "secret_key") and is_literal(kw.value):
|
|
419
|
+
return True
|
|
420
|
+
return False
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def has_command_taint_flow(node):
|
|
424
|
+
"""Tainted value passed to a command-named helper — the cross-function
|
|
425
|
+
command-injection flow (view builds 'nmap ' + ip and hands it to
|
|
426
|
+
command_out(); the Popen sink lives in the helper)."""
|
|
427
|
+
assigns = collect_assignments(node)
|
|
428
|
+
for child in ast.walk(node):
|
|
429
|
+
if not isinstance(child, ast.Call):
|
|
430
|
+
continue
|
|
431
|
+
name = None
|
|
432
|
+
if isinstance(child.func, ast.Name):
|
|
433
|
+
name = child.func.id
|
|
434
|
+
elif isinstance(child.func, ast.Attribute):
|
|
435
|
+
name = child.func.attr
|
|
436
|
+
if name and ("command" in name.lower() or name.lower().startswith("cmd_")
|
|
437
|
+
or name.lower().startswith("exec_")):
|
|
438
|
+
if any(is_tainted(a, assigns) for a in child.args):
|
|
439
|
+
return True
|
|
440
|
+
return False
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
FRAMEWORK_AUTH_MARKER = "__progmune_framework_auth__"
|
|
444
|
+
FORM_DELEGATION_MARKER = "__progmune_django_form__"
|
|
445
|
+
TOKEN_ISSUED_MARKER = "__progmune_token_issued__"
|
|
446
|
+
AUTH_CHECKED_MARKER = "__progmune_auth_checked__"
|
|
447
|
+
CREDENTIAL_CHECK_MARKER = "__progmune_credential_check__"
|
|
448
|
+
CMD_DYNAMIC_MARKER = "__progmune_command_dynamic__"
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def build_import_map(tree):
|
|
452
|
+
"""local name → qualified module path (from django.contrib.auth import login)."""
|
|
453
|
+
m = {}
|
|
454
|
+
for node in getattr(tree, 'body', []):
|
|
455
|
+
if isinstance(node, ast.ImportFrom):
|
|
456
|
+
mod = node.module or ""
|
|
457
|
+
for alias in node.names:
|
|
458
|
+
m[alias.asname or alias.name] = f"{mod}.{alias.name}"
|
|
459
|
+
elif isinstance(node, ast.Import):
|
|
460
|
+
for alias in node.names:
|
|
461
|
+
m[alias.asname or alias.name] = alias.name
|
|
462
|
+
return m
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def has_framework_auth(node, imports):
|
|
466
|
+
"""Calls resolving (via import resolution) to framework auth functions —
|
|
467
|
+
django.contrib.auth.login/authenticate, flask_login.*."""
|
|
468
|
+
for child in ast.walk(node):
|
|
469
|
+
if isinstance(child, ast.Call) and isinstance(child.func, ast.Name):
|
|
470
|
+
mod = imports.get(child.func.id, "")
|
|
471
|
+
if mod.startswith("django.contrib.auth") or mod.startswith("flask_login"):
|
|
472
|
+
return True
|
|
473
|
+
return False
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def has_form_delegation(node):
|
|
477
|
+
"""<Name>Form(request.POST) instantiated and .save() called on that
|
|
478
|
+
instance — Django form delegation (validation + hashing in framework)."""
|
|
479
|
+
assigns = collect_assignments(node)
|
|
480
|
+
for child in ast.walk(node):
|
|
481
|
+
if not isinstance(child, ast.Call):
|
|
482
|
+
continue
|
|
483
|
+
if isinstance(child.func, ast.Name) and child.func.id.endswith("Form") \
|
|
484
|
+
and any(is_request_rooted(a) for a in child.args):
|
|
485
|
+
# form = XForm(request.POST) — find its target names
|
|
486
|
+
return True
|
|
487
|
+
# form.save() on a variable assigned from an XForm(...) call
|
|
488
|
+
if isinstance(child.func, ast.Attribute) and child.func.attr == "save":
|
|
489
|
+
recv = child.func.value
|
|
490
|
+
if isinstance(recv, ast.Name):
|
|
491
|
+
v = assigns.get(recv.id)
|
|
492
|
+
if isinstance(v, ast.Call) and isinstance(v.func, ast.Name) \
|
|
493
|
+
and v.func.id.endswith("Form"):
|
|
494
|
+
return True
|
|
495
|
+
return False
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def has_token_issued(node):
|
|
499
|
+
"""set_cookie calls, assignments to token/session-named variables, or
|
|
500
|
+
dict literals containing a 'token' key — the function actually issues
|
|
501
|
+
session/token material (sess = {'token': ...} counts)."""
|
|
502
|
+
for child in ast.walk(node):
|
|
503
|
+
if isinstance(child, ast.Call):
|
|
504
|
+
name = None
|
|
505
|
+
if isinstance(child.func, ast.Name):
|
|
506
|
+
name = child.func.id
|
|
507
|
+
elif isinstance(child.func, ast.Attribute):
|
|
508
|
+
name = child.func.attr
|
|
509
|
+
if name in ("set_cookie", "set_secure_cookie"):
|
|
510
|
+
return True
|
|
511
|
+
if isinstance(child, ast.Assign):
|
|
512
|
+
for t in child.targets:
|
|
513
|
+
if isinstance(t, ast.Name) and re.search(r"token|session|jwt", t.id, re.I):
|
|
514
|
+
return True
|
|
515
|
+
if isinstance(child.value, ast.Dict):
|
|
516
|
+
for k in child.value.keys:
|
|
517
|
+
if isinstance(k, ast.Constant) and isinstance(k.value, str) \
|
|
518
|
+
and "token" in k.value.lower():
|
|
519
|
+
return True
|
|
520
|
+
return False
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def has_auth_checked(node):
|
|
524
|
+
"""An `is_authenticated` guard (if request.user.is_authenticated …)."""
|
|
525
|
+
for child in ast.walk(node):
|
|
526
|
+
if isinstance(child, ast.If):
|
|
527
|
+
if "is_authenticated" in ast.unparse(child.test):
|
|
528
|
+
return True
|
|
529
|
+
return False
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def has_credential_check(node):
|
|
533
|
+
"""`if token in <store>` / `if token == store.get(...)` — a parameter
|
|
534
|
+
verified against a credential store."""
|
|
535
|
+
for child in ast.walk(node):
|
|
536
|
+
if isinstance(child, ast.If) and isinstance(child.test, ast.Compare) \
|
|
537
|
+
and len(child.test.ops) == 1 \
|
|
538
|
+
and isinstance(child.test.ops[0], (ast.In, ast.Eq)):
|
|
539
|
+
s = ast.unparse(child.test)
|
|
540
|
+
if re.search(r'\btoken\b', s):
|
|
541
|
+
return True
|
|
542
|
+
return False
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _static_command_arg(a, assigns=None):
|
|
546
|
+
if isinstance(a, ast.Constant):
|
|
547
|
+
if isinstance(a.value, str):
|
|
548
|
+
return True
|
|
549
|
+
if isinstance(a.value, (list, tuple)) and all(isinstance(x, str) for x in a.value):
|
|
550
|
+
return True
|
|
551
|
+
if isinstance(a, (ast.List, ast.Tuple)):
|
|
552
|
+
return all(_static_command_arg(e, assigns) for e in a.elts)
|
|
553
|
+
if isinstance(a, ast.Name) and assigns is not None:
|
|
554
|
+
v = assigns.get(a.id)
|
|
555
|
+
if v is not None:
|
|
556
|
+
return _static_command_arg(v, assigns)
|
|
557
|
+
if isinstance(a, ast.IfExp):
|
|
558
|
+
return _static_command_arg(a.body, assigns) \
|
|
559
|
+
and _static_command_arg(a.orelse, assigns)
|
|
560
|
+
if isinstance(a, ast.Attribute):
|
|
561
|
+
if _root_name(a) in ("sys", "os", "path"):
|
|
562
|
+
return True
|
|
563
|
+
return False
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def has_dynamic_command(node):
|
|
567
|
+
"""subprocess/os command call with a NON-static command argument —
|
|
568
|
+
static string/list args (installers, fixed invocations) are not flagged.
|
|
569
|
+
Variables assigned from static strings count as static."""
|
|
570
|
+
assigns = collect_assignments(node)
|
|
571
|
+
for child in ast.walk(node):
|
|
572
|
+
if not isinstance(child, ast.Call):
|
|
573
|
+
continue
|
|
574
|
+
name = None
|
|
575
|
+
if isinstance(child.func, ast.Name):
|
|
576
|
+
name = child.func.id
|
|
577
|
+
elif isinstance(child.func, ast.Attribute):
|
|
578
|
+
name = child.func.attr
|
|
579
|
+
if name in ("system", "popen", "getoutput") and name not in ("getoutput",):
|
|
580
|
+
pass # os.system / os.popen / os.getoutput — qualify below
|
|
581
|
+
if name in ("run", "call", "check_call", "check_output", "Popen"):
|
|
582
|
+
if _root_name(child.func) != "subprocess":
|
|
583
|
+
continue
|
|
584
|
+
elif name in ("system", "popen", "getoutput"):
|
|
585
|
+
if _root_name(child.func) not in ("os", "commands", "pty"):
|
|
586
|
+
continue
|
|
587
|
+
else:
|
|
588
|
+
continue
|
|
589
|
+
if any(not _static_command_arg(a, assigns) for a in child.args):
|
|
590
|
+
return True
|
|
591
|
+
return False
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
COOKIE_AUTH_MARKER = "__progmune_cookie_authorization__"
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def build_module_constants(tree):
|
|
598
|
+
"""Module-level constant assignments (SECRET_COOKIE_KEY = '...')."""
|
|
599
|
+
consts = {}
|
|
600
|
+
for node in getattr(tree, 'body', []):
|
|
601
|
+
if isinstance(node, ast.Assign):
|
|
602
|
+
for t in node.targets:
|
|
603
|
+
if isinstance(t, ast.Name):
|
|
604
|
+
consts.setdefault(t.id, node.value)
|
|
605
|
+
return consts
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def build_global_constants(project_root):
|
|
609
|
+
"""Project-wide module-level constants, keyed 'module.path.NAME' —
|
|
610
|
+
resolves cross-module imports (from pygoat.settings import KEY)."""
|
|
611
|
+
consts = {}
|
|
612
|
+
for path in Path(project_root).rglob("*.py"):
|
|
613
|
+
if any(p.startswith('.') for p in path.parts):
|
|
614
|
+
continue
|
|
615
|
+
if 'node_modules' in path.parts or 'venv' in path.parts \
|
|
616
|
+
or 'site-packages' in path.parts:
|
|
617
|
+
continue
|
|
618
|
+
try:
|
|
619
|
+
tree = ast.parse(path.read_text(encoding='utf-8', errors='ignore'))
|
|
620
|
+
except Exception:
|
|
621
|
+
continue
|
|
622
|
+
rel = path.relative_to(project_root).with_suffix('').as_posix().replace('/', '.')
|
|
623
|
+
for name, val in build_module_constants(tree).items():
|
|
624
|
+
consts[f"{rel}.{name}"] = val
|
|
625
|
+
return consts
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _contains_cookies_ref(node):
|
|
629
|
+
if isinstance(node, ast.Attribute):
|
|
630
|
+
if node.attr == "COOKIES" and isinstance(node.value, ast.Name) \
|
|
631
|
+
and node.value.id == "request":
|
|
632
|
+
return True
|
|
633
|
+
return _contains_cookies_ref(node.value)
|
|
634
|
+
if isinstance(node, ast.Subscript):
|
|
635
|
+
return _contains_cookies_ref(node.value)
|
|
636
|
+
if isinstance(node, ast.Call):
|
|
637
|
+
return _contains_cookies_ref(node.func)
|
|
638
|
+
return False
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _cookie_tainted(node, assigns, depth=0):
|
|
642
|
+
"""Expression deriving from request.COOKIES — directly or via single-hop
|
|
643
|
+
assignment chains (cookie = request.COOKIES['x']; cookie.split('|')[0])."""
|
|
644
|
+
if node is None or depth > 3:
|
|
645
|
+
return False
|
|
646
|
+
if _contains_cookies_ref(node):
|
|
647
|
+
return True
|
|
648
|
+
if isinstance(node, ast.Name):
|
|
649
|
+
v = assigns.get(node.id)
|
|
650
|
+
return _cookie_tainted(v, assigns, depth + 1) if v is not None else False
|
|
651
|
+
if isinstance(node, ast.Attribute):
|
|
652
|
+
return _cookie_tainted(node.value, assigns, depth)
|
|
653
|
+
if isinstance(node, ast.Subscript):
|
|
654
|
+
return _cookie_tainted(node.value, assigns, depth)
|
|
655
|
+
if isinstance(node, ast.Call):
|
|
656
|
+
return _cookie_tainted(node.func, assigns, depth)
|
|
657
|
+
return False
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def has_cookie_authorization(node):
|
|
661
|
+
"""A client-controlled cookie value participates in a comparison or a
|
|
662
|
+
branch test — authorization decision made from cookie contents."""
|
|
663
|
+
assigns = collect_assignments(node)
|
|
664
|
+
for child in ast.walk(node):
|
|
665
|
+
if isinstance(child, (ast.If, ast.While)):
|
|
666
|
+
if _cookie_tainted(child.test, assigns):
|
|
667
|
+
return True
|
|
668
|
+
if isinstance(child, ast.Compare):
|
|
669
|
+
if _cookie_tainted(child.left, assigns) \
|
|
670
|
+
or any(_cookie_tainted(c, assigns) for c in child.comparators):
|
|
671
|
+
return True
|
|
672
|
+
return False
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
TEMPLATE_TAG_MARKER = "__progmune_template_tag__"
|
|
676
|
+
OWNERSHIP_CHECKED_MARKER = "__progmune_ownership_checked__"
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def has_template_tag_decorator(node):
|
|
680
|
+
"""@register.simple_tag / @register.inclusion_tag / @register.tag /
|
|
681
|
+
@register.filter — Django template-tag registration functions."""
|
|
682
|
+
for dec in getattr(node, 'decorator_list', []):
|
|
683
|
+
if isinstance(dec, ast.Call):
|
|
684
|
+
fn = dec.func
|
|
685
|
+
name = fn.attr if isinstance(fn, ast.Attribute) else \
|
|
686
|
+
(fn.id if isinstance(fn, ast.Name) else None)
|
|
687
|
+
if name in ("simple_tag", "inclusion_tag", "tag", "filter"):
|
|
688
|
+
return True
|
|
689
|
+
elif isinstance(dec, ast.Attribute) and dec.attr in ("simple_tag", "inclusion_tag", "tag", "filter"):
|
|
690
|
+
return True
|
|
691
|
+
return False
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def has_ownership_checked(node):
|
|
695
|
+
"""Inline ownership comparison: an identity-named parameter (user,
|
|
696
|
+
current_user, profile, author, owner) compared with ==/!= against another
|
|
697
|
+
expression — the ownership check the call-name interface cannot see."""
|
|
698
|
+
args = node.args
|
|
699
|
+
param_names = {a.arg for a in args.args + args.posonlyargs + args.kwonlyargs}
|
|
700
|
+
identity = {p for p in param_names
|
|
701
|
+
if re.search(r'^(user|current_user|profile|author|owner|request_user)$', p, re.I)}
|
|
702
|
+
if not identity:
|
|
703
|
+
return False
|
|
704
|
+
|
|
705
|
+
def refs_param(n, names, depth=0):
|
|
706
|
+
if depth > 2:
|
|
707
|
+
return False
|
|
708
|
+
if isinstance(n, ast.Name):
|
|
709
|
+
return n.id in names
|
|
710
|
+
if isinstance(n, ast.Attribute):
|
|
711
|
+
return refs_param(n.value, names, depth + 1)
|
|
712
|
+
return False
|
|
713
|
+
|
|
714
|
+
for child in ast.walk(node):
|
|
715
|
+
if isinstance(child, ast.Compare) and len(child.ops) == 1 \
|
|
716
|
+
and isinstance(child.ops[0], (ast.Eq, ast.NotEq)):
|
|
717
|
+
sides = [child.left] + list(child.comparators)
|
|
718
|
+
if any(refs_param(s, identity) for s in sides):
|
|
719
|
+
return True
|
|
720
|
+
# Per-user boolean state properties in branch tests — ownership
|
|
721
|
+
# checked through the data model (article.favorited, profile.following)
|
|
722
|
+
if isinstance(child, ast.If) \
|
|
723
|
+
and re.search(r'\.(favorited|following|is_owner|owned_by|can_edit|can_delete)\b',
|
|
724
|
+
ast.unparse(child.test)):
|
|
725
|
+
return True
|
|
726
|
+
return False
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def has_xss(node, unsafe_vars=None):
|
|
730
|
+
"""XSS check: a render/render_to_string call binds tainted request-derived
|
|
731
|
+
values into template variables that the template renders without escaping
|
|
732
|
+
({{ var|safe }} / autoescape off) — or mark_safe() applied to tainted
|
|
733
|
+
input directly."""
|
|
734
|
+
assigns = collect_assignments(node)
|
|
735
|
+
if unsafe_vars:
|
|
736
|
+
for child in ast.walk(node):
|
|
737
|
+
if not isinstance(child, ast.Call):
|
|
738
|
+
continue
|
|
739
|
+
name = None
|
|
740
|
+
if isinstance(child.func, ast.Name):
|
|
741
|
+
name = child.func.id
|
|
742
|
+
elif isinstance(child.func, ast.Attribute):
|
|
743
|
+
name = child.func.attr
|
|
744
|
+
if name not in ("render", "render_to_string"):
|
|
745
|
+
continue
|
|
746
|
+
tpl_idx = 1 if name == "render" else 0
|
|
747
|
+
if len(child.args) <= tpl_idx:
|
|
748
|
+
continue
|
|
749
|
+
tpl = child.args[tpl_idx]
|
|
750
|
+
tpl_name = tpl.value if isinstance(tpl, ast.Constant) \
|
|
751
|
+
and isinstance(tpl.value, str) else None
|
|
752
|
+
if not tpl_name:
|
|
753
|
+
continue
|
|
754
|
+
vars_ = None
|
|
755
|
+
for path, vs in unsafe_vars.items():
|
|
756
|
+
if path.endswith(tpl_name):
|
|
757
|
+
vars_ = vs
|
|
758
|
+
break
|
|
759
|
+
if not vars_:
|
|
760
|
+
continue
|
|
761
|
+
ctx = None
|
|
762
|
+
if len(child.args) > tpl_idx + 1:
|
|
763
|
+
ctx = child.args[tpl_idx + 1]
|
|
764
|
+
for kw in child.keywords:
|
|
765
|
+
if kw.arg == "context":
|
|
766
|
+
ctx = kw.value
|
|
767
|
+
if isinstance(ctx, ast.Name):
|
|
768
|
+
ctx = assigns.get(ctx.id)
|
|
769
|
+
if not isinstance(ctx, ast.Dict):
|
|
770
|
+
continue
|
|
771
|
+
for k, v in zip(ctx.keys, ctx.values):
|
|
772
|
+
key = k.value if isinstance(k, ast.Constant) else None
|
|
773
|
+
if key in vars_ and is_tainted(v, assigns):
|
|
774
|
+
return True
|
|
775
|
+
# mark_safe on tainted input is the same flaw expressed in the view
|
|
776
|
+
for child in ast.walk(node):
|
|
777
|
+
if isinstance(child, ast.Call) and isinstance(child.func, ast.Name) \
|
|
778
|
+
and child.func.id == "mark_safe":
|
|
779
|
+
if any(is_tainted(a, assigns) for a in child.args):
|
|
780
|
+
return True
|
|
781
|
+
return False
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def has_path_traversal(node):
|
|
785
|
+
"""Path-traversal check: a file sink whose path argument is tainted by
|
|
786
|
+
request-derived user input (directly or via single-hop assignment —
|
|
787
|
+
os.path.join chains resolve through assignment tracking)."""
|
|
788
|
+
assigns = collect_assignments(node)
|
|
789
|
+
for child in ast.walk(node):
|
|
790
|
+
if not isinstance(child, ast.Call) or not is_file_sink_call(child):
|
|
791
|
+
continue
|
|
792
|
+
if any(is_tainted(a, assigns) for a in child.args):
|
|
793
|
+
return True
|
|
794
|
+
if any(is_tainted(k.value, assigns) for k in child.keywords):
|
|
795
|
+
return True
|
|
796
|
+
# Path(...).read_text(): the tainted path lives in the receiver —
|
|
797
|
+
# either the direct call or a variable assigned from a Path(...) call.
|
|
798
|
+
if isinstance(child.func, ast.Attribute) and child.func.attr in ("read_text", "read_bytes"):
|
|
799
|
+
recv = child.func.value
|
|
800
|
+
if isinstance(recv, ast.Call) and any(is_tainted(a, assigns) for a in recv.args):
|
|
801
|
+
return True
|
|
802
|
+
if isinstance(recv, ast.Name):
|
|
803
|
+
v = assigns.get(recv.id)
|
|
804
|
+
if isinstance(v, ast.Call) and isinstance(v.func, ast.Name) \
|
|
805
|
+
and v.func.id == "Path" \
|
|
806
|
+
and any(is_tainted(a, assigns) for a in v.args):
|
|
807
|
+
return True
|
|
808
|
+
return False
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def extract_calls(node, unsafe_vars=None, imports=None, module_constants=None, global_constants=None):
|
|
812
|
+
"""Extract function call names from a function body (first-level only).
|
|
813
|
+
|
|
814
|
+
Attribute calls emit the full qualified chain (e.g. user.change_password,
|
|
815
|
+
User.objects.create_user) so rules can distinguish framework-delegated
|
|
816
|
+
calls from custom same-named functions. Bare-name calls stay as-is.
|
|
817
|
+
Source-level checks emit synthetic marker calls: unparameterized SQL,
|
|
818
|
+
SSRF, path traversal, XSS, eval, hardcoded secrets, command flow, CSRF,
|
|
819
|
+
and the semantic markers for framework auth / form delegation / token
|
|
820
|
+
issuance / auth guards / credential checks / dynamic commands."""
|
|
821
|
+
calls = []
|
|
822
|
+
seen = set()
|
|
823
|
+
for child in ast.walk(node):
|
|
824
|
+
if isinstance(child, ast.Call):
|
|
825
|
+
func = child.func
|
|
826
|
+
name = None
|
|
827
|
+
if isinstance(func, ast.Name):
|
|
828
|
+
name = func.id
|
|
829
|
+
elif isinstance(func, ast.Attribute):
|
|
830
|
+
# Walk the attribute chain: User.objects.create_user
|
|
831
|
+
parts = []
|
|
832
|
+
cur = func
|
|
833
|
+
while isinstance(cur, ast.Attribute):
|
|
834
|
+
parts.append(cur.attr)
|
|
835
|
+
cur = cur.value
|
|
836
|
+
if isinstance(cur, ast.Name):
|
|
837
|
+
parts.append(cur.id)
|
|
838
|
+
name = ".".join(reversed(parts)) if len(parts) > 1 else func.attr
|
|
839
|
+
if name and name not in seen:
|
|
840
|
+
seen.add(name)
|
|
841
|
+
calls.append(name)
|
|
842
|
+
if has_unparameterized_sql(node) and SQL_MARKER not in calls:
|
|
843
|
+
calls.append(SQL_MARKER)
|
|
844
|
+
if has_ssrf(node) and SSRF_MARKER not in calls:
|
|
845
|
+
calls.append(SSRF_MARKER)
|
|
846
|
+
if has_path_traversal(node) and PATH_MARKER not in calls:
|
|
847
|
+
calls.append(PATH_MARKER)
|
|
848
|
+
if unsafe_vars and has_xss(node, unsafe_vars) and XSS_MARKER not in calls:
|
|
849
|
+
calls.append(XSS_MARKER)
|
|
850
|
+
if has_ssti(node) and SSTI_MARKER not in calls:
|
|
851
|
+
calls.append(SSTI_MARKER)
|
|
852
|
+
if has_xxe(node) and XXE_MARKER not in calls:
|
|
853
|
+
calls.append(XXE_MARKER)
|
|
854
|
+
if has_dynamic_eval(node) and EVAL_MARKER not in calls:
|
|
855
|
+
calls.append(EVAL_MARKER)
|
|
856
|
+
if has_hardcoded_secret(node, module_constants, imports, global_constants) and SECRET_MARKER not in calls:
|
|
857
|
+
calls.append(SECRET_MARKER)
|
|
858
|
+
if has_cookie_authorization(node) and COOKIE_AUTH_MARKER not in calls:
|
|
859
|
+
calls.append(COOKIE_AUTH_MARKER)
|
|
860
|
+
if has_template_tag_decorator(node) and TEMPLATE_TAG_MARKER not in calls:
|
|
861
|
+
calls.append(TEMPLATE_TAG_MARKER)
|
|
862
|
+
if has_ownership_checked(node) and OWNERSHIP_CHECKED_MARKER not in calls:
|
|
863
|
+
calls.append(OWNERSHIP_CHECKED_MARKER)
|
|
864
|
+
if has_command_taint_flow(node) and CMD_FLOW_MARKER not in calls:
|
|
865
|
+
calls.append(CMD_FLOW_MARKER)
|
|
866
|
+
if has_csrf_exempt(node) and CSRF_MARKER not in calls:
|
|
867
|
+
calls.append(CSRF_MARKER)
|
|
868
|
+
if has_get_state_change(node) and GET_STATE_MARKER not in calls:
|
|
869
|
+
calls.append(GET_STATE_MARKER)
|
|
870
|
+
if imports and has_framework_auth(node, imports) and FRAMEWORK_AUTH_MARKER not in calls:
|
|
871
|
+
calls.append(FRAMEWORK_AUTH_MARKER)
|
|
872
|
+
if has_form_delegation(node) and FORM_DELEGATION_MARKER not in calls:
|
|
873
|
+
calls.append(FORM_DELEGATION_MARKER)
|
|
874
|
+
if has_token_issued(node) and TOKEN_ISSUED_MARKER not in calls:
|
|
875
|
+
calls.append(TOKEN_ISSUED_MARKER)
|
|
876
|
+
if has_auth_checked(node) and AUTH_CHECKED_MARKER not in calls:
|
|
877
|
+
calls.append(AUTH_CHECKED_MARKER)
|
|
878
|
+
if has_credential_check(node) and CREDENTIAL_CHECK_MARKER not in calls:
|
|
879
|
+
calls.append(CREDENTIAL_CHECK_MARKER)
|
|
880
|
+
if has_dynamic_command(node) and CMD_DYNAMIC_MARKER not in calls:
|
|
881
|
+
calls.append(CMD_DYNAMIC_MARKER)
|
|
882
|
+
return calls
|
|
883
|
+
|
|
884
|
+
# ── Protocol annotation extraction ──
|
|
885
|
+
|
|
886
|
+
def extract_protocol_from_decorators(node):
|
|
887
|
+
"""
|
|
888
|
+
Extract protocol state annotation from decorators.
|
|
889
|
+
Supports: @progmune(namespace="auth", pre=["S1"], post=["S2"])
|
|
890
|
+
@protocol(namespace="auth", pre_states=[...], post_states=[...])
|
|
891
|
+
"""
|
|
892
|
+
for dec in getattr(node, 'decorator_list', []):
|
|
893
|
+
dec_str = None
|
|
894
|
+
try:
|
|
895
|
+
dec_str = ast.unparse(dec)
|
|
896
|
+
except Exception:
|
|
897
|
+
continue
|
|
898
|
+
if not dec_str:
|
|
899
|
+
continue
|
|
900
|
+
# Match @progmune(...) or @protocol(...) — unparse drops the @ prefix
|
|
901
|
+
m = re.search(r'(?:progmune|protocol)\s*\((.*)\)', dec_str)
|
|
902
|
+
if m:
|
|
903
|
+
kwargs_str = m.group(1)
|
|
904
|
+
kwargs = {}
|
|
905
|
+
# Parse keyword arguments: key=value, key="value", key=[...]
|
|
906
|
+
for match in re.finditer(
|
|
907
|
+
r'''(\w+)\s*=\s*(?:(\[[^\]]*\])|"([^"]*)"|'([^']*)'|(\w+))''',
|
|
908
|
+
kwargs_str
|
|
909
|
+
):
|
|
910
|
+
key = match.group(1)
|
|
911
|
+
if match.group(2): # list [...]
|
|
912
|
+
# Handle both single and double quoted items inside list
|
|
913
|
+
items = re.findall(r'''["']([^"']*)["']''', match.group(2))
|
|
914
|
+
if not items:
|
|
915
|
+
# Also try bare words in list
|
|
916
|
+
items = [w.strip() for w in match.group(2).strip('[]').split(',') if w.strip()]
|
|
917
|
+
kwargs[key] = items
|
|
918
|
+
elif match.group(3): # "string"
|
|
919
|
+
kwargs[key] = match.group(3)
|
|
920
|
+
elif match.group(4): # 'string'
|
|
921
|
+
kwargs[key] = match.group(4)
|
|
922
|
+
elif match.group(5): # bare word
|
|
923
|
+
kwargs[key] = match.group(5)
|
|
924
|
+
return {
|
|
925
|
+
"namespace": kwargs.get("namespace"),
|
|
926
|
+
"pre_states": kwargs.get("pre_states") or kwargs.get("pre") or [],
|
|
927
|
+
"post_states": kwargs.get("post_states") or kwargs.get("post") or [],
|
|
928
|
+
"invalidate": kwargs.get("invalidate") or kwargs.get("inv") or [],
|
|
929
|
+
}
|
|
930
|
+
return None
|
|
931
|
+
|
|
932
|
+
# ── Docstring metadata extraction ──
|
|
933
|
+
|
|
934
|
+
def extract_docstring_meta(node):
|
|
935
|
+
"""Extract metadata from function docstring @ tags."""
|
|
936
|
+
doc = ast.get_docstring(node)
|
|
937
|
+
if not doc:
|
|
938
|
+
return {}
|
|
939
|
+
meta = {}
|
|
940
|
+
patterns = {
|
|
941
|
+
"purpose": r'@purpose\s+(.+)',
|
|
942
|
+
"description": r'@description\s+(.+)',
|
|
943
|
+
"requires": r'@requires\s+(.+)',
|
|
944
|
+
"produces": r'@produces\s+(.+)',
|
|
945
|
+
"useWhen": r'@useWhen\s+(.+)',
|
|
946
|
+
}
|
|
947
|
+
for key, pat in patterns.items():
|
|
948
|
+
m = re.search(pat, doc)
|
|
949
|
+
if m:
|
|
950
|
+
meta[key] = m.group(1).strip()
|
|
951
|
+
|
|
952
|
+
# @tags: comma-separated
|
|
953
|
+
tags_match = re.search(r'@tags\s+(.+)', doc)
|
|
954
|
+
if tags_match:
|
|
955
|
+
meta["tags"] = [t.strip() for t in tags_match.group(1).split(",")]
|
|
956
|
+
|
|
957
|
+
# @inputs / @outputs: function names
|
|
958
|
+
for dir_key in ("inputs", "outputs"):
|
|
959
|
+
m = re.search(rf'@{dir_key}\s+(.+)', doc)
|
|
960
|
+
if m:
|
|
961
|
+
meta[dir_key] = [t.strip() for t in m.group(1).split(",")]
|
|
962
|
+
|
|
963
|
+
return meta
|
|
964
|
+
|
|
965
|
+
# ── Is a function exported? ──
|
|
966
|
+
|
|
967
|
+
def is_exported(name, parent_class=None):
|
|
968
|
+
"""Module-level functions are exported unless _-prefixed."""
|
|
969
|
+
if parent_class:
|
|
970
|
+
return not name.startswith('_')
|
|
971
|
+
return not name.startswith('_')
|
|
972
|
+
|
|
973
|
+
# ── File-level extraction ──
|
|
974
|
+
|
|
975
|
+
def extract_functions_from_file(filepath: str, root_dir: str, unsafe_vars=None, global_constants=None):
|
|
976
|
+
"""Extract all functions and class methods from a Python file."""
|
|
977
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
978
|
+
try:
|
|
979
|
+
tree = ast.parse(f.read())
|
|
980
|
+
except SyntaxError:
|
|
981
|
+
return []
|
|
982
|
+
|
|
983
|
+
funcs = []
|
|
984
|
+
rel_path = os.path.relpath(filepath, root_dir)
|
|
985
|
+
imports = build_import_map(tree)
|
|
986
|
+
module_constants = build_module_constants(tree)
|
|
987
|
+
|
|
988
|
+
# Classes declaring DRF permission/authentication class attributes
|
|
989
|
+
# (permission_classes = (...)) — their methods are framework-guarded.
|
|
990
|
+
drf_permission_classes = set()
|
|
991
|
+
for cls in ast.walk(tree):
|
|
992
|
+
if not isinstance(cls, ast.ClassDef):
|
|
993
|
+
continue
|
|
994
|
+
for stmt in cls.body:
|
|
995
|
+
if isinstance(stmt, ast.Assign):
|
|
996
|
+
for t in stmt.targets:
|
|
997
|
+
if isinstance(t, ast.Name) and t.id in (
|
|
998
|
+
"permission_classes", "authentication_classes"):
|
|
999
|
+
drf_permission_classes.add(cls.name)
|
|
1000
|
+
|
|
1001
|
+
# Single-pass parent map (O(n)). The old per-node full-tree walk was O(n²)
|
|
1002
|
+
# and hung on large real-world files (e.g. fastapi's bigger modules).
|
|
1003
|
+
parent_map = {}
|
|
1004
|
+
for parent in ast.walk(tree):
|
|
1005
|
+
for child in ast.iter_child_nodes(parent):
|
|
1006
|
+
parent_map[child] = parent
|
|
1007
|
+
|
|
1008
|
+
def enclosing_class(node):
|
|
1009
|
+
cur = parent_map.get(node)
|
|
1010
|
+
while cur is not None:
|
|
1011
|
+
if isinstance(cur, ast.ClassDef):
|
|
1012
|
+
return cur.name
|
|
1013
|
+
cur = parent_map.get(cur)
|
|
1014
|
+
return None
|
|
1015
|
+
|
|
1016
|
+
for node in ast.walk(tree):
|
|
1017
|
+
parent_class = None
|
|
1018
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
1019
|
+
parent_class = enclosing_class(node)
|
|
1020
|
+
name = node.name
|
|
1021
|
+
|
|
1022
|
+
# Params
|
|
1023
|
+
params = []
|
|
1024
|
+
for arg in node.args.args:
|
|
1025
|
+
if arg.arg == 'self' or arg.arg == 'cls':
|
|
1026
|
+
continue
|
|
1027
|
+
ptype = get_annotation(arg.annotation) if arg.annotation else "any"
|
|
1028
|
+
params.append({"name": arg.arg, "type": ptype})
|
|
1029
|
+
|
|
1030
|
+
return_type = get_annotation(node.returns) if node.returns else "any"
|
|
1031
|
+
calls = extract_calls(node, unsafe_vars, imports, module_constants, global_constants)
|
|
1032
|
+
# Class-level framework guards: DRF permission classes, auth machinery
|
|
1033
|
+
if parent_class:
|
|
1034
|
+
if parent_class in drf_permission_classes \
|
|
1035
|
+
and "__progmune_drf_permissions__" not in calls:
|
|
1036
|
+
calls = calls + ["__progmune_drf_permissions__"]
|
|
1037
|
+
if re.search(r'authenticat', parent_class, re.I) \
|
|
1038
|
+
and "__progmune_auth_machinery__" not in calls:
|
|
1039
|
+
calls = calls + ["__progmune_auth_machinery__"]
|
|
1040
|
+
exported = is_exported(name, parent_class)
|
|
1041
|
+
protocol = extract_protocol_from_decorators(node)
|
|
1042
|
+
doc_meta = extract_docstring_meta(node)
|
|
1043
|
+
|
|
1044
|
+
func_info = {
|
|
1045
|
+
"name": f"{parent_class}.{name}" if parent_class else name,
|
|
1046
|
+
"params": params,
|
|
1047
|
+
"returnType": return_type,
|
|
1048
|
+
"file": rel_path,
|
|
1049
|
+
"calls": calls,
|
|
1050
|
+
"exported": exported,
|
|
1051
|
+
"external": False,
|
|
1052
|
+
"description": doc_meta.get("description") or doc_meta.get("purpose") or "",
|
|
1053
|
+
"purpose": doc_meta.get("purpose") or "",
|
|
1054
|
+
"tags": doc_meta.get("tags") or [],
|
|
1055
|
+
"inputs": doc_meta.get("inputs") or [],
|
|
1056
|
+
"outputs": doc_meta.get("outputs") or [],
|
|
1057
|
+
"requires": doc_meta.get("requires") or "",
|
|
1058
|
+
"produces": doc_meta.get("produces") or "",
|
|
1059
|
+
"useWhen": doc_meta.get("useWhen") or "",
|
|
1060
|
+
"language": "python",
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if protocol:
|
|
1064
|
+
func_info["protocol"] = protocol
|
|
1065
|
+
|
|
1066
|
+
funcs.append(func_info)
|
|
1067
|
+
|
|
1068
|
+
return funcs
|
|
1069
|
+
|
|
1070
|
+
# ── Main ──
|
|
1071
|
+
|
|
1072
|
+
def extract_ir(project_root: str):
|
|
1073
|
+
"""Walk all .py files and extract IR."""
|
|
1074
|
+
all_funcs = []
|
|
1075
|
+
unsafe_vars = scan_unsafe_template_vars(project_root)
|
|
1076
|
+
global_constants = build_global_constants(project_root)
|
|
1077
|
+
for path in Path(project_root).rglob("*.py"):
|
|
1078
|
+
# Skip hidden dirs, node_modules, site-packages
|
|
1079
|
+
parts = path.parts
|
|
1080
|
+
if any(p.startswith('.') for p in parts):
|
|
1081
|
+
continue
|
|
1082
|
+
if 'node_modules' in parts or 'site-packages' in parts or 'venv' in parts:
|
|
1083
|
+
continue
|
|
1084
|
+
# Skip test files — tests are not production surface for a security scanner.
|
|
1085
|
+
# Checks run on the path RELATIVE to project_root (absolute parts would
|
|
1086
|
+
# accidentally match the repo's parent dirs, e.g. .../benchmarks/...).
|
|
1087
|
+
rel_parts = path.relative_to(Path(project_root)).parts
|
|
1088
|
+
fname = path.name
|
|
1089
|
+
if fname.startswith('test_') or fname.endswith('_test.py'):
|
|
1090
|
+
continue
|
|
1091
|
+
if 'tests' in rel_parts or 'test' in rel_parts[:-1]:
|
|
1092
|
+
continue
|
|
1093
|
+
# Skip docs/example/benchmark/script dirs — not shipped production surface
|
|
1094
|
+
nonsurface = ('docs', 'docs_src', 'examples', 'benchmarks', 'scripts')
|
|
1095
|
+
if any(p in nonsurface for p in rel_parts[:-1]):
|
|
1096
|
+
continue
|
|
1097
|
+
all_funcs.extend(extract_functions_from_file(str(path), project_root, unsafe_vars, global_constants))
|
|
1098
|
+
return all_funcs
|
|
1099
|
+
|
|
1100
|
+
if __name__ == "__main__":
|
|
1101
|
+
if len(sys.argv) < 2:
|
|
1102
|
+
print("Usage: python tools/extract_ir.py <project_root> [output_path]")
|
|
1103
|
+
sys.exit(1)
|
|
1104
|
+
root = sys.argv[1]
|
|
1105
|
+
output = sys.argv[2] if len(sys.argv) > 2 else "ir.json"
|
|
1106
|
+
functions = extract_ir(root)
|
|
1107
|
+
with open(output, "w", encoding="utf-8") as f:
|
|
1108
|
+
json.dump(functions, f, indent=2, ensure_ascii=False)
|
|
1109
|
+
print(f"✅ IR extracted: {len(functions)} functions → {output}")
|