@orionscanner/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +248 -0
  3. package/assets/logo.png +0 -0
  4. package/bin/orion.js +48 -0
  5. package/docs/API_REFERENCE.md +211 -0
  6. package/docs/ARCHITECTURE.md +183 -0
  7. package/docs/CHANGELOG.md +112 -0
  8. package/docs/CONTRIBUTING.md +157 -0
  9. package/docs/ENGINE_CATALOG.md +87 -0
  10. package/docs/GETTING_STARTED.md +226 -0
  11. package/package.json +48 -0
  12. package/pyproject.toml +16 -0
  13. package/src/orion/__init__.py +0 -0
  14. package/src/orion/__pycache__/__init__.cpython-313.pyc +0 -0
  15. package/src/orion/engine/__init__.py +17 -0
  16. package/src/orion/engine/__pycache__/__init__.cpython-313.pyc +0 -0
  17. package/src/orion/engine/__pycache__/config.cpython-313.pyc +0 -0
  18. package/src/orion/engine/__pycache__/core.cpython-313.pyc +0 -0
  19. package/src/orion/engine/__pycache__/ir.cpython-313.pyc +0 -0
  20. package/src/orion/engine/__pycache__/languages.cpython-313.pyc +0 -0
  21. package/src/orion/engine/__pycache__/registry.cpython-313.pyc +0 -0
  22. package/src/orion/engine/__pycache__/results.cpython-313.pyc +0 -0
  23. package/src/orion/engine/__pycache__/runner.cpython-313.pyc +0 -0
  24. package/src/orion/engine/api_security/__pycache__/engine.cpython-313.pyc +0 -0
  25. package/src/orion/engine/api_security/engine.py +125 -0
  26. package/src/orion/engine/architecture/__pycache__/engine.cpython-313.pyc +0 -0
  27. package/src/orion/engine/architecture/engine.py +463 -0
  28. package/src/orion/engine/authorization/__pycache__/engine.cpython-313.pyc +0 -0
  29. package/src/orion/engine/authorization/engine.py +337 -0
  30. package/src/orion/engine/callgraph/__pycache__/engine.cpython-313.pyc +0 -0
  31. package/src/orion/engine/callgraph/engine.py +236 -0
  32. package/src/orion/engine/cfg/__pycache__/engine.cpython-313.pyc +0 -0
  33. package/src/orion/engine/cfg/engine.py +509 -0
  34. package/src/orion/engine/chaos/__pycache__/engine.cpython-313.pyc +0 -0
  35. package/src/orion/engine/chaos/engine.py +268 -0
  36. package/src/orion/engine/chaos_config/__pycache__/engine.cpython-313.pyc +0 -0
  37. package/src/orion/engine/chaos_config/engine.py +322 -0
  38. package/src/orion/engine/complexity/__pycache__/engine.cpython-313.pyc +0 -0
  39. package/src/orion/engine/complexity/engine.py +144 -0
  40. package/src/orion/engine/compliance/__pycache__/engine.cpython-313.pyc +0 -0
  41. package/src/orion/engine/compliance/engine.py +325 -0
  42. package/src/orion/engine/config.py +41 -0
  43. package/src/orion/engine/config_engine/__pycache__/engine.cpython-313.pyc +0 -0
  44. package/src/orion/engine/config_engine/engine.py +364 -0
  45. package/src/orion/engine/config_validation/__pycache__/engine.cpython-313.pyc +0 -0
  46. package/src/orion/engine/config_validation/engine.py +313 -0
  47. package/src/orion/engine/container/__pycache__/engine.cpython-313.pyc +0 -0
  48. package/src/orion/engine/container/engine.py +546 -0
  49. package/src/orion/engine/contract_probe/__pycache__/engine.cpython-313.pyc +0 -0
  50. package/src/orion/engine/contract_probe/engine.py +316 -0
  51. package/src/orion/engine/core.py +75 -0
  52. package/src/orion/engine/crypto_guard/__pycache__/engine.cpython-313.pyc +0 -0
  53. package/src/orion/engine/crypto_guard/engine.py +134 -0
  54. package/src/orion/engine/data_residency/__pycache__/engine.cpython-313.pyc +0 -0
  55. package/src/orion/engine/data_residency/engine.py +238 -0
  56. package/src/orion/engine/dataflow/__pycache__/engine.cpython-313.pyc +0 -0
  57. package/src/orion/engine/dataflow/engine.py +398 -0
  58. package/src/orion/engine/dead_import/__pycache__/engine.cpython-313.pyc +0 -0
  59. package/src/orion/engine/dead_import/engine.py +293 -0
  60. package/src/orion/engine/deps/__pycache__/engine.cpython-313.pyc +0 -0
  61. package/src/orion/engine/deps/engine.py +244 -0
  62. package/src/orion/engine/dispatcher.py +350 -0
  63. package/src/orion/engine/docs/AUDIT_PROMPT.md +1248 -0
  64. package/src/orion/engine/duplication/__pycache__/engine.cpython-313.pyc +0 -0
  65. package/src/orion/engine/duplication/engine.py +120 -0
  66. package/src/orion/engine/error_handling/__pycache__/engine.cpython-313.pyc +0 -0
  67. package/src/orion/engine/error_handling/engine.py +256 -0
  68. package/src/orion/engine/fp_learn/__pycache__/engine.cpython-313.pyc +0 -0
  69. package/src/orion/engine/fp_learn/engine.py +248 -0
  70. package/src/orion/engine/hunter/__pycache__/engine.cpython-313.pyc +0 -0
  71. package/src/orion/engine/hunter/engine.py +324 -0
  72. package/src/orion/engine/iac_security/__pycache__/engine.cpython-313.pyc +0 -0
  73. package/src/orion/engine/iac_security/engine.py +218 -0
  74. package/src/orion/engine/ir.py +22 -0
  75. package/src/orion/engine/languages.py +454 -0
  76. package/src/orion/engine/license/__pycache__/engine.cpython-313.pyc +0 -0
  77. package/src/orion/engine/license/engine.py +172 -0
  78. package/src/orion/engine/lint/__pycache__/engine.cpython-313.pyc +0 -0
  79. package/src/orion/engine/lint/engine.py +558 -0
  80. package/src/orion/engine/llm_security/__pycache__/engine.cpython-313.pyc +0 -0
  81. package/src/orion/engine/llm_security/engine.py +125 -0
  82. package/src/orion/engine/logging_c/__pycache__/engine.cpython-313.pyc +0 -0
  83. package/src/orion/engine/logging_c/engine.py +164 -0
  84. package/src/orion/engine/noise/__pycache__/engine.cpython-313.pyc +0 -0
  85. package/src/orion/engine/noise/engine.py +140 -0
  86. package/src/orion/engine/orphan/__pycache__/engine.cpython-313.pyc +0 -0
  87. package/src/orion/engine/orphan/engine.py +244 -0
  88. package/src/orion/engine/perf_guard/__pycache__/engine.cpython-313.pyc +0 -0
  89. package/src/orion/engine/perf_guard/engine.py +345 -0
  90. package/src/orion/engine/prompt_security/__pycache__/engine.cpython-313.pyc +0 -0
  91. package/src/orion/engine/prompt_security/detectors/__init__.py +4 -0
  92. package/src/orion/engine/prompt_security/engine.py +170 -0
  93. package/src/orion/engine/provenance/__pycache__/engine.cpython-313.pyc +0 -0
  94. package/src/orion/engine/provenance/engine.py +242 -0
  95. package/src/orion/engine/registry.py +55 -0
  96. package/src/orion/engine/regression/__pycache__/engine.cpython-313.pyc +0 -0
  97. package/src/orion/engine/regression/engine.py +213 -0
  98. package/src/orion/engine/results.py +21 -0
  99. package/src/orion/engine/runner.py +1310 -0
  100. package/src/orion/engine/sandbox_escape/__pycache__/engine.cpython-313.pyc +0 -0
  101. package/src/orion/engine/sandbox_escape/engine.py +284 -0
  102. package/src/orion/engine/sast/__pycache__/engine.cpython-313.pyc +0 -0
  103. package/src/orion/engine/sast/engine.py +217 -0
  104. package/src/orion/engine/sast/patterns/__init__.py +513 -0
  105. package/src/orion/engine/sast/patterns/__pycache__/__init__.cpython-313.pyc +0 -0
  106. package/src/orion/engine/secrets/__pycache__/engine.cpython-313.pyc +0 -0
  107. package/src/orion/engine/secrets/engine.py +77 -0
  108. package/src/orion/engine/secrets/patterns/__init__.py +74 -0
  109. package/src/orion/engine/secrets/patterns/__pycache__/__init__.cpython-313.pyc +0 -0
  110. package/src/orion/engine/sensitive_data/__pycache__/engine.cpython-313.pyc +0 -0
  111. package/src/orion/engine/sensitive_data/engine.py +143 -0
  112. package/src/orion/engine/state/__pycache__/engine.cpython-313.pyc +0 -0
  113. package/src/orion/engine/state/engine.py +218 -0
  114. package/src/orion/engine/supply_chain_sbom/__pycache__/engine.cpython-313.pyc +0 -0
  115. package/src/orion/engine/supply_chain_sbom/engine.py +290 -0
  116. package/src/orion/engine/symbol/__pycache__/engine.cpython-313.pyc +0 -0
  117. package/src/orion/engine/symbol/engine.py +463 -0
  118. package/src/orion/engine/taint/__pycache__/engine.cpython-313.pyc +0 -0
  119. package/src/orion/engine/taint/engine.py +464 -0
  120. package/src/orion/engine/test_coverage/__pycache__/engine.cpython-313.pyc +0 -0
  121. package/src/orion/engine/test_coverage/engine.py +445 -0
  122. package/src/orion/engine/threat_model/__pycache__/engine.cpython-313.pyc +0 -0
  123. package/src/orion/engine/threat_model/engine.py +126 -0
  124. package/src/orion/engine/wiring_trace/__pycache__/engine.cpython-313.pyc +0 -0
  125. package/src/orion/engine/wiring_trace/engine.py +200 -0
  126. package/src/orion_engine.egg-info/PKG-INFO +7 -0
  127. package/src/orion_engine.egg-info/SOURCES.txt +65 -0
  128. package/src/orion_engine.egg-info/dependency_links.txt +1 -0
  129. package/src/orion_engine.egg-info/entry_points.txt +2 -0
  130. package/src/orion_engine.egg-info/top_level.txt +1 -0
