@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
|
Binary file
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Code duplication detection engine using sliding window over normalized tokens."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import re
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from orion.engine.config import EngineConfig
|
|
6
|
+
from orion.engine.ir import OrionIR
|
|
7
|
+
from orion.engine.results import EngineResult, Finding
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Engine:
|
|
11
|
+
name = "duplication"
|
|
12
|
+
version = "1.0.0"
|
|
13
|
+
|
|
14
|
+
WINDOW_LINES = 8
|
|
15
|
+
MIN_OCCURRENCES = 3
|
|
16
|
+
MIN_TOKENS = 40
|
|
17
|
+
|
|
18
|
+
async def check_availability(self) -> bool:
|
|
19
|
+
return True
|
|
20
|
+
|
|
21
|
+
def resource_limits(self):
|
|
22
|
+
from orion.engine.config import ResourceLimits
|
|
23
|
+
return ResourceLimits(max_cpu_seconds=120, max_memory_mb=512)
|
|
24
|
+
|
|
25
|
+
async def run(self, ir: OrionIR, config: EngineConfig) -> EngineResult:
|
|
26
|
+
try:
|
|
27
|
+
text = ir.content.decode("utf-8", errors="replace")
|
|
28
|
+
except Exception:
|
|
29
|
+
text = str(ir.content)
|
|
30
|
+
|
|
31
|
+
path = ir.metadata.get("path", "unknown")
|
|
32
|
+
language = ir.metadata.get("language", "unknown")
|
|
33
|
+
|
|
34
|
+
if language not in ("python", "javascript", "typescript", "java", "go", "c", "cpp", "rust"):
|
|
35
|
+
return EngineResult(engine=self.name, findings=[], ok=True)
|
|
36
|
+
|
|
37
|
+
tokens = self._normalize_and_tokenize(text, language)
|
|
38
|
+
if len(tokens) < self.MIN_TOKENS:
|
|
39
|
+
return EngineResult(engine=self.name, findings=[], ok=True)
|
|
40
|
+
|
|
41
|
+
window_hashes = self._sliding_window_hashes(tokens, path)
|
|
42
|
+
clusters = self._find_clusters(window_hashes)
|
|
43
|
+
|
|
44
|
+
findings = []
|
|
45
|
+
reported_lines = set()
|
|
46
|
+
for cluster in clusters:
|
|
47
|
+
start_line = cluster[0][1]
|
|
48
|
+
if start_line in reported_lines or any(abs(start_line - r) < self.WINDOW_LINES for r in reported_lines):
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
if len(cluster) >= self.MIN_OCCURRENCES:
|
|
52
|
+
reported_lines.add(start_line)
|
|
53
|
+
locations = [{"file": loc[0], "line": loc[1]} for loc in cluster]
|
|
54
|
+
unique_files = set(loc[0] for loc in cluster)
|
|
55
|
+
token_count = self.WINDOW_LINES * 10
|
|
56
|
+
findings.append(Finding(
|
|
57
|
+
engine=self.name,
|
|
58
|
+
severity="low",
|
|
59
|
+
title=f"Code duplication block ({len(cluster)} occurrences)",
|
|
60
|
+
description=f"Found {len(cluster)} similar code sequences of {self.WINDOW_LINES}+ lines",
|
|
61
|
+
location=f"{path}:{start_line}",
|
|
62
|
+
metadata={
|
|
63
|
+
"confidence": 0.8,
|
|
64
|
+
"locations": locations,
|
|
65
|
+
"token_count": token_count,
|
|
66
|
+
"occurrences": len(cluster),
|
|
67
|
+
"cwe": "CWE-1041",
|
|
68
|
+
},
|
|
69
|
+
))
|
|
70
|
+
|
|
71
|
+
return EngineResult(engine=self.name, findings=findings, ok=True)
|
|
72
|
+
|
|
73
|
+
def _normalize_and_tokenize(self, text: str, language: str) -> list[tuple[int, str]]:
|
|
74
|
+
lines = text.splitlines()
|
|
75
|
+
normalized = []
|
|
76
|
+
|
|
77
|
+
for line_no, line in enumerate(lines, 1):
|
|
78
|
+
stripped = line.strip()
|
|
79
|
+
if not stripped:
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
if language == "python":
|
|
83
|
+
stripped = re.sub(r"#.*$", "", stripped)
|
|
84
|
+
elif language in ("javascript", "typescript", "java", "c", "cpp", "go", "rust"):
|
|
85
|
+
stripped = re.sub(r"//.*$", "", stripped)
|
|
86
|
+
stripped = re.sub(r"/\*.*?\*/", "", stripped)
|
|
87
|
+
|
|
88
|
+
stripped = re.sub(r"\s+", " ", stripped)
|
|
89
|
+
|
|
90
|
+
stripped = re.sub(r"\b\d+\b", "<LIT>", stripped)
|
|
91
|
+
stripped = re.sub(r"\b0x[0-9a-fA-F]+\b", "<LIT>", stripped)
|
|
92
|
+
stripped = re.sub(r'"[^"]*"', "<LIT>", stripped)
|
|
93
|
+
stripped = re.sub(r"'[^']*'", "<LIT>", stripped)
|
|
94
|
+
stripped = re.sub(r"`[^`]*`", "<LIT>", stripped)
|
|
95
|
+
|
|
96
|
+
stripped = re.sub(r"\b[a-zA-Z_][a-zA-Z0-9_]*\b", "<ID>", stripped)
|
|
97
|
+
|
|
98
|
+
normalized.append((line_no, stripped))
|
|
99
|
+
|
|
100
|
+
return normalized
|
|
101
|
+
|
|
102
|
+
def _sliding_window_hashes(self, tokens: list[tuple[int, str]], filepath: str) -> dict[str, list[tuple[str, int]]]:
|
|
103
|
+
window_hashes = defaultdict(list)
|
|
104
|
+
|
|
105
|
+
for i in range(len(tokens) - self.WINDOW_LINES + 1):
|
|
106
|
+
window = [t[1] for t in tokens[i:i + self.WINDOW_LINES]]
|
|
107
|
+
window_str = "\n".join(window)
|
|
108
|
+
h = hashlib.sha256(window_str.encode()).hexdigest()[:16]
|
|
109
|
+
|
|
110
|
+
start_line = tokens[i][0]
|
|
111
|
+
window_hashes[h].append((filepath, start_line))
|
|
112
|
+
|
|
113
|
+
return window_hashes
|
|
114
|
+
|
|
115
|
+
def _find_clusters(self, window_hashes: dict[str, list[tuple[str, int]]]) -> list[list[tuple[str, int]]]:
|
|
116
|
+
clusters = []
|
|
117
|
+
for h, locations in window_hashes.items():
|
|
118
|
+
if len(locations) >= self.MIN_OCCURRENCES:
|
|
119
|
+
clusters.append(locations)
|
|
120
|
+
return clusters
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Error handling anti-patterns detection engine using AST analysis."""
|
|
2
|
+
import ast
|
|
3
|
+
import sys
|
|
4
|
+
from orion.engine.config import EngineConfig
|
|
5
|
+
from orion.engine.ir import OrionIR
|
|
6
|
+
from orion.engine.results import EngineResult, Finding
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Engine:
|
|
10
|
+
name = "error_handling"
|
|
11
|
+
version = "1.0.0"
|
|
12
|
+
|
|
13
|
+
CRITICAL_EXCEPTIONS = {
|
|
14
|
+
"KeyboardInterrupt", "SystemExit", "GeneratorExit",
|
|
15
|
+
"MemoryError", "BaseException"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async def check_availability(self) -> bool:
|
|
19
|
+
return True
|
|
20
|
+
|
|
21
|
+
def resource_limits(self):
|
|
22
|
+
from orion.engine.config import ResourceLimits
|
|
23
|
+
return ResourceLimits(max_cpu_seconds=60, max_memory_mb=256)
|
|
24
|
+
|
|
25
|
+
async def run(self, ir: OrionIR, config: EngineConfig) -> EngineResult:
|
|
26
|
+
try:
|
|
27
|
+
text = ir.content.decode("utf-8", errors="replace")
|
|
28
|
+
except Exception:
|
|
29
|
+
return EngineResult(engine=self.name, findings=[], ok=True)
|
|
30
|
+
|
|
31
|
+
path = ir.metadata.get("path", "unknown")
|
|
32
|
+
is_test = self._is_test_file(path)
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
tree = ast.parse(text, filename=path)
|
|
36
|
+
except SyntaxError:
|
|
37
|
+
return EngineResult(engine=self.name, findings=[], ok=True)
|
|
38
|
+
|
|
39
|
+
findings = []
|
|
40
|
+
visitor = ErrorHandlingVisitor(path, is_test, text.splitlines())
|
|
41
|
+
visitor.visit(tree)
|
|
42
|
+
findings.extend(visitor.findings)
|
|
43
|
+
|
|
44
|
+
return EngineResult(engine=self.name, findings=findings, ok=True)
|
|
45
|
+
|
|
46
|
+
def _is_test_file(self, path: str) -> bool:
|
|
47
|
+
test_patterns = ["test_", "_test.py", "/tests/", "/test/", "conftest.py"]
|
|
48
|
+
return any(p in path for p in test_patterns)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ErrorHandlingVisitor(ast.NodeVisitor):
|
|
52
|
+
CRITICAL_EXCEPTIONS = {
|
|
53
|
+
"KeyboardInterrupt", "SystemExit", "GeneratorExit",
|
|
54
|
+
"MemoryError", "BaseException"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
def __init__(self, filepath: str, is_test: bool, lines: list[str]):
|
|
58
|
+
self.filepath = filepath
|
|
59
|
+
self.is_test = is_test
|
|
60
|
+
self.lines = lines
|
|
61
|
+
self.findings = []
|
|
62
|
+
self.in_test_context = is_test
|
|
63
|
+
self.except_counts = {}
|
|
64
|
+
self.in_finally = False
|
|
65
|
+
|
|
66
|
+
def visit_Try(self, node: ast.Try):
|
|
67
|
+
self._check_bare_except(node)
|
|
68
|
+
self._check_except_pass(node)
|
|
69
|
+
self._check_except_logging_only(node)
|
|
70
|
+
self._check_raise_without_from(node)
|
|
71
|
+
self._check_multiple_same_except(node)
|
|
72
|
+
self._check_critical_exception_caught(node)
|
|
73
|
+
self._check_try_with_pass_finally(node)
|
|
74
|
+
|
|
75
|
+
old_finally = self.in_finally
|
|
76
|
+
self.in_finally = True
|
|
77
|
+
for handler in node.handlers:
|
|
78
|
+
self.visit(handler)
|
|
79
|
+
for stmt in node.body:
|
|
80
|
+
self.visit(stmt)
|
|
81
|
+
for stmt in node.finalbody:
|
|
82
|
+
self.visit(stmt)
|
|
83
|
+
self.in_finally = old_finally
|
|
84
|
+
|
|
85
|
+
if node.orelse:
|
|
86
|
+
for stmt in node.orelse:
|
|
87
|
+
self.visit(stmt)
|
|
88
|
+
|
|
89
|
+
def _check_bare_except(self, node: ast.Try):
|
|
90
|
+
for handler in node.handlers:
|
|
91
|
+
if handler.type is None:
|
|
92
|
+
self._add_finding(
|
|
93
|
+
handler,
|
|
94
|
+
"Bare except clause",
|
|
95
|
+
"Bare 'except:' catches all exceptions including SystemExit and KeyboardInterrupt",
|
|
96
|
+
confidence=0.9,
|
|
97
|
+
cwe="CWE-396",
|
|
98
|
+
)
|
|
99
|
+
elif isinstance(handler.type, ast.Name):
|
|
100
|
+
if handler.type.id in self.CRITICAL_EXCEPTIONS and handler.name is None:
|
|
101
|
+
self._add_finding(
|
|
102
|
+
handler,
|
|
103
|
+
f"Catching {handler.type.id} without 'as'",
|
|
104
|
+
f"Catching {handler.type.id} without binding prevents proper handling",
|
|
105
|
+
confidence=0.85,
|
|
106
|
+
cwe="CWE-396",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def _check_except_pass(self, node: ast.Try):
|
|
110
|
+
for handler in node.handlers:
|
|
111
|
+
if len(handler.body) == 1:
|
|
112
|
+
stmt = handler.body[0]
|
|
113
|
+
if isinstance(stmt, ast.Pass):
|
|
114
|
+
self._add_finding(
|
|
115
|
+
handler,
|
|
116
|
+
"Empty except block (pass)",
|
|
117
|
+
"Exception caught and silently ignored",
|
|
118
|
+
confidence=0.85,
|
|
119
|
+
cwe="CWE-391",
|
|
120
|
+
)
|
|
121
|
+
elif isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant) and stmt.value.value is Ellipsis:
|
|
122
|
+
self._add_finding(
|
|
123
|
+
handler,
|
|
124
|
+
"Empty except block (Ellipsis)",
|
|
125
|
+
"Exception caught and ignored with '...'",
|
|
126
|
+
confidence=0.85,
|
|
127
|
+
cwe="CWE-391",
|
|
128
|
+
)
|
|
129
|
+
elif isinstance(stmt, ast.Return):
|
|
130
|
+
self._add_finding(
|
|
131
|
+
handler,
|
|
132
|
+
"Except block only returns",
|
|
133
|
+
"Exception caught and function returns without handling",
|
|
134
|
+
confidence=0.8,
|
|
135
|
+
cwe="CWE-391",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def _check_except_logging_only(self, node: ast.Try):
|
|
139
|
+
for handler in node.handlers:
|
|
140
|
+
if len(handler.body) == 1:
|
|
141
|
+
stmt = handler.body[0]
|
|
142
|
+
if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
|
|
143
|
+
func = stmt.value.func
|
|
144
|
+
if isinstance(func, ast.Attribute):
|
|
145
|
+
if func.attr in ("error", "exception", "warning", "info", "debug", "critical"):
|
|
146
|
+
if isinstance(func.value, ast.Name) and func.value.id in ("logging", "logger", "log"):
|
|
147
|
+
self._add_finding(
|
|
148
|
+
handler,
|
|
149
|
+
"Except only logs",
|
|
150
|
+
"Exception caught but only logged without recovery or re-raise",
|
|
151
|
+
confidence=0.75,
|
|
152
|
+
cwe="CWE-391",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def _check_raise_without_from(self, node: ast.Try):
|
|
156
|
+
for handler in node.handlers:
|
|
157
|
+
for stmt in ast.walk(handler):
|
|
158
|
+
if isinstance(stmt, ast.Raise) and stmt.exc and not stmt.cause:
|
|
159
|
+
if not (isinstance(stmt.exc, ast.Name) and stmt.exc.id in self.CRITICAL_EXCEPTIONS):
|
|
160
|
+
self._add_finding(
|
|
161
|
+
stmt,
|
|
162
|
+
"Raise without 'from' (lost exception cause)",
|
|
163
|
+
"Re-raising exception without 'from' loses original traceback",
|
|
164
|
+
confidence=0.8,
|
|
165
|
+
cwe="CWE-390",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def _check_multiple_same_except(self, node: ast.Try):
|
|
169
|
+
exc_types = []
|
|
170
|
+
for handler in node.handlers:
|
|
171
|
+
if handler.type:
|
|
172
|
+
if isinstance(handler.type, ast.Name):
|
|
173
|
+
exc_types.append(handler.type.id)
|
|
174
|
+
elif isinstance(handler.type, ast.Tuple):
|
|
175
|
+
for elt in handler.type.elts:
|
|
176
|
+
if isinstance(elt, ast.Name):
|
|
177
|
+
exc_types.append(elt.id)
|
|
178
|
+
|
|
179
|
+
for exc in set(exc_types):
|
|
180
|
+
if exc_types.count(exc) > 1:
|
|
181
|
+
self._add_finding(
|
|
182
|
+
node,
|
|
183
|
+
f"Multiple except blocks for same exception: {exc}",
|
|
184
|
+
f"Exception '{exc}' caught in multiple except blocks - consolidate",
|
|
185
|
+
confidence=0.8,
|
|
186
|
+
cwe="CWE-397",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def _check_critical_exception_caught(self, node: ast.Try):
|
|
190
|
+
for handler in node.handlers:
|
|
191
|
+
if handler.type:
|
|
192
|
+
if isinstance(handler.type, ast.Name):
|
|
193
|
+
if handler.type.id in self.CRITICAL_EXCEPTIONS:
|
|
194
|
+
has_reraise = any(
|
|
195
|
+
isinstance(stmt, ast.Raise) and (stmt.exc is None or
|
|
196
|
+
(isinstance(stmt.exc, ast.Name) and stmt.exc.id == handler.type.id))
|
|
197
|
+
for stmt in ast.walk(handler) if isinstance(stmt, ast.Raise)
|
|
198
|
+
)
|
|
199
|
+
if not has_reraise:
|
|
200
|
+
self._add_finding(
|
|
201
|
+
handler,
|
|
202
|
+
f"Catching {handler.type.id} without re-raise",
|
|
203
|
+
f"Critical exception {handler.type.id} caught but not re-raised",
|
|
204
|
+
confidence=0.9,
|
|
205
|
+
cwe="CWE-396",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def _check_try_with_pass_finally(self, node: ast.Try):
|
|
209
|
+
if node.finalbody and len(node.finalbody) == 1:
|
|
210
|
+
if isinstance(node.finalbody[0], ast.Pass):
|
|
211
|
+
self._add_finding(
|
|
212
|
+
node,
|
|
213
|
+
"Try block with only 'pass' in finally",
|
|
214
|
+
"Finally block contains only 'pass' - consider removing",
|
|
215
|
+
confidence=0.75,
|
|
216
|
+
cwe="CWE-1054",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def visit_Assert(self, node: ast.Assert):
|
|
220
|
+
if not self.in_test_context:
|
|
221
|
+
self._add_finding(
|
|
222
|
+
node,
|
|
223
|
+
"Assert in production code",
|
|
224
|
+
"Assert statements are optimized out with -O flag",
|
|
225
|
+
confidence=0.8,
|
|
226
|
+
cwe="CWE-617",
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def visit_Call(self, node: ast.Call):
|
|
230
|
+
if isinstance(node.func, ast.Attribute):
|
|
231
|
+
if node.func.attr == "exit" and isinstance(node.func.value, ast.Name) and node.func.value.id == "sys":
|
|
232
|
+
if not self.in_test_context:
|
|
233
|
+
self._add_finding(
|
|
234
|
+
node,
|
|
235
|
+
"sys.exit() in library code",
|
|
236
|
+
"sys.exit() should not be used in library/module code",
|
|
237
|
+
confidence=0.85,
|
|
238
|
+
cwe="CWE-617",
|
|
239
|
+
)
|
|
240
|
+
self.generic_visit(node)
|
|
241
|
+
|
|
242
|
+
def _add_finding(self, node: ast.AST, title: str, description: str, confidence: float, cwe: str):
|
|
243
|
+
line_no = getattr(node, "lineno", 0)
|
|
244
|
+
snippet = self.lines[line_no - 1].strip() if 0 < line_no <= len(self.lines) else ""
|
|
245
|
+
self.findings.append(Finding(
|
|
246
|
+
engine="error_handling",
|
|
247
|
+
severity="high" if confidence >= 0.85 else "medium",
|
|
248
|
+
title=title,
|
|
249
|
+
description=description,
|
|
250
|
+
location=f"{self.filepath}:{line_no}",
|
|
251
|
+
metadata={
|
|
252
|
+
"confidence": confidence,
|
|
253
|
+
"snippet": snippet[:200],
|
|
254
|
+
"cwe": cwe,
|
|
255
|
+
},
|
|
256
|
+
))
|
|
Binary file
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""False positive learning engine - learns from user feedback to reduce false positives."""
|
|
2
|
+
import json
|
|
3
|
+
import hashlib
|
|
4
|
+
import ast
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, List, Optional, Any, Set
|
|
7
|
+
from dataclasses import dataclass, asdict
|
|
8
|
+
from collections import defaultdict
|
|
9
|
+
|
|
10
|
+
from orion.engine.config import EngineConfig
|
|
11
|
+
from orion.engine.ir import OrionIR
|
|
12
|
+
from orion.engine.results import EngineResult, Finding
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class FPFeature:
|
|
17
|
+
engine: str
|
|
18
|
+
pattern: str
|
|
19
|
+
file_type: str
|
|
20
|
+
ast_context: str
|
|
21
|
+
confidence_reduction: float = 0.3
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class FeedbackEntry:
|
|
26
|
+
engine: str
|
|
27
|
+
location: str
|
|
28
|
+
title: str
|
|
29
|
+
feedback: str
|
|
30
|
+
reason: str
|
|
31
|
+
timestamp: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FPLearnEngine:
|
|
35
|
+
name = "fp_learn"
|
|
36
|
+
version = "1.0.0"
|
|
37
|
+
|
|
38
|
+
FEEDBACK_FILE = ".orion-feedback.json"
|
|
39
|
+
|
|
40
|
+
async def check_availability(self) -> bool:
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
def resource_limits(self):
|
|
44
|
+
from orion.engine.config import ResourceLimits
|
|
45
|
+
return ResourceLimits(max_cpu_seconds=10, max_memory_mb=64)
|
|
46
|
+
|
|
47
|
+
def _load_feedback(self, project_root: str) -> List[FeedbackEntry]:
|
|
48
|
+
"""Load feedback from .orion-feedback.json."""
|
|
49
|
+
feedback_path = Path(project_root) / self.FEEDBACK_FILE
|
|
50
|
+
if not feedback_path.exists():
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
with open(feedback_path, "r") as f:
|
|
55
|
+
data = json.load(f)
|
|
56
|
+
entries = []
|
|
57
|
+
for item in data.get("findings", []):
|
|
58
|
+
entries.append(FeedbackEntry(
|
|
59
|
+
engine=item.get("engine", ""),
|
|
60
|
+
location=item.get("location", ""),
|
|
61
|
+
title=item.get("title", ""),
|
|
62
|
+
feedback=item.get("feedback", ""),
|
|
63
|
+
reason=item.get("reason", ""),
|
|
64
|
+
timestamp=item.get("timestamp", ""),
|
|
65
|
+
))
|
|
66
|
+
return entries
|
|
67
|
+
except Exception:
|
|
68
|
+
return []
|
|
69
|
+
|
|
70
|
+
def _extract_features(self, finding: Finding, content: str) -> FPFeature:
|
|
71
|
+
"""Extract features from a finding for FP matching."""
|
|
72
|
+
engine = finding.engine
|
|
73
|
+
# Normalize pattern key - use metadata.pattern if available, else derive from title
|
|
74
|
+
pattern = finding.metadata.get("pattern", "")
|
|
75
|
+
if not pattern:
|
|
76
|
+
pattern = finding.title.lower().replace(" ", "_")
|
|
77
|
+
# Normalize: replace spaces with underscores, lowercase
|
|
78
|
+
pattern = pattern.lower().replace(" ", "_")
|
|
79
|
+
file_type = Path(finding.metadata.get("file", "")).suffix
|
|
80
|
+
|
|
81
|
+
# Extract AST context around the finding
|
|
82
|
+
ast_context = ""
|
|
83
|
+
if file_type == ".py":
|
|
84
|
+
try:
|
|
85
|
+
tree = ast.parse(content)
|
|
86
|
+
line = finding.metadata.get("line", 0)
|
|
87
|
+
for node in ast.walk(tree):
|
|
88
|
+
if hasattr(node, 'lineno') and node.lineno == line:
|
|
89
|
+
ast_context = type(node).__name__
|
|
90
|
+
break
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
return FPFeature(
|
|
95
|
+
engine=engine,
|
|
96
|
+
pattern=pattern,
|
|
97
|
+
file_type=file_type,
|
|
98
|
+
ast_context=ast_context,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def _feature_hash(self, feature: FPFeature) -> str:
|
|
102
|
+
"""Generate a hash for a feature set."""
|
|
103
|
+
data = f"{feature.engine}:{feature.pattern}:{feature.file_type}:{feature.ast_context}"
|
|
104
|
+
return hashlib.sha256(data.encode()).hexdigest()[:16]
|
|
105
|
+
|
|
106
|
+
def _learn_fp_patterns(self, feedback_entries: List[FeedbackEntry], all_files: Dict[str, OrionIR]) -> Set[str]:
|
|
107
|
+
"""Learn FP patterns from feedback."""
|
|
108
|
+
fp_hashes = set()
|
|
109
|
+
|
|
110
|
+
for entry in feedback_entries:
|
|
111
|
+
if entry.feedback != "false_positive":
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
# Find the file content
|
|
115
|
+
file_path = entry.location.split(":")[0] if ":" in entry.location else ""
|
|
116
|
+
line_str = entry.location.split(":")[1] if ":" in entry.location else "0"
|
|
117
|
+
try:
|
|
118
|
+
line_num = int(line_str)
|
|
119
|
+
except ValueError:
|
|
120
|
+
line_num = 0
|
|
121
|
+
|
|
122
|
+
file_ir = all_files.get(file_path)
|
|
123
|
+
if not file_ir:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
content = file_ir.content.decode("utf-8", errors="replace")
|
|
128
|
+
except Exception:
|
|
129
|
+
content = ""
|
|
130
|
+
|
|
131
|
+
# Create a pseudo-finding to extract features
|
|
132
|
+
pseudo_finding = Finding(
|
|
133
|
+
engine=entry.engine,
|
|
134
|
+
severity="",
|
|
135
|
+
title=entry.title,
|
|
136
|
+
description="",
|
|
137
|
+
location=entry.location,
|
|
138
|
+
metadata={"pattern": entry.title.lower().replace(" ", "_"), "line": line_num, "file": file_path},
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
feature = self._extract_features(pseudo_finding, content)
|
|
142
|
+
fp_hashes.add(self._feature_hash(feature))
|
|
143
|
+
|
|
144
|
+
return fp_hashes
|
|
145
|
+
|
|
146
|
+
def _apply_fp_reduction(self, findings: List[Finding], fp_hashes: Set[str], all_files: Dict[str, OrionIR]) -> List[Finding]:
|
|
147
|
+
"""Apply FP reduction to findings."""
|
|
148
|
+
adjusted = []
|
|
149
|
+
fp_stats = {"learned": len(fp_hashes), "applied": 0, "suppressed": 0}
|
|
150
|
+
|
|
151
|
+
for finding in findings:
|
|
152
|
+
file_path = finding.metadata.get("file", finding.location.split(":")[0] if ":" in finding.location else "")
|
|
153
|
+
file_ir = all_files.get(file_path)
|
|
154
|
+
if not file_ir:
|
|
155
|
+
adjusted.append(finding)
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
content = file_ir.content.decode("utf-8", errors="replace")
|
|
160
|
+
except Exception:
|
|
161
|
+
content = ""
|
|
162
|
+
|
|
163
|
+
feature = self._extract_features(finding, content)
|
|
164
|
+
feature_hash = self._feature_hash(feature)
|
|
165
|
+
|
|
166
|
+
if feature_hash in fp_hashes:
|
|
167
|
+
# Reduce confidence by 0.3
|
|
168
|
+
original_confidence = finding.metadata.get("confidence", 0.8)
|
|
169
|
+
new_confidence = max(0.0, original_confidence - 0.3)
|
|
170
|
+
|
|
171
|
+
if new_confidence < 0.7:
|
|
172
|
+
# Suppress finding (below threshold)
|
|
173
|
+
fp_stats["suppressed"] += 1
|
|
174
|
+
# Don't add to adjusted findings
|
|
175
|
+
continue
|
|
176
|
+
else:
|
|
177
|
+
fp_stats["applied"] += 1
|
|
178
|
+
adjusted.append(Finding(
|
|
179
|
+
engine=finding.engine,
|
|
180
|
+
severity=finding.severity,
|
|
181
|
+
title=finding.title,
|
|
182
|
+
description=finding.description,
|
|
183
|
+
location=finding.location,
|
|
184
|
+
metadata={
|
|
185
|
+
**finding.metadata,
|
|
186
|
+
"confidence": new_confidence,
|
|
187
|
+
"fp_adjusted": True,
|
|
188
|
+
"original_confidence": original_confidence,
|
|
189
|
+
},
|
|
190
|
+
))
|
|
191
|
+
else:
|
|
192
|
+
adjusted.append(finding)
|
|
193
|
+
|
|
194
|
+
# Store stats in the last finding's metadata or return via metrics
|
|
195
|
+
return adjusted, fp_stats
|
|
196
|
+
|
|
197
|
+
async def run(self, ir: OrionIR, config: EngineConfig) -> EngineResult:
|
|
198
|
+
project_root = ir.metadata.get("project_root", ".")
|
|
199
|
+
all_files = ir.metadata.get("all_files", {})
|
|
200
|
+
|
|
201
|
+
if not all_files:
|
|
202
|
+
all_files = {ir.metadata.get("path", "unknown"): ir}
|
|
203
|
+
|
|
204
|
+
# Get all findings from context
|
|
205
|
+
all_findings = ir.metadata.get("all_findings", [])
|
|
206
|
+
|
|
207
|
+
# Also check project context
|
|
208
|
+
project_ctx = ir.metadata.get("project_context")
|
|
209
|
+
if project_ctx and hasattr(project_ctx, 'findings'):
|
|
210
|
+
all_findings.extend(project_ctx.findings)
|
|
211
|
+
|
|
212
|
+
# Load feedback
|
|
213
|
+
feedback_entries = self._load_feedback(project_root)
|
|
214
|
+
|
|
215
|
+
if not feedback_entries:
|
|
216
|
+
return EngineResult(
|
|
217
|
+
engine=self.name,
|
|
218
|
+
findings=[],
|
|
219
|
+
ok=True,
|
|
220
|
+
metrics={
|
|
221
|
+
"feedback_entries": 0,
|
|
222
|
+
"fp_stats": {"learned": 0, "applied": 0, "suppressed": 0},
|
|
223
|
+
},
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# Learn FP patterns
|
|
227
|
+
fp_hashes = self._learn_fp_patterns(feedback_entries, all_files)
|
|
228
|
+
|
|
229
|
+
# Apply FP reduction to all findings
|
|
230
|
+
adjusted_findings, fp_stats = self._apply_fp_reduction(all_findings, fp_hashes, all_files)
|
|
231
|
+
|
|
232
|
+
# This engine doesn't produce its own findings - it adjusts others
|
|
233
|
+
# But we return metrics with the stats
|
|
234
|
+
return EngineResult(
|
|
235
|
+
engine=self.name,
|
|
236
|
+
findings=[],
|
|
237
|
+
ok=True,
|
|
238
|
+
metrics={
|
|
239
|
+
"feedback_entries": len(feedback_entries),
|
|
240
|
+
"fp_patterns_learned": len(fp_hashes),
|
|
241
|
+
"fp_stats": fp_stats,
|
|
242
|
+
"findings_adjusted": fp_stats["applied"],
|
|
243
|
+
"findings_suppressed": fp_stats["suppressed"],
|
|
244
|
+
},
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
Engine = FPLearnEngine
|
|
Binary file
|