@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.
- package/LICENSE +21 -0
- package/README.md +248 -0
- package/assets/logo.png +0 -0
- package/bin/orion.js +48 -0
- package/docs/API_REFERENCE.md +211 -0
- package/docs/ARCHITECTURE.md +183 -0
- package/docs/CHANGELOG.md +112 -0
- package/docs/CONTRIBUTING.md +157 -0
- package/docs/ENGINE_CATALOG.md +87 -0
- package/docs/GETTING_STARTED.md +226 -0
- package/package.json +48 -0
- package/pyproject.toml +16 -0
- package/src/orion/__init__.py +0 -0
- package/src/orion/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/orion/engine/__init__.py +17 -0
- package/src/orion/engine/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/orion/engine/__pycache__/config.cpython-313.pyc +0 -0
- package/src/orion/engine/__pycache__/core.cpython-313.pyc +0 -0
- package/src/orion/engine/__pycache__/ir.cpython-313.pyc +0 -0
- package/src/orion/engine/__pycache__/languages.cpython-313.pyc +0 -0
- package/src/orion/engine/__pycache__/registry.cpython-313.pyc +0 -0
- package/src/orion/engine/__pycache__/results.cpython-313.pyc +0 -0
- package/src/orion/engine/__pycache__/runner.cpython-313.pyc +0 -0
- package/src/orion/engine/api_security/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/api_security/engine.py +125 -0
- package/src/orion/engine/architecture/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/architecture/engine.py +463 -0
- package/src/orion/engine/authorization/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/authorization/engine.py +337 -0
- package/src/orion/engine/callgraph/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/callgraph/engine.py +236 -0
- package/src/orion/engine/cfg/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/cfg/engine.py +509 -0
- package/src/orion/engine/chaos/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/chaos/engine.py +268 -0
- package/src/orion/engine/chaos_config/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/chaos_config/engine.py +322 -0
- package/src/orion/engine/complexity/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/complexity/engine.py +144 -0
- package/src/orion/engine/compliance/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/compliance/engine.py +325 -0
- package/src/orion/engine/config.py +41 -0
- package/src/orion/engine/config_engine/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/config_engine/engine.py +364 -0
- package/src/orion/engine/config_validation/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/config_validation/engine.py +313 -0
- package/src/orion/engine/container/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/container/engine.py +546 -0
- package/src/orion/engine/contract_probe/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/contract_probe/engine.py +316 -0
- package/src/orion/engine/core.py +75 -0
- package/src/orion/engine/crypto_guard/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/crypto_guard/engine.py +134 -0
- package/src/orion/engine/data_residency/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/data_residency/engine.py +238 -0
- package/src/orion/engine/dataflow/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/dataflow/engine.py +398 -0
- package/src/orion/engine/dead_import/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/dead_import/engine.py +293 -0
- package/src/orion/engine/deps/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/deps/engine.py +244 -0
- package/src/orion/engine/dispatcher.py +350 -0
- package/src/orion/engine/docs/AUDIT_PROMPT.md +1248 -0
- package/src/orion/engine/duplication/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/duplication/engine.py +120 -0
- package/src/orion/engine/error_handling/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/error_handling/engine.py +256 -0
- package/src/orion/engine/fp_learn/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/fp_learn/engine.py +248 -0
- package/src/orion/engine/hunter/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/hunter/engine.py +324 -0
- package/src/orion/engine/iac_security/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/iac_security/engine.py +218 -0
- package/src/orion/engine/ir.py +22 -0
- package/src/orion/engine/languages.py +454 -0
- package/src/orion/engine/license/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/license/engine.py +172 -0
- package/src/orion/engine/lint/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/lint/engine.py +558 -0
- package/src/orion/engine/llm_security/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/llm_security/engine.py +125 -0
- package/src/orion/engine/logging_c/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/logging_c/engine.py +164 -0
- package/src/orion/engine/noise/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/noise/engine.py +140 -0
- package/src/orion/engine/orphan/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/orphan/engine.py +244 -0
- package/src/orion/engine/perf_guard/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/perf_guard/engine.py +345 -0
- package/src/orion/engine/prompt_security/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/prompt_security/detectors/__init__.py +4 -0
- package/src/orion/engine/prompt_security/engine.py +170 -0
- package/src/orion/engine/provenance/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/provenance/engine.py +242 -0
- package/src/orion/engine/registry.py +55 -0
- package/src/orion/engine/regression/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/regression/engine.py +213 -0
- package/src/orion/engine/results.py +21 -0
- package/src/orion/engine/runner.py +1310 -0
- package/src/orion/engine/sandbox_escape/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/sandbox_escape/engine.py +284 -0
- package/src/orion/engine/sast/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/sast/engine.py +217 -0
- package/src/orion/engine/sast/patterns/__init__.py +513 -0
- package/src/orion/engine/sast/patterns/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/orion/engine/secrets/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/secrets/engine.py +77 -0
- package/src/orion/engine/secrets/patterns/__init__.py +74 -0
- package/src/orion/engine/secrets/patterns/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/orion/engine/sensitive_data/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/sensitive_data/engine.py +143 -0
- package/src/orion/engine/state/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/state/engine.py +218 -0
- package/src/orion/engine/supply_chain_sbom/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/supply_chain_sbom/engine.py +290 -0
- package/src/orion/engine/symbol/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/symbol/engine.py +463 -0
- package/src/orion/engine/taint/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/taint/engine.py +464 -0
- package/src/orion/engine/test_coverage/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/test_coverage/engine.py +445 -0
- package/src/orion/engine/threat_model/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/threat_model/engine.py +126 -0
- package/src/orion/engine/wiring_trace/__pycache__/engine.cpython-313.pyc +0 -0
- package/src/orion/engine/wiring_trace/engine.py +200 -0
- package/src/orion_engine.egg-info/PKG-INFO +7 -0
- package/src/orion_engine.egg-info/SOURCES.txt +65 -0
- package/src/orion_engine.egg-info/dependency_links.txt +1 -0
- package/src/orion_engine.egg-info/entry_points.txt +2 -0
- 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
|
|
Binary file
|
|
@@ -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
|
|
Binary file
|