@@ -0,0 +1,337 @@
1
+ """Authorization engine for detecting missing authentication on routes."""
2
+ import ast
3
+ import re
4
+ from typing import Dict, List, Set, Optional, Any, Tuple
5
+ from dataclasses import dataclass, field
6
+ from orion.engine.config import EngineConfig, ResourceLimits
7
+ from orion.engine.ir import OrionIR
8
+ from orion.engine.results import EngineResult, Finding
9
+
10
+
11
+ AUTH_DECORATORS = {
12
+ "flask": [
13
+ "login_required", "permission_required", "roles_required",
14
+ "auth_required", "token_required", "jwt_required",
15
+ "fresh_login_required", "current_user",
16
+ ],
17
+ "fastapi": [
18
+ "Depends", "Security", "get_current_user", "get_current_active_user",
19
+ "oauth2_scheme", "http_bearer", "api_key_header",
20
+ ],
21
+ "django": [
22
+ "login_required", "permission_required", "user_passes_test",
23
+ "staff_member_required", "superuser_required",
24
+ "LoginRequiredMixin", "PermissionRequiredMixin", "UserPassesTestMixin",
25
+ ],
26
+ "bottle": ["auth_required", "require_auth"],
27
+ "tornado": ["authenticated", "auth_required"],
28
+ "starlette": ["Depends", "Security"],
29
+ "generic": ["requires_auth", "requires_login", "requires_permission",
30
+ "check_auth", "verify_token", "validate_user"],
31
+ }
32
+
33
+ ROUTE_DECORATORS = {
34
+ "flask": ["route", "get", "post", "put", "delete", "patch", "head", "options"],
35
+ "fastapi": ["get", "post", "put", "delete", "patch", "head", "options", "api_route"],
36
+ "django": ["path", "re_path", "url"],
37
+ "bottle": ["route", "get", "post", "put", "delete", "patch"],
38
+ "tornado": ["route"],
39
+ "starlette": ["route", "get", "post", "put", "delete", "patch"],
40
+ "aiohttp": ["get", "post", "put", "delete", "patch"],
41
+ }
42
+
43
+ EXEMPT_PATTERNS = [
44
+ r"/health", r"/ready", r"/live", r"/metrics", r"/status",
45
+ r"/ping", r"/version", r"/favicon", r"/robots\.txt",
46
+ r"/static/", r"/media/", r"/assets/",
47
+ r"\.well-known/", r"/docs", r"/redoc", r"/openapi",
48
+ ]
49
+
50
+
51
+ @dataclass
52
+ class RouteInfo:
53
+ path: str
54
+ methods: List[str]
55
+ decorators: List[str]
56
+ lineno: int
57
+ filename: str
58
+ has_auth: bool = False
59
+ auth_decorators: List[str] = field(default_factory=list)
60
+ is_exempt: bool = False
61
+
62
+
63
+ class AuthVisitor(ast.NodeVisitor):
64
+ def __init__(self, filename: str, source_lines: List[str]):
65
+ self.filename = filename
66
+ self.source_lines = source_lines
67
+ self.findings: List[Finding] = []
68
+ self.routes: List[RouteInfo] = []
69
+ self.imports: Dict[str, str] = {}
70
+ self.current_class: Optional[str] = None
71
+ self.blueprint_routes: List[RouteInfo] = []
72
+
73
+ def get_snippet(self, node: ast.AST, context: int = 3) -> str:
74
+ start = max(0, node.lineno - 1 - context)
75
+ end = min(len(self.source_lines),
76
+ getattr(node, 'end_lineno', node.lineno) + context if hasattr(node, 'end_lineno') else node.lineno + context)
77
+ return "\n".join(self.source_lines[start:end])
78
+
79
+ def visit_Import(self, node: ast.Import):
80
+ for alias in node.names:
81
+ self.imports[alias.asname or alias.name] = alias.name
82
+ self.generic_visit(node)
83
+
84
+ def visit_ImportFrom(self, node: ast.ImportFrom):
85
+ if node.module:
86
+ for alias in node.names:
87
+ self.imports[alias.asname or alias.name] = f"{node.module}.{alias.name}"
88
+ self.generic_visit(node)
89
+
90
+ def visit_ClassDef(self, node: ast.ClassDef):
91
+ old_class = self.current_class
92
+ self.current_class = node.name
93
+ self.generic_visit(node)
94
+ self.current_class = old_class
95
+
96
+ def visit_FunctionDef(self, node: ast.FunctionDef):
97
+ self._check_function(node)
98
+ self.generic_visit(node)
99
+
100
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
101
+ self._check_function(node)
102
+ self.generic_visit(node)
103
+
104
+ def _check_function(self, node: ast.FunctionDef):
105
+ route_info = self._extract_route(node)
106
+ if route_info:
107
+ self._analyze_route(route_info)
108
+ if not route_info.is_exempt:
109
+ self.routes.append(route_info)
110
+
111
+ def _extract_route(self, node: ast.FunctionDef) -> Optional[RouteInfo]:
112
+ decorators = []
113
+ for dec in node.decorator_list:
114
+ dec_str = self._decorator_to_string(dec)
115
+ decorators.append(dec_str)
116
+
117
+ for dec in decorators:
118
+ if self._is_route_decorator(dec):
119
+ path, methods = self._extract_path_and_methods(dec, node)
120
+ if path:
121
+ return RouteInfo(
122
+ path=path,
123
+ methods=methods,
124
+ decorators=decorators,
125
+ lineno=node.lineno,
126
+ filename=self.filename,
127
+ )
128
+ return None
129
+
130
+ def _decorator_to_string(self, node: ast.AST) -> str:
131
+ if isinstance(node, ast.Name):
132
+ return node.id
133
+ elif isinstance(node, ast.Attribute):
134
+ return self._decorator_to_string(node.value) + "." + node.attr
135
+ elif isinstance(node, ast.Call):
136
+ return self._decorator_to_string(node.func)
137
+ return ""
138
+
139
+ def _is_route_decorator(self, dec: str) -> bool:
140
+ dec_lower = dec.lower()
141
+ for framework, patterns in ROUTE_DECORATORS.items():
142
+ for pattern in patterns:
143
+ if pattern in dec_lower:
144
+ return True
145
+ return False
146
+
147
+ def _extract_path_and_methods(self, dec: str, node: ast.FunctionDef) -> Tuple[str, List[str]]:
148
+ path = ""
149
+ methods = ["GET"]
150
+
151
+ dec_lower = dec.lower()
152
+
153
+ if any(m in dec_lower for m in [".get", "@get", "@app.get", "@router.get"]):
154
+ methods = ["GET"]
155
+ elif any(m in dec_lower for m in [".post", "@post", "@app.post", "@router.post"]):
156
+ methods = ["POST"]
157
+ elif any(m in dec_lower for m in [".put", "@put", "@app.put", "@router.put"]):
158
+ methods = ["PUT"]
159
+ elif any(m in dec_lower for m in [".delete", "@delete", "@app.delete", "@router.delete"]):
160
+ methods = ["DELETE"]
161
+ elif any(m in dec_lower for m in [".patch", "@patch", "@app.patch", "@router.patch"]):
162
+ methods = ["PATCH"]
163
+
164
+ if "(" in dec:
165
+ try:
166
+ call_part = dec[dec.index("(")+1:dec.rindex(")")]
167
+ parts = [p.strip().strip('"\'') for p in call_part.split(",")]
168
+ if parts:
169
+ path = parts[0]
170
+ if len(parts) > 1 and "method" in parts[1].lower():
171
+ pass
172
+ except:
173
+ pass
174
+
175
+ if not path:
176
+ path = f"/{node.name}"
177
+
178
+ return path, methods
179
+
180
+ def _analyze_route(self, route: RouteInfo):
181
+ route.has_auth = False
182
+ route.auth_decorators = []
183
+
184
+ for dec in route.decorators:
185
+ if self._is_auth_decorator(dec):
186
+ route.has_auth = True
187
+ route.auth_decorators.append(dec)
188
+
189
+ if not route.has_auth:
190
+ if self.current_class:
191
+ for base in getattr(self, 'class_bases', []):
192
+ if self._is_auth_mixin(base):
193
+ route.has_auth = True
194
+ route.auth_decorators.append(f"Mixin:{base}")
195
+
196
+ route.is_exempt = self._is_exempt_path(route.path)
197
+
198
+ def _is_auth_decorator(self, dec: str) -> bool:
199
+ dec_lower = dec.lower()
200
+ for framework, patterns in AUTH_DECORATORS.items():
201
+ for pattern in patterns:
202
+ if pattern.lower() in dec_lower:
203
+ return True
204
+ return False
205
+
206
+ def _is_auth_mixin(self, base: str) -> bool:
207
+ base_lower = base.lower()
208
+ auth_mixins = ["loginrequiredmixin", "permissionrequiredmixin", "userpassestestmixin",
209
+ "staffmemberrequiredmixin", "superuserrequiredmixin",
210
+ "accesscontrolmixin", "authorizationmixin"]
211
+ return any(m in base_lower for m in auth_mixins)
212
+
213
+ def _is_exempt_path(self, path: str) -> bool:
214
+ for pattern in EXEMPT_PATTERNS:
215
+ if re.search(pattern, path, re.IGNORECASE):
216
+ return True
217
+ return False
218
+
219
+ def visit_Call(self, node: ast.Call):
220
+ if isinstance(node.func, ast.Attribute):
221
+ if node.func.attr in ("add_url_rule", "route"):
222
+ if node.args:
223
+ path = self._get_string_value(node.args[0])
224
+ if path:
225
+ methods = ["GET"]
226
+ if len(node.args) > 1:
227
+ methods_arg = node.args[1]
228
+ if isinstance(methods_arg, ast.List):
229
+ methods = [self._get_string_value(e) or "GET" for e in methods_arg.elts]
230
+
231
+ route = RouteInfo(
232
+ path=path,
233
+ methods=methods,
234
+ decorators=[f"add_url_rule({path})"],
235
+ lineno=node.lineno,
236
+ filename=self.filename,
237
+ )
238
+ self._analyze_route(route)
239
+ if not route.is_exempt:
240
+ self.routes.append(route)
241
+ self.generic_visit(node)
242
+
243
+ def _get_string_value(self, node: ast.AST) -> Optional[str]:
244
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
245
+ return node.value
246
+ if isinstance(node, ast.Str):
247
+ return node.s
248
+ return None
249
+
250
+
251
+ class Engine:
252
+ name = "authorization"
253
+ version = "1.0.0"
254
+
255
+ async def check_availability(self) -> bool:
256
+ return True
257
+
258
+ def resource_limits(self) -> ResourceLimits:
259
+ return ResourceLimits(max_cpu_seconds=60, max_memory_mb=512, max_files=50000)
260
+
261
+ async def run(self, ctx, config: EngineConfig) -> EngineResult:
262
+ # Handle both single IR and list of IRs
263
+ if not isinstance(ctx, list):
264
+ ctx = [ctx]
265
+ all_findings = []
266
+ errors = []
267
+ files_processed = 0
268
+ total_routes = 0
269
+ unprotected_routes = 0
270
+
271
+ exclude_patterns = config.toggles.get("exclude", []) if config.toggles else []
272
+
273
+ for ir in ctx:
274
+ try:
275
+ if not ir.metadata.get("language") == "python":
276
+ continue
277
+
278
+ rel_path = ir.metadata.get("path", "unknown.py")
279
+ if any(pattern in rel_path for pattern in exclude_patterns):
280
+ continue
281
+
282
+ content = ir.content.decode("utf-8", errors="replace")
283
+ source_lines = content.splitlines()
284
+ tree = ast.parse(content, filename=rel_path)
285
+
286
+ visitor = AuthVisitor(rel_path, source_lines)
287
+ visitor.visit(tree)
288
+
289
+ for route in visitor.routes:
290
+ total_routes += 1
291
+ if not route.has_auth:
292
+ unprotected_routes += 1
293
+ severity = "high" if any(m in route.methods for m in ["POST", "PUT", "DELETE", "PATCH"]) else "medium"
294
+
295
+ all_findings.append(Finding(
296
+ engine="authorization",
297
+ severity=severity,
298
+ title="Missing authentication on route",
299
+ description=f"Route '{route.path}' [{', '.join(route.methods)}] has no authentication decorator. "
300
+ f"Consider adding @login_required, @auth_required, or Depends(get_current_user).",
301
+ location=f"{route.filename}:{route.lineno}",
302
+ metadata={
303
+ "snippet": self.get_snippet_by_line(source_lines, route.lineno),
304
+ "cwe": "CWE-306",
305
+ "confidence": 0.85,
306
+ "route_path": route.path,
307
+ "route_methods": route.methods,
308
+ "decorators": route.decorators,
309
+ "is_exempt": route.is_exempt,
310
+ }
311
+ ))
312
+
313
+ files_processed += 1
314
+
315
+ except SyntaxError as e:
316
+ errors.append(f"Syntax error in {ir.metadata.get('path', 'unknown')}: {e}")
317
+ except Exception as e:
318
+ errors.append(f"Error processing {ir.metadata.get('path', 'unknown')}: {e}")
319
+
320
+ return EngineResult(
321
+ engine=self.name,
322
+ findings=all_findings,
323
+ errors=errors,
324
+ metrics={
325
+ "files_processed": files_processed,
326
+ "total_routes": total_routes,
327
+ "unprotected_routes": unprotected_routes,
328
+ "protected_routes": total_routes - unprotected_routes,
329
+ "findings_count": len(all_findings),
330
+ },
331
+ ok=len(errors) == 0
332
+ )
333
+
334
+ def get_snippet_by_line(self, lines: List[str], line: int, context: int = 3) -> str:
335
+ start = max(0, line - 1 - context)
336
+ end = min(len(lines), line + context)
337
+ return "\n".join(lines[start:end])
@@ -0,0 +1,236 @@
1
+ """Function call graph builder engine."""
2
+ import ast
3
+ import asyncio
4
+ from collections import defaultdict
5
+ from typing import Dict, List, Set, Optional, Any
6
+ from orion.engine.config import EngineConfig, ResourceLimits
7
+ from orion.engine.ir import OrionIR
8
+ from orion.engine.results import EngineResult, Finding
9
+
10
+
11
+ class CallGraphVisitor(ast.NodeVisitor):
12
+ def __init__(self, filename: str, source_lines: List[str]):
13
+ self.filename = filename
14
+ self.source_lines = source_lines
15
+ self.calls: Dict[str, Set[str]] = defaultdict(set)
16
+ self.function_defs: Set[str] = set()
17
+ self.class_defs: Set[str] = set()
18
+ self.imports: Dict[str, str] = {}
19
+ self.current_class: Optional[str] = None
20
+ self.current_function: Optional[str] = None
21
+ self.call_depth = 0
22
+ self.max_call_depth = 100
23
+ self.undefined_calls: List[tuple] = []
24
+ self.recursive_calls: List[tuple] = []
25
+ self.deep_chains: List[tuple] = []
26
+
27
+ def get_qualified_name(self, name: str) -> str:
28
+ parts = []
29
+ if self.current_class:
30
+ parts.append(self.current_class)
31
+ if self.current_function:
32
+ parts.append(self.current_function)
33
+ parts.append(name)
34
+ return ".".join(parts)
35
+
36
+ def visit_Import(self, node: ast.Import):
37
+ for alias in node.names:
38
+ self.imports[alias.asname or alias.name] = alias.name
39
+ self.generic_visit(node)
40
+
41
+ def visit_ImportFrom(self, node: ast.ImportFrom):
42
+ if node.module:
43
+ for alias in node.names:
44
+ self.imports[alias.asname or alias.name] = f"{node.module}.{alias.name}"
45
+ self.generic_visit(node)
46
+
47
+ def visit_ClassDef(self, node: ast.ClassDef):
48
+ old_class = self.current_class
49
+ self.current_class = node.name
50
+ self.class_defs.add(self.get_qualified_name(node.name))
51
+ self.generic_visit(node)
52
+ self.current_class = old_class
53
+
54
+ def visit_FunctionDef(self, node: ast.FunctionDef):
55
+ old_function = self.current_function
56
+ self.current_function = node.name
57
+ qual_name = self.get_qualified_name(node.name)
58
+ self.function_defs.add(qual_name)
59
+ self.generic_visit(node)
60
+ self.current_function = old_function
61
+
62
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
63
+ self.visit_FunctionDef(node)
64
+
65
+ def visit_Call(self, node: ast.Call):
66
+ if self.call_depth > self.max_call_depth:
67
+ return
68
+ self.call_depth += 1
69
+ try:
70
+ caller = self.current_function or "<module>"
71
+ if self.current_class:
72
+ caller = f"{self.current_class}.{caller}"
73
+
74
+ callee = self._resolve_callee(node.func)
75
+ if callee:
76
+ self.calls[caller].add(callee)
77
+
78
+ if callee == caller:
79
+ self.recursive_calls.append((caller, node.lineno))
80
+
81
+ if callee not in self.function_defs and not self._is_builtin(callee):
82
+ self.undefined_calls.append((caller, callee, node.lineno))
83
+
84
+ self.generic_visit(node)
85
+ finally:
86
+ self.call_depth -= 1
87
+
88
+ def _resolve_callee(self, node: ast.AST) -> Optional[str]:
89
+ if isinstance(node, ast.Name):
90
+ return node.id
91
+ elif isinstance(node, ast.Attribute):
92
+ parts = []
93
+ current = node
94
+ while isinstance(current, ast.Attribute):
95
+ parts.append(current.attr)
96
+ current = current.value
97
+ if isinstance(current, ast.Name):
98
+ parts.append(current.id)
99
+ return ".".join(reversed(parts))
100
+ elif isinstance(current, ast.Call):
101
+ return "<dynamic>"
102
+ return ".".join(reversed(parts))
103
+ elif isinstance(node, ast.Call):
104
+ return "<dynamic>"
105
+ elif isinstance(node, ast.Subscript):
106
+ return "<subscript>"
107
+ elif isinstance(node, ast.Lambda):
108
+ return "<lambda>"
109
+ return None
110
+
111
+ def _is_builtin(self, name: str) -> bool:
112
+ builtins = {
113
+ "print", "len", "str", "int", "float", "list", "dict", "set", "tuple",
114
+ "range", "enumerate", "zip", "map", "filter", "sorted", "reversed",
115
+ "sum", "min", "max", "abs", "round", "pow", "divmod", "isinstance",
116
+ "issubclass", "hasattr", "getattr", "setattr", "delattr", "callable",
117
+ "type", "object", "super", "property", "staticmethod", "classmethod",
118
+ "open", "input", "eval", "exec", "compile", "hash", "id", "hex", "oct",
119
+ "bin", "chr", "ord", "ascii", "repr", "format", "vars", "locals", "globals",
120
+ "dir", "help", "breakpoint", "__import__", "iter", "next", "slice",
121
+ "property", "complex", "bool", "bytes", "bytearray", "memoryview",
122
+ "frozenset", "classmethod", "staticmethod", "property",
123
+ "Exception", "BaseException", "ValueError", "TypeError", "KeyError",
124
+ "IndexError", "AttributeError", "ImportError", "ModuleNotFoundError",
125
+ "OSError", "IOError", "RuntimeError", "StopIteration", "GeneratorExit",
126
+ "KeyboardInterrupt", "SystemExit", "NameError", "SyntaxError",
127
+ }
128
+ return name in builtins or name.startswith("__") and name.endswith("__")
129
+
130
+
131
+ class Engine:
132
+ name = "callgraph"
133
+ version = "1.0.0"
134
+
135
+ async def check_availability(self) -> bool:
136
+ return True
137
+
138
+ def resource_limits(self) -> ResourceLimits:
139
+ return ResourceLimits(max_cpu_seconds=60, max_memory_mb=512, max_files=50000)
140
+
141
+ async def run(self, ctx, config: EngineConfig) -> EngineResult:
142
+ # Handle both single IR and list of IRs
143
+ if not isinstance(ctx, list):
144
+ ctx = [ctx]
145
+ all_calls: Dict[str, Set[str]] = defaultdict(set)
146
+ all_functions: Set[str] = set()
147
+ all_classes: Set[str] = set()
148
+ all_undefined: List[tuple] = []
149
+ all_recursive: List[tuple] = []
150
+ errors = []
151
+ files_processed = 0
152
+
153
+ exclude_patterns = config.toggles.get("exclude", []) if config.toggles else []
154
+
155
+ for ir in ctx:
156
+ try:
157
+ if not ir.metadata.get("language") == "python":
158
+ continue
159
+
160
+ rel_path = ir.metadata.get("path", "unknown.py")
161
+ if any(pattern in rel_path for pattern in exclude_patterns):
162
+ continue
163
+
164
+ content = ir.content.decode("utf-8", errors="replace")
165
+ source_lines = content.splitlines()
166
+ tree = ast.parse(content, filename=rel_path)
167
+
168
+ visitor = CallGraphVisitor(rel_path, source_lines)
169
+ visitor.visit(tree)
170
+
171
+ for caller, callees in visitor.calls.items():
172
+ all_calls[caller].update(callees)
173
+ all_functions.update(visitor.function_defs)
174
+ all_classes.update(visitor.class_defs)
175
+ all_undefined.extend(visitor.undefined_calls)
176
+ all_recursive.extend(visitor.recursive_calls)
177
+ files_processed += 1
178
+
179
+ except SyntaxError as e:
180
+ errors.append(f"Syntax error in {ir.metadata.get('path', 'unknown')}: {e}")
181
+ except Exception as e:
182
+ errors.append(f"Error processing {ir.metadata.get('path', 'unknown')}: {e}")
183
+
184
+ deep_chains = self._find_deep_chains(all_calls)
185
+
186
+ call_graph = {k: list(v) for k, v in all_calls.items()}
187
+
188
+ metrics = {
189
+ "files_processed": files_processed,
190
+ "functions_defined": len(all_functions),
191
+ "classes_defined": len(all_classes),
192
+ "total_edges": sum(len(v) for v in all_calls.values()),
193
+ "unique_callers": len(all_calls),
194
+ "undefined_calls_count": len(all_undefined),
195
+ "recursive_calls_count": len(all_recursive),
196
+ "deep_chains_count": len(deep_chains),
197
+ "call_graph": call_graph,
198
+ "undefined_calls": all_undefined[:50],
199
+ "recursive_calls": all_recursive[:50],
200
+ "deep_chains": deep_chains[:20],
201
+ }
202
+
203
+ return EngineResult(
204
+ engine=self.name,
205
+ findings=[],
206
+ errors=errors,
207
+ metrics=metrics,
208
+ ok=len(errors) == 0
209
+ )
210
+
211
+ def _find_deep_chains(self, calls: Dict[str, Set[str]], max_depth: int = 10) -> List[tuple]:
212
+ chains = []
213
+ visited = set()
214
+
215
+ def dfs(node: str, path: List[str], depth: int):
216
+ if depth > max_depth:
217
+ chains.append((path[0], path[-1], depth, path.copy()))
218
+ return
219
+ if node in visited:
220
+ return
221
+ if node in path:
222
+ return
223
+
224
+ visited.add(node)
225
+ path.append(node)
226
+
227
+ for callee in calls.get(node, []):
228
+ dfs(callee, path, depth + 1)
229
+
230
+ path.pop()
231
+ visited.discard(node)
232
+
233
+ for caller in calls:
234
+ dfs(caller, [], 0)
235
+
236
+ return chains