@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,345 @@
|
|
|
1
|
+
"""Performance anti-patterns detection engine - AST analysis."""
|
|
2
|
+
import ast
|
|
3
|
+
import re
|
|
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 = "perf_guard"
|
|
11
|
+
version = "1.0.0"
|
|
12
|
+
|
|
13
|
+
DB_QUERY_METHODS = {
|
|
14
|
+
"execute", "executemany", "query", "find", "find_one", "get", "filter",
|
|
15
|
+
"filter_by", "all", "first", "one", "scalar", "scalars", "fetchone",
|
|
16
|
+
"fetchall", "fetchmany", "select", "insert", "update", "delete",
|
|
17
|
+
"aggregate", "count", "exists", "bulk_insert", "bulk_update",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
HOT_LOOP_METHODS = {"append", "extend", "insert", "add", "update"}
|
|
21
|
+
STRING_CONCAT_PATTERNS = {"__iadd__", "__add__"}
|
|
22
|
+
SYNC_IO_IN_ASYNC = {"open", "read", "write", "readline", "readlines", "requests.get", "requests.post", "requests.put", "requests.delete", "requests.request", "urllib.request.urlopen", "httpx.get", "httpx.post", "httpx.put", "httpx.delete"}
|
|
23
|
+
|
|
24
|
+
COMPILE_PATTERN = re.compile(r"re\.compile|re\.search|re\.match|re\.findall|re\.sub|re\.split")
|
|
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
|
+
async def run(self, ir: OrionIR, config: EngineConfig) -> EngineResult:
|
|
34
|
+
try:
|
|
35
|
+
text = ir.content.decode("utf-8", errors="replace")
|
|
36
|
+
except Exception:
|
|
37
|
+
return EngineResult(engine=self.name, findings=[], ok=True)
|
|
38
|
+
|
|
39
|
+
path = ir.metadata.get("path", "unknown")
|
|
40
|
+
is_test = self._is_test_file(path)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
tree = ast.parse(text, filename=path)
|
|
44
|
+
except SyntaxError:
|
|
45
|
+
return EngineResult(engine=self.name, findings=[], ok=True)
|
|
46
|
+
|
|
47
|
+
findings = []
|
|
48
|
+
visitor = PerfVisitor(path, is_test, text.splitlines())
|
|
49
|
+
visitor.visit(tree)
|
|
50
|
+
findings.extend(visitor.findings)
|
|
51
|
+
|
|
52
|
+
findings.extend(self._check_regex_compile_in_loop(text, path))
|
|
53
|
+
findings.extend(self._check_json_in_hot_path(text, path))
|
|
54
|
+
|
|
55
|
+
return EngineResult(engine=self.name, findings=findings, ok=True)
|
|
56
|
+
|
|
57
|
+
def _is_test_file(self, path: str) -> bool:
|
|
58
|
+
return any(p in path for p in ["test_", "_test.py", "/tests/", "/test/", "conftest.py"])
|
|
59
|
+
|
|
60
|
+
def _check_regex_compile_in_loop(self, text: str, path: str) -> list[Finding]:
|
|
61
|
+
findings = []
|
|
62
|
+
lines = text.splitlines()
|
|
63
|
+
in_loop = False
|
|
64
|
+
loop_depth = 0
|
|
65
|
+
|
|
66
|
+
for i, line in enumerate(lines):
|
|
67
|
+
stripped = line.strip()
|
|
68
|
+
if re.match(r"^\s*(for|while)\s+", stripped):
|
|
69
|
+
loop_depth += 1
|
|
70
|
+
in_loop = True
|
|
71
|
+
elif stripped and not stripped.startswith("#"):
|
|
72
|
+
if loop_depth > 0:
|
|
73
|
+
if self.COMPILE_PATTERN.search(line):
|
|
74
|
+
findings.append(Finding(
|
|
75
|
+
engine=self.name,
|
|
76
|
+
severity="medium",
|
|
77
|
+
title="re.compile in loop",
|
|
78
|
+
description="Regex compilation inside loop - compile once outside loop",
|
|
79
|
+
location=f"{path}:{i + 1}",
|
|
80
|
+
metadata={
|
|
81
|
+
"confidence": 0.85,
|
|
82
|
+
"snippet": line.strip()[:200],
|
|
83
|
+
"pattern": "re_compile_in_loop",
|
|
84
|
+
"cwe": "CWE-1054",
|
|
85
|
+
},
|
|
86
|
+
))
|
|
87
|
+
|
|
88
|
+
if loop_depth > 0 and stripped and not re.match(r"^\s*(for|while|if|elif|else|try|except|finally|with|def|class|async)\s", stripped):
|
|
89
|
+
pass
|
|
90
|
+
if "dedent" in stripped.lower() or (stripped == "" and loop_depth > 0):
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
indent = len(line) - len(line.lstrip())
|
|
94
|
+
if i + 1 < len(lines):
|
|
95
|
+
next_indent = len(lines[i + 1]) - len(lines[i + 1].lstrip())
|
|
96
|
+
if next_indent < indent and loop_depth > 0:
|
|
97
|
+
loop_depth = max(0, loop_depth - 1)
|
|
98
|
+
|
|
99
|
+
return findings
|
|
100
|
+
|
|
101
|
+
def _check_json_in_hot_path(self, text: str, path: str) -> list[Finding]:
|
|
102
|
+
findings = []
|
|
103
|
+
lines = text.splitlines()
|
|
104
|
+
in_loop = False
|
|
105
|
+
loop_depth = 0
|
|
106
|
+
|
|
107
|
+
for i, line in enumerate(lines):
|
|
108
|
+
stripped = line.strip()
|
|
109
|
+
if re.match(r"^\s*(for|while)\s+", stripped):
|
|
110
|
+
loop_depth += 1
|
|
111
|
+
in_loop = True
|
|
112
|
+
elif in_loop and ("json.loads" in line or "json.dumps" in line or "json.load" in line or "json.dump" in line):
|
|
113
|
+
findings.append(Finding(
|
|
114
|
+
engine=self.name,
|
|
115
|
+
severity="medium",
|
|
116
|
+
title="json.loads/dumps in loop",
|
|
117
|
+
description="JSON serialization in hot loop - consider batching or caching",
|
|
118
|
+
location=f"{path}:{i + 1}",
|
|
119
|
+
metadata={
|
|
120
|
+
"confidence": 0.8,
|
|
121
|
+
"snippet": line.strip()[:200],
|
|
122
|
+
"pattern": "json_in_loop",
|
|
123
|
+
"cwe": "CWE-1054",
|
|
124
|
+
},
|
|
125
|
+
))
|
|
126
|
+
|
|
127
|
+
indent = len(line) - len(line.lstrip())
|
|
128
|
+
if i + 1 < len(lines):
|
|
129
|
+
next_indent = len(lines[i + 1]) - len(lines[i + 1].lstrip())
|
|
130
|
+
if next_indent < indent and loop_depth > 0:
|
|
131
|
+
loop_depth = max(0, loop_depth - 1)
|
|
132
|
+
|
|
133
|
+
return findings
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class PerfVisitor(ast.NodeVisitor):
|
|
137
|
+
def __init__(self, filepath: str, is_test: bool, lines: list[str]):
|
|
138
|
+
self.filepath = filepath
|
|
139
|
+
self.is_test = is_test
|
|
140
|
+
self.lines = lines
|
|
141
|
+
self.findings = []
|
|
142
|
+
self.loop_vars = []
|
|
143
|
+
self.in_async_function = False
|
|
144
|
+
self.current_loop_depth = 0
|
|
145
|
+
|
|
146
|
+
def visit_For(self, node: ast.For):
|
|
147
|
+
old_depth = self.current_loop_depth
|
|
148
|
+
self.current_loop_depth += 1
|
|
149
|
+
|
|
150
|
+
if self.current_loop_depth >= 2:
|
|
151
|
+
self._check_nested_loops(node)
|
|
152
|
+
|
|
153
|
+
self._check_db_query_in_loop(node)
|
|
154
|
+
self._check_list_append_in_loop(node)
|
|
155
|
+
self._check_string_concat_in_loop(node)
|
|
156
|
+
self._check_large_in_memory_load(node)
|
|
157
|
+
self._check_missing_pagination(node)
|
|
158
|
+
|
|
159
|
+
old_vars = self.loop_vars[:]
|
|
160
|
+
if isinstance(node.target, ast.Name):
|
|
161
|
+
self.loop_vars.append(node.target.id)
|
|
162
|
+
elif isinstance(node.target, ast.Tuple):
|
|
163
|
+
for elt in node.target.elts:
|
|
164
|
+
if isinstance(elt, ast.Name):
|
|
165
|
+
self.loop_vars.append(elt.id)
|
|
166
|
+
|
|
167
|
+
self.generic_visit(node)
|
|
168
|
+
|
|
169
|
+
self.loop_vars = old_vars
|
|
170
|
+
self.current_loop_depth = old_depth
|
|
171
|
+
|
|
172
|
+
def visit_While(self, node: ast.While):
|
|
173
|
+
old_depth = self.current_loop_depth
|
|
174
|
+
self.current_loop_depth += 1
|
|
175
|
+
|
|
176
|
+
if self.current_loop_depth >= 2:
|
|
177
|
+
self._check_nested_loops(node)
|
|
178
|
+
|
|
179
|
+
self._check_db_query_in_loop(node)
|
|
180
|
+
self._check_list_append_in_loop(node)
|
|
181
|
+
self._check_string_concat_in_loop(node)
|
|
182
|
+
self._check_large_in_memory_load(node)
|
|
183
|
+
self._check_missing_pagination(node)
|
|
184
|
+
|
|
185
|
+
# While nodes don't have a target like For nodes
|
|
186
|
+
old_vars = self.loop_vars[:]
|
|
187
|
+
self.generic_visit(node)
|
|
188
|
+
self.loop_vars = old_vars
|
|
189
|
+
self.current_loop_depth = old_depth
|
|
190
|
+
|
|
191
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
|
|
192
|
+
old_async = self.in_async_function
|
|
193
|
+
self.in_async_function = True
|
|
194
|
+
self.generic_visit(node)
|
|
195
|
+
self.in_async_function = old_async
|
|
196
|
+
|
|
197
|
+
def visit_Call(self, node: ast.Call):
|
|
198
|
+
if self.in_async_function:
|
|
199
|
+
self._check_sync_io_in_async(node)
|
|
200
|
+
|
|
201
|
+
self._check_repeated_compile(node)
|
|
202
|
+
|
|
203
|
+
self.generic_visit(node)
|
|
204
|
+
|
|
205
|
+
def _check_nested_loops(self, node: ast.For):
|
|
206
|
+
outer_var = self.loop_vars[-1] if self.loop_vars else None
|
|
207
|
+
inner_var = None
|
|
208
|
+
if isinstance(node.target, ast.Name):
|
|
209
|
+
inner_var = node.target.id
|
|
210
|
+
elif isinstance(node.target, ast.Tuple):
|
|
211
|
+
for elt in node.target.elts:
|
|
212
|
+
if isinstance(elt, ast.Name):
|
|
213
|
+
inner_var = elt.id
|
|
214
|
+
break
|
|
215
|
+
|
|
216
|
+
if outer_var and inner_var and outer_var == inner_var:
|
|
217
|
+
self._add_finding(node, "nested_loop_same_var",
|
|
218
|
+
f"Nested loops using same variable '{outer_var}' - likely O(n²)",
|
|
219
|
+
"Use different variable names or consider algorithm optimization",
|
|
220
|
+
confidence=0.8)
|
|
221
|
+
|
|
222
|
+
def _check_db_query_in_loop(self, node: ast.For):
|
|
223
|
+
db_callers = {"db", "session", "cursor", "conn", "connection", "repo", "repository", "query", "orm"}
|
|
224
|
+
strict_db_methods = {"execute", "executemany", "raw", "raw_sql", "fetchmany", "fetchall"}
|
|
225
|
+
|
|
226
|
+
for stmt in ast.walk(node):
|
|
227
|
+
if isinstance(stmt, ast.Call) and isinstance(stmt.func, ast.Attribute):
|
|
228
|
+
caller_name = ""
|
|
229
|
+
if isinstance(stmt.func.value, ast.Name):
|
|
230
|
+
caller_name = stmt.func.value.id.lower()
|
|
231
|
+
|
|
232
|
+
method_name = stmt.func.attr.lower()
|
|
233
|
+
|
|
234
|
+
# Flag only if explicit DB caller or strictly SQL execution method on a database object
|
|
235
|
+
if (caller_name in db_callers and method_name in {"execute", "query", "find", "select", "insert", "update", "delete"}) or \
|
|
236
|
+
(method_name in strict_db_methods and caller_name not in {"dict", "json", "re", "os", "sys"}):
|
|
237
|
+
self._add_finding(stmt, "n_plus_one_query",
|
|
238
|
+
f"Database query '{stmt.func.attr}' in loop - potential N+1",
|
|
239
|
+
"Use batch loading, joins, or prefetch to avoid N+1 queries",
|
|
240
|
+
confidence=0.85)
|
|
241
|
+
break
|
|
242
|
+
|
|
243
|
+
def _check_list_append_in_loop(self, node: ast.For):
|
|
244
|
+
# Do not flag standard list accumulation as an error
|
|
245
|
+
pass
|
|
246
|
+
|
|
247
|
+
def _is_list_comprehension_possible(self, loop_node: ast.For, list_var: str) -> bool:
|
|
248
|
+
append_count = 0
|
|
249
|
+
other_stmts = 0
|
|
250
|
+
for stmt in ast.walk(loop_node):
|
|
251
|
+
if stmt is loop_node:
|
|
252
|
+
continue
|
|
253
|
+
if isinstance(stmt, ast.Call) and isinstance(stmt.func, ast.Attribute):
|
|
254
|
+
if stmt.func.attr == "append" and isinstance(stmt.func.value, ast.Name) and stmt.func.value.id == list_var:
|
|
255
|
+
append_count += 1
|
|
256
|
+
elif isinstance(stmt, (ast.Assign, ast.AugAssign, ast.If, ast.For, ast.While, ast.Try, ast.With)):
|
|
257
|
+
other_stmts += 1
|
|
258
|
+
return append_count > 0 and other_stmts == 0
|
|
259
|
+
|
|
260
|
+
def _check_string_concat_in_loop(self, node: ast.For):
|
|
261
|
+
for stmt in ast.walk(node):
|
|
262
|
+
if isinstance(stmt, ast.AugAssign) and isinstance(stmt.op, ast.Add):
|
|
263
|
+
if isinstance(stmt.target, ast.Name):
|
|
264
|
+
self._add_finding(stmt, "string_concat_in_loop",
|
|
265
|
+
f"String concatenation (+=) in loop for '{stmt.target.id}'",
|
|
266
|
+
"Use list append + ''.join() or StringIO for better performance",
|
|
267
|
+
confidence=0.85)
|
|
268
|
+
break
|
|
269
|
+
|
|
270
|
+
def _check_large_in_memory_load(self, node: ast.For):
|
|
271
|
+
for stmt in ast.walk(node):
|
|
272
|
+
if isinstance(stmt, ast.Call) and isinstance(stmt.func, ast.Attribute):
|
|
273
|
+
if stmt.func.attr in ("fetchall", "read", "readlines", "read_all"):
|
|
274
|
+
if isinstance(stmt.func.value, ast.Name):
|
|
275
|
+
self._add_finding(stmt, "large_in_memory_load",
|
|
276
|
+
f"Large in-memory load '{stmt.func.attr}()' in loop",
|
|
277
|
+
"Use pagination, streaming, or chunked processing",
|
|
278
|
+
confidence=0.8)
|
|
279
|
+
break
|
|
280
|
+
|
|
281
|
+
def _check_missing_pagination(self, node: ast.For):
|
|
282
|
+
for stmt in ast.walk(node):
|
|
283
|
+
if isinstance(stmt, ast.Call) and isinstance(stmt.func, ast.Attribute):
|
|
284
|
+
if stmt.func.attr in ("query", "filter", "filter_by", "find", "select", "all"):
|
|
285
|
+
has_limit = False
|
|
286
|
+
has_offset = False
|
|
287
|
+
for kw in stmt.keywords:
|
|
288
|
+
if kw.arg in ("limit", "offset", "page", "per_page", "skip", "take"):
|
|
289
|
+
if kw.arg in ("limit", "per_page", "take", "page_size"):
|
|
290
|
+
has_limit = True
|
|
291
|
+
if kw.arg in ("offset", "skip", "page"):
|
|
292
|
+
has_offset = True
|
|
293
|
+
if not has_limit and not has_offset:
|
|
294
|
+
self._add_finding(stmt, "missing_pagination",
|
|
295
|
+
f"Query '{stmt.func.attr}' without pagination (limit/offset)",
|
|
296
|
+
"Add limit/offset or pagination to prevent large result sets",
|
|
297
|
+
confidence=0.75)
|
|
298
|
+
break
|
|
299
|
+
|
|
300
|
+
def _check_sync_io_in_async(self, node: ast.Call):
|
|
301
|
+
func_name = self._get_call_name(node)
|
|
302
|
+
if func_name and any(pattern in func_name for pattern in self.SYNC_IO_IN_ASYNC):
|
|
303
|
+
self._add_finding(node, "sync_io_in_async",
|
|
304
|
+
f"Synchronous I/O '{func_name}' in async function",
|
|
305
|
+
"Use async alternatives (aiofiles, aiohttp, asyncpg, etc.)",
|
|
306
|
+
confidence=0.85)
|
|
307
|
+
|
|
308
|
+
def _check_repeated_compile(self, node: ast.Call):
|
|
309
|
+
func_name = self._get_call_name(node)
|
|
310
|
+
if func_name and "re.compile" in func_name:
|
|
311
|
+
pass
|
|
312
|
+
|
|
313
|
+
def _get_call_name(self, node: ast.Call) -> str | None:
|
|
314
|
+
if isinstance(node.func, ast.Name):
|
|
315
|
+
return node.func.id
|
|
316
|
+
elif isinstance(node.func, ast.Attribute):
|
|
317
|
+
parts = []
|
|
318
|
+
current = node.func
|
|
319
|
+
while isinstance(current, ast.Attribute):
|
|
320
|
+
parts.append(current.attr)
|
|
321
|
+
current = current.value
|
|
322
|
+
if isinstance(current, ast.Name):
|
|
323
|
+
parts.append(current.id)
|
|
324
|
+
return ".".join(reversed(parts))
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
def _add_finding(self, node: ast.AST, pattern: str, title: str, description: str, confidence: float):
|
|
328
|
+
line_no = getattr(node, "lineno", 0)
|
|
329
|
+
snippet = self.lines[line_no - 1].strip() if 0 < line_no <= len(self.lines) else ""
|
|
330
|
+
self.findings.append(Finding(
|
|
331
|
+
engine="perf_guard",
|
|
332
|
+
severity="high" if confidence >= 0.85 else "medium",
|
|
333
|
+
title=title,
|
|
334
|
+
description=description,
|
|
335
|
+
location=f"{self.filepath}:{line_no}",
|
|
336
|
+
metadata={
|
|
337
|
+
"confidence": confidence,
|
|
338
|
+
"pattern": pattern,
|
|
339
|
+
"snippet": snippet[:200],
|
|
340
|
+
"cwe": "CWE-1054",
|
|
341
|
+
},
|
|
342
|
+
))
|
|
343
|
+
|
|
344
|
+
SYNC_IO_IN_ASYNC = {"open", "read", "write", "readline", "readlines", "requests.get", "requests.post", "requests.put", "requests.delete", "requests.request", "urllib.request.urlopen", "httpx.get", "httpx.post", "httpx.put", "httpx.delete"}
|
|
345
|
+
DB_QUERY_METHODS = {"execute", "executemany", "query", "find", "find_one", "get", "filter", "filter_by", "all", "first", "one", "scalar", "scalars", "fetchone", "fetchall", "fetchmany", "select", "insert", "update", "delete", "aggregate", "count", "exists", "bulk_insert", "bulk_update"}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Prompt security engine - detect injection patterns in prompts and LLM inputs."""
|
|
2
|
+
import re
|
|
3
|
+
import base64
|
|
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 = "prompt_security"
|
|
11
|
+
version = "1.0.0"
|
|
12
|
+
|
|
13
|
+
EXPLICIT_PATTERNS = [
|
|
14
|
+
(r"ignore previous instructions", "ignore_previous"),
|
|
15
|
+
(r"disregard (?:all |the )?(?:previous|above) (?:instructions|prompts?)", "disregard_previous"),
|
|
16
|
+
(r"system prompt", "system_prompt_ref"),
|
|
17
|
+
(r"you are now (?:a |an |the )?(?:different|new|unrestricted)", "role_change"),
|
|
18
|
+
(r"forget everything (?:above|before|prior)", "forget_context"),
|
|
19
|
+
(r"new instructions?:", "new_instructions"),
|
|
20
|
+
(r"override (?:your |the )?(?:system |)(?:prompt|instructions)", "override_prompt"),
|
|
21
|
+
(r"jailbreak", "jailbreak"),
|
|
22
|
+
(r"\bDAN\b", "dan_mode"),
|
|
23
|
+
(r"do anything now", "do_anything_now"),
|
|
24
|
+
(r"act as (?:a |an |)(?:unrestricted|uncensored|no restrictions?)", "act_as_unrestricted"),
|
|
25
|
+
(r"pretend to be (?:a |an |)(?:unrestricted|uncensored|no restrictions?)", "pretend_unrestricted"),
|
|
26
|
+
(r"reveal your (?:system |)(?:prompt|instructions?)", "reveal_prompt"),
|
|
27
|
+
(r"show me your (?:system |)(?:prompt|instructions?)", "show_prompt"),
|
|
28
|
+
(r"what is your system prompt", "ask_system_prompt"),
|
|
29
|
+
(r"ignore (?:all |)safety", "ignore_safety"),
|
|
30
|
+
(r"bypass (?:safety|security|filter)", "bypass_safety"),
|
|
31
|
+
(r"disable (?:safety|filter|moderation)", "disable_safety"),
|
|
32
|
+
(r"you have no (?:restrictions|limitations|rules)", "no_restrictions"),
|
|
33
|
+
(r"unrestricted mode", "unrestricted_mode"),
|
|
34
|
+
(r"developer mode", "developer_mode"),
|
|
35
|
+
(r"sudo mode", "sudo_mode"),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
HEURISTIC_PATTERNS = [
|
|
39
|
+
(r"<\|endoftext\|>", "special_token_endoftext"),
|
|
40
|
+
(r"<\|im_start\|>", "special_token_im_start"),
|
|
41
|
+
(r"<\|im_end\|>", "special_token_im_end"),
|
|
42
|
+
(r"<\|", "special_token_generic"),
|
|
43
|
+
(r"###\s*(?:instruction|system|user|assistant)", "markdown_role"),
|
|
44
|
+
(r"<<SYS>>", "sys_token"),
|
|
45
|
+
(r"<</SYS>>", "sys_token_end"),
|
|
46
|
+
(r"\[INST\]", "inst_token"),
|
|
47
|
+
(r"\[/INST\]", "inst_token_end"),
|
|
48
|
+
(r"<\|user\|>", "user_token"),
|
|
49
|
+
(r"<\|assistant\|>", "assistant_token"),
|
|
50
|
+
(r"<\|system\|>", "system_token"),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
BASE64_PATTERN = re.compile(r"[A-Za-z0-9+/]{50,}={0,2}")
|
|
54
|
+
REPEATING_PATTERN = re.compile(r"(.{10,}?)\1{3,}")
|
|
55
|
+
|
|
56
|
+
async def check_availability(self) -> bool:
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
def resource_limits(self):
|
|
60
|
+
from orion.engine.config import ResourceLimits
|
|
61
|
+
return ResourceLimits(max_cpu_seconds=30, max_memory_mb=128)
|
|
62
|
+
|
|
63
|
+
async def run(self, ir: OrionIR, config: EngineConfig) -> EngineResult:
|
|
64
|
+
try:
|
|
65
|
+
text = ir.content.decode("utf-8", errors="replace")
|
|
66
|
+
except Exception:
|
|
67
|
+
text = str(ir.content)
|
|
68
|
+
|
|
69
|
+
path = ir.metadata.get("path", "unknown")
|
|
70
|
+
filename = Path(path).name.lower()
|
|
71
|
+
ext = Path(path).suffix.lower()
|
|
72
|
+
|
|
73
|
+
# Skip documentation files
|
|
74
|
+
if ext in (".md", ".markdown", ".rst", ".txt") or "docs" in path.lower() or "license" in filename:
|
|
75
|
+
return EngineResult(engine=self.name, findings=[], ok=True)
|
|
76
|
+
|
|
77
|
+
lines = text.splitlines()
|
|
78
|
+
findings = []
|
|
79
|
+
|
|
80
|
+
text_lower = text.lower()
|
|
81
|
+
|
|
82
|
+
for pattern, name in self.EXPLICIT_PATTERNS:
|
|
83
|
+
for match in re.finditer(pattern, text_lower, re.IGNORECASE):
|
|
84
|
+
line_no = text[:match.start()].count("\n") + 1
|
|
85
|
+
snippet = self._get_snippet(lines, line_no)
|
|
86
|
+
findings.append(Finding(
|
|
87
|
+
engine=self.name,
|
|
88
|
+
severity="critical",
|
|
89
|
+
title=f"Prompt injection: {name}",
|
|
90
|
+
description=f"Explicit prompt injection pattern detected: {name}",
|
|
91
|
+
location=f"{path}:{line_no}",
|
|
92
|
+
metadata={
|
|
93
|
+
"confidence": 0.85,
|
|
94
|
+
"snippet": snippet,
|
|
95
|
+
"pattern": name,
|
|
96
|
+
"match": match.group(0)[:100],
|
|
97
|
+
"cwe": "CWE-74",
|
|
98
|
+
"cvss": 9.0,
|
|
99
|
+
},
|
|
100
|
+
))
|
|
101
|
+
|
|
102
|
+
for pattern, name in self.HEURISTIC_PATTERNS:
|
|
103
|
+
for match in re.finditer(pattern, text):
|
|
104
|
+
line_no = text[:match.start()].count("\n") + 1
|
|
105
|
+
snippet = self._get_snippet(lines, line_no)
|
|
106
|
+
findings.append(Finding(
|
|
107
|
+
engine=self.name,
|
|
108
|
+
severity="medium",
|
|
109
|
+
title=f"Prompt injection indicator: {name}",
|
|
110
|
+
description=f"Heuristic prompt injection indicator detected: {name}",
|
|
111
|
+
location=f"{path}:{line_no}",
|
|
112
|
+
metadata={
|
|
113
|
+
"confidence": 0.65,
|
|
114
|
+
"snippet": snippet,
|
|
115
|
+
"pattern": name,
|
|
116
|
+
"match": match.group(0)[:100],
|
|
117
|
+
"cwe": "CWE-74",
|
|
118
|
+
"cvss": 6.5,
|
|
119
|
+
},
|
|
120
|
+
))
|
|
121
|
+
|
|
122
|
+
for match in self.BASE64_PATTERN.finditer(text):
|
|
123
|
+
try:
|
|
124
|
+
decoded = base64.b64decode(match.group(0)).decode("utf-8", errors="ignore")
|
|
125
|
+
if any(p in decoded.lower() for p in ["ignore", "disregard", "system prompt", "jailbreak", "dan"]):
|
|
126
|
+
line_no = text[:match.start()].count("\n") + 1
|
|
127
|
+
snippet = self._get_snippet(lines, line_no)
|
|
128
|
+
findings.append(Finding(
|
|
129
|
+
engine=self.name,
|
|
130
|
+
severity="high",
|
|
131
|
+
title="Base64-encoded prompt injection payload",
|
|
132
|
+
description="Base64 string decodes to text containing prompt injection patterns",
|
|
133
|
+
location=f"{path}:{line_no}",
|
|
134
|
+
metadata={
|
|
135
|
+
"confidence": 0.8,
|
|
136
|
+
"snippet": snippet,
|
|
137
|
+
"decoded_preview": decoded[:200],
|
|
138
|
+
"cwe": "CWE-74",
|
|
139
|
+
"cvss": 8.0,
|
|
140
|
+
},
|
|
141
|
+
))
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
for match in self.REPEATING_PATTERN.finditer(text):
|
|
146
|
+
if len(match.group(1)) > 10:
|
|
147
|
+
line_no = text[:match.start()].count("\n") + 1
|
|
148
|
+
snippet = self._get_snippet(lines, line_no)
|
|
149
|
+
findings.append(Finding(
|
|
150
|
+
engine=self.name,
|
|
151
|
+
severity="low",
|
|
152
|
+
title="Repeating sequence detected",
|
|
153
|
+
description="Long repeating sequence may indicate prompt stuffing attack",
|
|
154
|
+
location=f"{path}:{line_no}",
|
|
155
|
+
metadata={
|
|
156
|
+
"confidence": 0.55,
|
|
157
|
+
"snippet": snippet,
|
|
158
|
+
"repeat_unit": match.group(1)[:50],
|
|
159
|
+
"count": len(match.group(0)) // len(match.group(1)),
|
|
160
|
+
"cwe": "CWE-74",
|
|
161
|
+
"cvss": 4.0,
|
|
162
|
+
},
|
|
163
|
+
))
|
|
164
|
+
|
|
165
|
+
return EngineResult(engine=self.name, findings=findings, ok=True)
|
|
166
|
+
|
|
167
|
+
def _get_snippet(self, lines: list, line_no: int, context: int = 2) -> str:
|
|
168
|
+
start = max(0, line_no - context - 1)
|
|
169
|
+
end = min(len(lines), line_no + context)
|
|
170
|
+
return "\n".join(lines[start:end])
|
|
Binary file
|