@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,445 @@
1
+ """Test coverage heuristics engine - finds untested code and test quality issues."""
2
+ import ast
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Dict, List, Set, Optional, Tuple
6
+ from collections import defaultdict
7
+
8
+ from orion.engine.config import EngineConfig
9
+ from orion.engine.ir import OrionIR
10
+ from orion.engine.results import EngineResult, Finding
11
+
12
+
13
+ class TestCoverageEngine:
14
+ name = "test_coverage"
15
+ version = "1.0.0"
16
+
17
+ TEST_FILE_PATTERNS = [
18
+ "test_*.py",
19
+ "*_test.py",
20
+ "tests/*.py",
21
+ "test/**/*.py",
22
+ ]
23
+
24
+ EXCLUDED_DIRS = {"__pycache__", ".git", ".venv", "venv", "env", "node_modules", "dist", "build", "migrations"}
25
+
26
+ async def check_availability(self) -> bool:
27
+ return True
28
+
29
+ def resource_limits(self):
30
+ from orion.engine.config import ResourceLimits
31
+ return ResourceLimits(max_cpu_seconds=60, max_memory_mb=256)
32
+
33
+ def _should_exclude(self, filepath: str, config: EngineConfig) -> bool:
34
+ path = Path(filepath)
35
+ for part in path.parts:
36
+ if part in self.EXCLUDED_DIRS:
37
+ return True
38
+ if config.toggles.get("exclude"):
39
+ for pattern in config.toggles["exclude"]:
40
+ if path.match(pattern) or any(p.match(pattern) for p in path.parents):
41
+ return True
42
+ return False
43
+
44
+ def _is_test_file(self, filepath: str) -> bool:
45
+ path = Path(filepath)
46
+ name = path.name
47
+ return (
48
+ name.startswith("test_") or
49
+ name.endswith("_test.py") or
50
+ "tests" in path.parts or
51
+ "test" in path.parts
52
+ )
53
+
54
+ def _is_source_file(self, filepath: str) -> bool:
55
+ path = Path(filepath)
56
+ return path.suffix == ".py" and not self._is_test_file(filepath)
57
+
58
+ def _get_module_name(self, filepath: str, project_root: str) -> Optional[str]:
59
+ try:
60
+ path = Path(filepath).relative_to(Path(project_root))
61
+ except ValueError:
62
+ return None
63
+
64
+ if path.suffix == ".py":
65
+ path = path.with_suffix("")
66
+
67
+ parts = list(path.parts)
68
+ if parts and parts[-1] == "__init__":
69
+ parts = parts[:-1]
70
+
71
+ return ".".join(parts) if parts else None
72
+
73
+ def _extract_test_functions(self, content: str) -> List[Dict]:
74
+ """Extract test functions from test file."""
75
+ tests = []
76
+ try:
77
+ tree = ast.parse(content)
78
+ for node in ast.walk(tree):
79
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
80
+ if node.name.startswith("test_"):
81
+ # Check for assertions
82
+ has_assert = False
83
+ mock_targets = []
84
+ for n in ast.walk(node):
85
+ if isinstance(n, ast.Assert):
86
+ has_assert = True
87
+ elif isinstance(n, ast.Call):
88
+ # Check for mock.patch, unittest.mock, etc.
89
+ if isinstance(n.func, ast.Attribute):
90
+ if n.func.attr in ("patch", "patch_object", "MagicMock", "Mock", "AsyncMock"):
91
+ if n.args:
92
+ for arg in n.args:
93
+ if isinstance(arg, ast.Constant):
94
+ mock_targets.append(arg.value)
95
+ elif isinstance(n.func, ast.Name):
96
+ if n.func.id in ("patch", "MagicMock", "Mock", "AsyncMock"):
97
+ if n.args:
98
+ for arg in n.args:
99
+ if isinstance(arg, ast.Constant):
100
+ mock_targets.append(arg.value)
101
+ elif isinstance(n, ast.With):
102
+ for item in n.items:
103
+ if isinstance(item.context_expr, ast.Call):
104
+ if isinstance(item.context_expr.func, ast.Attribute):
105
+ if item.context_expr.func.attr in ("patch", "patch_object"):
106
+ if item.context_expr.args:
107
+ for arg in item.context_expr.args:
108
+ if isinstance(arg, ast.Constant):
109
+ mock_targets.append(arg.value)
110
+
111
+ tests.append({
112
+ "name": node.name,
113
+ "line": node.lineno,
114
+ "end_line": getattr(node, 'end_lineno', node.lineno),
115
+ "has_assert": has_assert,
116
+ "mock_targets": mock_targets,
117
+ "line_count": getattr(node, 'end_lineno', node.lineno) - node.lineno + 1,
118
+ })
119
+ except SyntaxError:
120
+ pass
121
+ return tests
122
+
123
+ def _extract_source_symbols(self, content: str) -> Dict[str, List[Dict]]:
124
+ """Extract public functions/classes from source file."""
125
+ symbols = {"functions": [], "classes": [], "methods": []}
126
+ try:
127
+ tree = ast.parse(content)
128
+ for node in ast.walk(tree):
129
+ if isinstance(node, ast.FunctionDef):
130
+ if not node.name.startswith("_"):
131
+ symbols["functions"].append({
132
+ "name": node.name,
133
+ "line": node.lineno,
134
+ "end_line": getattr(node, 'end_lineno', node.lineno),
135
+ "args": [arg.arg for arg in node.args.args],
136
+ })
137
+ elif isinstance(node, ast.AsyncFunctionDef):
138
+ if not node.name.startswith("_"):
139
+ symbols["functions"].append({
140
+ "name": node.name,
141
+ "line": node.lineno,
142
+ "end_line": getattr(node, 'end_lineno', node.lineno),
143
+ "args": [arg.arg for arg in node.args.args],
144
+ })
145
+ elif isinstance(node, ast.ClassDef):
146
+ if not node.name.startswith("_"):
147
+ methods = []
148
+ for item in node.body:
149
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
150
+ if not item.name.startswith("_"):
151
+ methods.append({
152
+ "name": item.name,
153
+ "line": item.lineno,
154
+ "end_line": getattr(item, 'end_lineno', item.lineno),
155
+ })
156
+ symbols["classes"].append({
157
+ "name": node.name,
158
+ "line": node.lineno,
159
+ "end_line": getattr(node, 'end_lineno', node.lineno),
160
+ "methods": methods,
161
+ })
162
+ except SyntaxError:
163
+ pass
164
+ return symbols
165
+
166
+ def _extract_imports(self, content: str) -> List[str]:
167
+ imports = []
168
+ try:
169
+ tree = ast.parse(content)
170
+ for node in ast.walk(tree):
171
+ if isinstance(node, ast.Import):
172
+ for alias in node.names:
173
+ imports.append(alias.name)
174
+ elif isinstance(node, ast.ImportFrom):
175
+ if node.module:
176
+ for alias in node.names:
177
+ imports.append(f"{node.module}.{alias.name}")
178
+ except SyntaxError:
179
+ pass
180
+ return imports
181
+
182
+ def _map_tests_to_source(self, test_files: Dict[str, List[Dict]], source_files: Dict[str, Dict]) -> Dict[str, Set[str]]:
183
+ """Map test functions to source symbols they likely test."""
184
+ mapping = defaultdict(set) # source_symbol -> set of test names
185
+
186
+ for test_file, tests in test_files.items():
187
+ for test in tests:
188
+ test_name = test["name"]
189
+ # Heuristic 1: naming convention test_foo -> foo
190
+ match = re.match(r"test_(\w+)", test_name)
191
+ if match:
192
+ target = match.group(1)
193
+ for src_file, symbols in source_files.items():
194
+ for func in symbols["functions"]:
195
+ if func["name"] == target or func["name"].endswith("_" + target):
196
+ mapping[f"{src_file}:{func['name']}"].add(f"{test_file}:{test_name}")
197
+ for cls in symbols["classes"]:
198
+ if cls["name"].lower() == target or cls["name"].lower() == target.capitalize():
199
+ mapping[f"{src_file}:{cls['name']}"].add(f"{test_file}:{test_name}")
200
+ for method in cls["methods"]:
201
+ mapping[f"{src_file}:{cls['name']}.{method['name']}"].add(f"{test_file}:{test_name}")
202
+
203
+ # Heuristic 2: mock targets
204
+ for mock_target in test.get("mock_targets", []):
205
+ for src_file, symbols in source_files.items():
206
+ for func in symbols["functions"]:
207
+ if mock_target.endswith(func["name"]) or func["name"] in mock_target:
208
+ mapping[f"{src_file}:{func['name']}"].add(f"{test_file}:{test_name}")
209
+ for cls in symbols["classes"]:
210
+ if mock_target.endswith(cls["name"]) or cls["name"] in mock_target:
211
+ mapping[f"{src_file}:{cls['name']}"].add(f"{test_file}:{test_name}")
212
+ for method in cls["methods"]:
213
+ if mock_target.endswith(method["name"]):
214
+ mapping[f"{src_file}:{cls['name']}.{method['name']}"].add(f"{test_file}:{test_name}")
215
+
216
+ # Heuristic 3: imports in test file
217
+ try:
218
+ test_ir = test_files.get(test_file, {})
219
+ except:
220
+ pass
221
+
222
+ return mapping
223
+
224
+ def _check_conftest(self, all_files: Dict[str, OrionIR], project_root: str) -> List[Finding]:
225
+ findings = []
226
+ test_dirs = set()
227
+ for filepath in all_files:
228
+ path = Path(filepath)
229
+ if self._is_test_file(filepath):
230
+ test_dirs.add(str(path.parent))
231
+
232
+ for test_dir in test_dirs:
233
+ conftest_path = Path(test_dir) / "conftest.py"
234
+ if not conftest_path.exists():
235
+ # Check if there are test files in this dir
236
+ has_tests = any(
237
+ self._is_test_file(f) and str(Path(f).parent) == test_dir
238
+ for f in all_files
239
+ )
240
+ if has_tests:
241
+ findings.append(Finding(
242
+ engine=self.name,
243
+ severity="low",
244
+ title=f"Missing conftest.py in test directory",
245
+ description=f"Test directory '{test_dir}' has tests but no conftest.py for shared fixtures",
246
+ location=str(conftest_path),
247
+ metadata={
248
+ "rule": "missing_conftest",
249
+ "directory": test_dir,
250
+ },
251
+ ))
252
+ return findings
253
+
254
+ async def run(self, ir: OrionIR, config: EngineConfig) -> EngineResult:
255
+ project_root = ir.metadata.get("project_root", ".")
256
+ all_files = ir.metadata.get("all_files", {})
257
+
258
+ if not all_files:
259
+ return EngineResult(engine=self.name, ok=True, metrics={"mode": "single_file"})
260
+
261
+ # Separate test and source files
262
+ test_files_content = {}
263
+ source_files_content = {}
264
+
265
+ for filepath, file_ir in all_files.items():
266
+ if self._should_exclude(filepath, config):
267
+ continue
268
+
269
+ try:
270
+ content = file_ir.content.decode("utf-8", errors="replace")
271
+ except Exception:
272
+ continue
273
+
274
+ if self._is_test_file(filepath):
275
+ test_files_content[filepath] = content
276
+ elif self._is_source_file(filepath):
277
+ source_files_content[filepath] = content
278
+
279
+ # Extract test functions
280
+ test_functions = {}
281
+ for filepath, content in test_files_content.items():
282
+ tests = self._extract_test_functions(content)
283
+ if tests:
284
+ test_functions[filepath] = tests
285
+
286
+ # Extract source symbols
287
+ source_symbols = {}
288
+ for filepath, content in source_files_content.items():
289
+ symbols = self._extract_source_symbols(content)
290
+ if symbols["functions"] or symbols["classes"]:
291
+ source_symbols[filepath] = symbols
292
+
293
+ # Map tests to source
294
+ test_to_source = self._map_tests_to_source(test_functions, source_symbols)
295
+
296
+ all_findings = []
297
+
298
+ # 1. Find untested public functions/classes
299
+ for src_file, symbols in source_symbols.items():
300
+ for func in symbols["functions"]:
301
+ key = f"{src_file}:{func['name']}"
302
+ if key not in test_to_source:
303
+ all_findings.append(Finding(
304
+ engine=self.name,
305
+ severity="medium",
306
+ title=f"Untested public function: {func['name']}",
307
+ description=f"Public function '{func['name']}' has no corresponding test",
308
+ location=f"{src_file}:{func['line']}",
309
+ metadata={
310
+ "rule": "untested_function",
311
+ "untested_function": func["name"],
312
+ "file": src_file,
313
+ "line": func["line"],
314
+ "type": "function",
315
+ },
316
+ ))
317
+
318
+ for cls in symbols["classes"]:
319
+ key = f"{src_file}:{cls['name']}"
320
+ if key not in test_to_source:
321
+ all_findings.append(Finding(
322
+ engine=self.name,
323
+ severity="medium",
324
+ title=f"Untested public class: {cls['name']}",
325
+ description=f"Public class '{cls['name']}' has no corresponding test",
326
+ location=f"{src_file}:{cls['line']}",
327
+ metadata={
328
+ "rule": "untested_class",
329
+ "untested_function": cls["name"],
330
+ "file": src_file,
331
+ "line": cls["line"],
332
+ "type": "class",
333
+ },
334
+ ))
335
+ for method in cls["methods"]:
336
+ key = f"{src_file}:{cls['name']}.{method['name']}"
337
+ if key not in test_to_source:
338
+ all_findings.append(Finding(
339
+ engine=self.name,
340
+ severity="low",
341
+ title=f"Untested public method: {cls['name']}.{method['name']}",
342
+ description=f"Public method '{cls['name']}.{method['name']}' has no corresponding test",
343
+ location=f"{src_file}:{method['line']}",
344
+ metadata={
345
+ "rule": "untested_method",
346
+ "untested_function": f"{cls['name']}.{method['name']}",
347
+ "file": src_file,
348
+ "line": method["line"],
349
+ "type": "method",
350
+ },
351
+ ))
352
+
353
+ # 2. Test files with no assertions
354
+ for test_file, tests in test_functions.items():
355
+ for test in tests:
356
+ if not test["has_assert"] and test["mock_targets"]:
357
+ all_findings.append(Finding(
358
+ engine=self.name,
359
+ severity="medium",
360
+ title=f"Test only mocks, no assertions: {test['name']}",
361
+ description=f"Test '{test['name']}' uses mocks but has no assertions",
362
+ location=f"{test_file}:{test['line']}",
363
+ metadata={
364
+ "rule": "test_only_mocks",
365
+ "test": test["name"],
366
+ "file": test_file,
367
+ "line": test["line"],
368
+ "mocks": test["mock_targets"],
369
+ },
370
+ ))
371
+ elif not test["has_assert"] and not test["mock_targets"]:
372
+ all_findings.append(Finding(
373
+ engine=self.name,
374
+ severity="low",
375
+ title=f"Test has no assertions: {test['name']}",
376
+ description=f"Test '{test['name']}' has no assertions",
377
+ location=f"{test_file}:{test['line']}",
378
+ metadata={
379
+ "rule": "test_no_assertions",
380
+ "test": test["name"],
381
+ "file": test_file,
382
+ "line": test["line"],
383
+ },
384
+ ))
385
+
386
+ # 3. Long test functions (likely integration tests)
387
+ for test_file, tests in test_functions.items():
388
+ for test in tests:
389
+ if test["line_count"] > 50:
390
+ all_findings.append(Finding(
391
+ engine=self.name,
392
+ severity="low",
393
+ title=f"Long test function: {test['name']} ({test['line_count']} lines)",
394
+ description=f"Test function exceeds 50 lines - likely an integration test",
395
+ location=f"{test_file}:{test['line']}",
396
+ metadata={
397
+ "rule": "long_test_function",
398
+ "test": test["name"],
399
+ "file": test_file,
400
+ "line": test["line"],
401
+ "line_count": test["line_count"],
402
+ },
403
+ ))
404
+
405
+ # 4. Missing conftest.py
406
+ all_findings.extend(self._check_conftest(all_files, project_root))
407
+
408
+ # 5. Test files that don't import any source code
409
+ for test_file, content in test_files_content.items():
410
+ imports = self._extract_imports(content)
411
+ imports_source = any(
412
+ any(src_file.replace("/", ".").replace(".py", "") in imp for src_file in source_files_content)
413
+ for imp in imports
414
+ )
415
+ if not imports_source and test_functions.get(test_file):
416
+ all_findings.append(Finding(
417
+ engine=self.name,
418
+ severity="low",
419
+ title=f"Test file doesn't import source code: {Path(test_file).name}",
420
+ description=f"Test file may not be testing actual source code",
421
+ location=test_file,
422
+ metadata={
423
+ "rule": "test_no_source_imports",
424
+ "file": test_file,
425
+ "imports": imports,
426
+ },
427
+ ))
428
+
429
+ return EngineResult(
430
+ engine=self.name,
431
+ findings=all_findings,
432
+ ok=True,
433
+ metrics={
434
+ "source_files": len(source_files_content),
435
+ "test_files": len(test_files_content),
436
+ "test_functions": sum(len(t) for t in test_functions.values()),
437
+ "source_functions": sum(len(s["functions"]) for s in source_symbols.values()),
438
+ "source_classes": sum(len(s["classes"]) for s in source_symbols.values()),
439
+ "mapped_tests": len(test_to_source),
440
+ "findings_count": len(all_findings),
441
+ },
442
+ )
443
+
444
+
445
+ Engine = TestCoverageEngine
@@ -0,0 +1,126 @@
1
+ """ThreatPulse™ - Automated In-House STRIDE & Trust Boundary Threat Modeling Engine.
2
+
3
+ 100% Proprietary In-House IP for Commercial Architecture & Threat Governance.
4
+ """
5
+
6
+ import ast
7
+ import re
8
+ from pathlib import Path
9
+ from typing import List, Dict, Any, Set
10
+
11
+ from orion.engine.config import EngineConfig, ResourceLimits
12
+ from orion.engine.ir import OrionIR
13
+ from orion.engine.results import EngineResult, Finding
14
+
15
+
16
+ class ThreatModelEngine:
17
+ name = "threat_model"
18
+ version = "1.0.0"
19
+
20
+ ENTRYPOINT_PATTERNS = [
21
+ re.compile(r"@(?:app|router|blueprint)\.(?:route|get|post|put|delete|patch)\s*\(", re.IGNORECASE),
22
+ re.compile(r"def\s+(?:handle_|process_|on_|api_|webhook_)[a-zA-Z0-9_]*\s*\(", re.IGNORECASE),
23
+ re.compile(r"(?:express|fastify|koa)\.(?:get|post|put|delete|use)\s*\(", re.IGNORECASE),
24
+ ]
25
+
26
+ UNAUTHENTICATED_PATTERNS = [
27
+ re.compile(r"@(?:allow_anonymous|public_route|no_auth|skip_auth|permit_all)", re.IGNORECASE),
28
+ re.compile(r"auth(?:entication)?\s*=\s*(?:False|None|0|'none'|\"none\")", re.IGNORECASE),
29
+ ]
30
+
31
+ DATA_STORE_PATTERNS = [
32
+ re.compile(r"(?:db|database|session|cursor|repository)\.(?:execute|query|raw|commit|save|delete)\s*\(", re.IGNORECASE),
33
+ re.compile(r"(?:mongoose|prisma|typeorm|sequelize)\.(?:find|create|update|delete)\s*\(", re.IGNORECASE),
34
+ ]
35
+
36
+ EXTERNAL_IO_PATTERNS = [
37
+ re.compile(r"(?:requests|httpx|urllib|fetch|axios)\.(?:get|post|put|delete|request)\s*\(", re.IGNORECASE),
38
+ re.compile(r"(?:socket|boto3|azure|gcp|sns|sqs|kafka|redis)\.", re.IGNORECASE),
39
+ ]
40
+
41
+ async def check_availability(self) -> bool:
42
+ return True
43
+
44
+ def resource_limits(self) -> ResourceLimits:
45
+ return ResourceLimits(max_cpu_seconds=45, max_memory_mb=128)
46
+
47
+ async def run(self, ir: OrionIR, config: EngineConfig) -> EngineResult:
48
+ filepath = ir.metadata.get("path", "unknown")
49
+ findings: List[Finding] = []
50
+
51
+ try:
52
+ content = ir.content.decode("utf-8", errors="replace")
53
+ except Exception:
54
+ content = str(ir.content)
55
+
56
+ lines = content.splitlines()
57
+
58
+ # 1. Unauthenticated Public Entrypoints (STRIDE: Spoofing / Elevation of Privilege)
59
+ for i, line in enumerate(lines, 1):
60
+ for pat in self.UNAUTHENTICATED_PATTERNS:
61
+ if pat.search(line):
62
+ snippet = line.strip()
63
+ findings.append(Finding(
64
+ engine=self.name,
65
+ severity="medium",
66
+ title=f"STRIDE Threat: Unauthenticated Entrypoint Boundary",
67
+ description=f"Publicly accessible entrypoint '{snippet}' bypasses authentication middleware, creating risk of Elevation of Privilege or Spoofing",
68
+ location=f"{filepath}:{i}",
69
+ metadata={
70
+ "threat_category": "STRIDE-Elevation-Of-Privilege",
71
+ "trust_boundary": "External-to-Internal",
72
+ "cwe": "CWE-306",
73
+ "snippet": snippet,
74
+ "confidence": 0.85,
75
+ },
76
+ ))
77
+
78
+ # 2. Raw Database Queries across Boundaries (STRIDE: Tampering / Information Disclosure)
79
+ for i, line in enumerate(lines, 1):
80
+ for pat in self.DATA_STORE_PATTERNS:
81
+ if pat.search(line) and ("where" in line.lower() or "select" in line.lower() or "exec" in line.lower()):
82
+ snippet = line.strip()
83
+ findings.append(Finding(
84
+ engine=self.name,
85
+ severity="medium",
86
+ title=f"STRIDE Threat: Direct Data Store Trust Crossing",
87
+ description=f"Direct persistence query '{snippet}' crosses data boundary without ORM abstraction layer",
88
+ location=f"{filepath}:{i}",
89
+ metadata={
90
+ "threat_category": "STRIDE-Tampering",
91
+ "trust_boundary": "Application-to-DataStore",
92
+ "cwe": "CWE-89",
93
+ "snippet": snippet,
94
+ "confidence": 0.80,
95
+ },
96
+ ))
97
+
98
+ # 3. External Network & Integration Boundaries (STRIDE: Denial of Service / Repudiation)
99
+ for i, line in enumerate(lines, 1):
100
+ for pat in self.EXTERNAL_IO_PATTERNS:
101
+ if pat.search(line) and "timeout" not in line.lower():
102
+ snippet = line.strip()
103
+ findings.append(Finding(
104
+ engine=self.name,
105
+ severity="low",
106
+ title=f"STRIDE Threat: Unbounded External Service Call",
107
+ description=f"External network call without explicit timeout creates cascading Denial of Service risk across microservice boundaries",
108
+ location=f"{filepath}:{i}",
109
+ metadata={
110
+ "threat_category": "STRIDE-Denial-Of-Service",
111
+ "trust_boundary": "Internal-to-External-ThirdParty",
112
+ "cwe": "CWE-400",
113
+ "snippet": snippet,
114
+ "confidence": 0.75,
115
+ },
116
+ ))
117
+
118
+ return EngineResult(
119
+ engine=self.name,
120
+ findings=findings,
121
+ ok=True,
122
+ metrics={"file": filepath, "threat_checks_completed": 3, "findings_count": len(findings)},
123
+ )
124
+
125
+
126
+ Engine = ThreatModelEngine