@orionscanner/cli 0.1.0 → 0.1.1

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 (129) hide show
  1. package/README.md +135 -248
  2. package/bin/orion.js +30 -29
  3. package/dist/orion.exe +0 -0
  4. package/package.json +8 -10
  5. package/docs/API_REFERENCE.md +0 -211
  6. package/docs/ARCHITECTURE.md +0 -183
  7. package/docs/CHANGELOG.md +0 -112
  8. package/docs/CONTRIBUTING.md +0 -157
  9. package/docs/ENGINE_CATALOG.md +0 -87
  10. package/docs/GETTING_STARTED.md +0 -226
  11. package/pyproject.toml +0 -16
  12. package/src/orion/__init__.py +0 -0
  13. package/src/orion/__pycache__/__init__.cpython-313.pyc +0 -0
  14. package/src/orion/engine/__init__.py +0 -17
  15. package/src/orion/engine/__pycache__/__init__.cpython-313.pyc +0 -0
  16. package/src/orion/engine/__pycache__/config.cpython-313.pyc +0 -0
  17. package/src/orion/engine/__pycache__/core.cpython-313.pyc +0 -0
  18. package/src/orion/engine/__pycache__/ir.cpython-313.pyc +0 -0
  19. package/src/orion/engine/__pycache__/languages.cpython-313.pyc +0 -0
  20. package/src/orion/engine/__pycache__/registry.cpython-313.pyc +0 -0
  21. package/src/orion/engine/__pycache__/results.cpython-313.pyc +0 -0
  22. package/src/orion/engine/__pycache__/runner.cpython-313.pyc +0 -0
  23. package/src/orion/engine/api_security/__pycache__/engine.cpython-313.pyc +0 -0
  24. package/src/orion/engine/api_security/engine.py +0 -125
  25. package/src/orion/engine/architecture/__pycache__/engine.cpython-313.pyc +0 -0
  26. package/src/orion/engine/architecture/engine.py +0 -463
  27. package/src/orion/engine/authorization/__pycache__/engine.cpython-313.pyc +0 -0
  28. package/src/orion/engine/authorization/engine.py +0 -337
  29. package/src/orion/engine/callgraph/__pycache__/engine.cpython-313.pyc +0 -0
  30. package/src/orion/engine/callgraph/engine.py +0 -236
  31. package/src/orion/engine/cfg/__pycache__/engine.cpython-313.pyc +0 -0
  32. package/src/orion/engine/cfg/engine.py +0 -509
  33. package/src/orion/engine/chaos/__pycache__/engine.cpython-313.pyc +0 -0
  34. package/src/orion/engine/chaos/engine.py +0 -268
  35. package/src/orion/engine/chaos_config/__pycache__/engine.cpython-313.pyc +0 -0
  36. package/src/orion/engine/chaos_config/engine.py +0 -322
  37. package/src/orion/engine/complexity/__pycache__/engine.cpython-313.pyc +0 -0
  38. package/src/orion/engine/complexity/engine.py +0 -144
  39. package/src/orion/engine/compliance/__pycache__/engine.cpython-313.pyc +0 -0
  40. package/src/orion/engine/compliance/engine.py +0 -325
  41. package/src/orion/engine/config.py +0 -41
  42. package/src/orion/engine/config_engine/__pycache__/engine.cpython-313.pyc +0 -0
  43. package/src/orion/engine/config_engine/engine.py +0 -364
  44. package/src/orion/engine/config_validation/__pycache__/engine.cpython-313.pyc +0 -0
  45. package/src/orion/engine/config_validation/engine.py +0 -313
  46. package/src/orion/engine/container/__pycache__/engine.cpython-313.pyc +0 -0
  47. package/src/orion/engine/container/engine.py +0 -546
  48. package/src/orion/engine/contract_probe/__pycache__/engine.cpython-313.pyc +0 -0
  49. package/src/orion/engine/contract_probe/engine.py +0 -316
  50. package/src/orion/engine/core.py +0 -75
  51. package/src/orion/engine/crypto_guard/__pycache__/engine.cpython-313.pyc +0 -0
  52. package/src/orion/engine/crypto_guard/engine.py +0 -134
  53. package/src/orion/engine/data_residency/__pycache__/engine.cpython-313.pyc +0 -0
  54. package/src/orion/engine/data_residency/engine.py +0 -238
  55. package/src/orion/engine/dataflow/__pycache__/engine.cpython-313.pyc +0 -0
  56. package/src/orion/engine/dataflow/engine.py +0 -398
  57. package/src/orion/engine/dead_import/__pycache__/engine.cpython-313.pyc +0 -0
  58. package/src/orion/engine/dead_import/engine.py +0 -293
  59. package/src/orion/engine/deps/__pycache__/engine.cpython-313.pyc +0 -0
  60. package/src/orion/engine/deps/engine.py +0 -244
  61. package/src/orion/engine/dispatcher.py +0 -350
  62. package/src/orion/engine/docs/AUDIT_PROMPT.md +0 -1248
  63. package/src/orion/engine/duplication/__pycache__/engine.cpython-313.pyc +0 -0
  64. package/src/orion/engine/duplication/engine.py +0 -120
  65. package/src/orion/engine/error_handling/__pycache__/engine.cpython-313.pyc +0 -0
  66. package/src/orion/engine/error_handling/engine.py +0 -256
  67. package/src/orion/engine/fp_learn/__pycache__/engine.cpython-313.pyc +0 -0
  68. package/src/orion/engine/fp_learn/engine.py +0 -248
  69. package/src/orion/engine/hunter/__pycache__/engine.cpython-313.pyc +0 -0
  70. package/src/orion/engine/hunter/engine.py +0 -324
  71. package/src/orion/engine/iac_security/__pycache__/engine.cpython-313.pyc +0 -0
  72. package/src/orion/engine/iac_security/engine.py +0 -218
  73. package/src/orion/engine/ir.py +0 -22
  74. package/src/orion/engine/languages.py +0 -454
  75. package/src/orion/engine/license/__pycache__/engine.cpython-313.pyc +0 -0
  76. package/src/orion/engine/license/engine.py +0 -172
  77. package/src/orion/engine/lint/__pycache__/engine.cpython-313.pyc +0 -0
  78. package/src/orion/engine/lint/engine.py +0 -558
  79. package/src/orion/engine/llm_security/__pycache__/engine.cpython-313.pyc +0 -0
  80. package/src/orion/engine/llm_security/engine.py +0 -125
  81. package/src/orion/engine/logging_c/__pycache__/engine.cpython-313.pyc +0 -0
  82. package/src/orion/engine/logging_c/engine.py +0 -164
  83. package/src/orion/engine/noise/__pycache__/engine.cpython-313.pyc +0 -0
  84. package/src/orion/engine/noise/engine.py +0 -140
  85. package/src/orion/engine/orphan/__pycache__/engine.cpython-313.pyc +0 -0
  86. package/src/orion/engine/orphan/engine.py +0 -244
  87. package/src/orion/engine/perf_guard/__pycache__/engine.cpython-313.pyc +0 -0
  88. package/src/orion/engine/perf_guard/engine.py +0 -345
  89. package/src/orion/engine/prompt_security/__pycache__/engine.cpython-313.pyc +0 -0
  90. package/src/orion/engine/prompt_security/detectors/__init__.py +0 -4
  91. package/src/orion/engine/prompt_security/engine.py +0 -170
  92. package/src/orion/engine/provenance/__pycache__/engine.cpython-313.pyc +0 -0
  93. package/src/orion/engine/provenance/engine.py +0 -242
  94. package/src/orion/engine/registry.py +0 -55
  95. package/src/orion/engine/regression/__pycache__/engine.cpython-313.pyc +0 -0
  96. package/src/orion/engine/regression/engine.py +0 -213
  97. package/src/orion/engine/results.py +0 -21
  98. package/src/orion/engine/runner.py +0 -1310
  99. package/src/orion/engine/sandbox_escape/__pycache__/engine.cpython-313.pyc +0 -0
  100. package/src/orion/engine/sandbox_escape/engine.py +0 -284
  101. package/src/orion/engine/sast/__pycache__/engine.cpython-313.pyc +0 -0
  102. package/src/orion/engine/sast/engine.py +0 -217
  103. package/src/orion/engine/sast/patterns/__init__.py +0 -513
  104. package/src/orion/engine/sast/patterns/__pycache__/__init__.cpython-313.pyc +0 -0
  105. package/src/orion/engine/secrets/__pycache__/engine.cpython-313.pyc +0 -0
  106. package/src/orion/engine/secrets/engine.py +0 -77
  107. package/src/orion/engine/secrets/patterns/__init__.py +0 -74
  108. package/src/orion/engine/secrets/patterns/__pycache__/__init__.cpython-313.pyc +0 -0
  109. package/src/orion/engine/sensitive_data/__pycache__/engine.cpython-313.pyc +0 -0
  110. package/src/orion/engine/sensitive_data/engine.py +0 -143
  111. package/src/orion/engine/state/__pycache__/engine.cpython-313.pyc +0 -0
  112. package/src/orion/engine/state/engine.py +0 -218
  113. package/src/orion/engine/supply_chain_sbom/__pycache__/engine.cpython-313.pyc +0 -0
  114. package/src/orion/engine/supply_chain_sbom/engine.py +0 -290
  115. package/src/orion/engine/symbol/__pycache__/engine.cpython-313.pyc +0 -0
  116. package/src/orion/engine/symbol/engine.py +0 -463
  117. package/src/orion/engine/taint/__pycache__/engine.cpython-313.pyc +0 -0
  118. package/src/orion/engine/taint/engine.py +0 -464
  119. package/src/orion/engine/test_coverage/__pycache__/engine.cpython-313.pyc +0 -0
  120. package/src/orion/engine/test_coverage/engine.py +0 -445
  121. package/src/orion/engine/threat_model/__pycache__/engine.cpython-313.pyc +0 -0
  122. package/src/orion/engine/threat_model/engine.py +0 -126
  123. package/src/orion/engine/wiring_trace/__pycache__/engine.cpython-313.pyc +0 -0
  124. package/src/orion/engine/wiring_trace/engine.py +0 -200
  125. package/src/orion_engine.egg-info/PKG-INFO +0 -7
  126. package/src/orion_engine.egg-info/SOURCES.txt +0 -65
  127. package/src/orion_engine.egg-info/dependency_links.txt +0 -1
  128. package/src/orion_engine.egg-info/entry_points.txt +0 -2
  129. package/src/orion_engine.egg-info/top_level.txt +0 -1
@@ -1,337 +0,0 @@
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])
@@ -1,236 +0,0 @@
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