progmune-runtime 3.7.9 → 3.7.11

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.
@@ -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}")