@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,364 @@
|
|
|
1
|
+
"""Config validation engine for YAML, JSON, TOML, .env, .ini, .properties, Python config files."""
|
|
2
|
+
import ast
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import tomllib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from orion.engine.config import EngineConfig
|
|
8
|
+
from orion.engine.ir import OrionIR
|
|
9
|
+
from orion.engine.results import EngineResult, Finding
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Engine:
|
|
13
|
+
name = "config_engine"
|
|
14
|
+
version = "1.0.0"
|
|
15
|
+
|
|
16
|
+
REQUIRED_KEYS = {
|
|
17
|
+
"DATABASE_URL", "SECRET_KEY", "JWT_SECRET", "API_KEY",
|
|
18
|
+
"REDIS_URL", "CELERY_BROKER_URL", "POSTGRES_URL", "MYSQL_URL",
|
|
19
|
+
"MONGODB_URI", "RABBITMQ_URL", "KAFKA_BOOTSTRAP_SERVERS",
|
|
20
|
+
"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "GOOGLE_API_KEY",
|
|
21
|
+
"STRIPE_SECRET_KEY", "SENDGRID_API_KEY", "TWILIO_AUTH_TOKEN"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
TYPE_HINTS = {
|
|
25
|
+
"port": int, "PORT": int, "port_number": int,
|
|
26
|
+
"debug": bool, "DEBUG": bool, "verbose": bool,
|
|
27
|
+
"hosts": list, "HOSTS": list, "servers": list, "allowed_hosts": list,
|
|
28
|
+
"timeout": (int, float), "TIMEOUT": (int, float),
|
|
29
|
+
"retries": int, "RETRIES": int, "max_retries": int,
|
|
30
|
+
"workers": int, "WORKERS": int,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
PLACEHOLDER_PATTERNS = [
|
|
34
|
+
r"changeme", r"your_.*_here", r"TODO", r"FIXME", r"xxx+",
|
|
35
|
+
r"replace_me", r"insert_.*_here", r"<.*>", r"\{\{.*\}\}",
|
|
36
|
+
r"your-.*-here", r"example\.com", r"localhost", r"127\.0\.0\.1"
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
SENSITIVE_KEYS = {
|
|
40
|
+
"SECRET_KEY", "JWT_SECRET", "API_KEY", "PASSWORD", "TOKEN",
|
|
41
|
+
"PRIVATE_KEY", "ACCESS_TOKEN", "REFRESH_TOKEN", "CREDENTIALS"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def __init__(self):
|
|
45
|
+
self._placeholder_regex = [re.compile(p, re.IGNORECASE) for p in self.PLACEHOLDER_PATTERNS]
|
|
46
|
+
|
|
47
|
+
async def check_availability(self) -> bool:
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
def resource_limits(self):
|
|
51
|
+
from orion.engine.config import ResourceLimits
|
|
52
|
+
return ResourceLimits(max_cpu_seconds=60, max_memory_mb=256)
|
|
53
|
+
|
|
54
|
+
def _is_config_file(self, filename: str, ext: str) -> bool:
|
|
55
|
+
if ext in (".yaml", ".yml", ".json", ".toml", ".env", ".ini", ".cfg", ".conf", ".properties"):
|
|
56
|
+
return True
|
|
57
|
+
if ext == ".py":
|
|
58
|
+
name = filename.lower()
|
|
59
|
+
return any(k in name for k in ("config", "settings", "conf", "env", "secret", "options"))
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
async def run(self, ir: OrionIR, config: EngineConfig) -> EngineResult:
|
|
63
|
+
path = ir.metadata.get("path", "unknown")
|
|
64
|
+
filename = Path(path).name.lower()
|
|
65
|
+
ext = Path(path).suffix.lower()
|
|
66
|
+
|
|
67
|
+
if not self._is_config_file(filename, ext):
|
|
68
|
+
return EngineResult(engine=self.name, findings=[], ok=True)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
text = ir.content.decode("utf-8", errors="replace")
|
|
72
|
+
except Exception:
|
|
73
|
+
return EngineResult(engine=self.name, findings=[], ok=True)
|
|
74
|
+
|
|
75
|
+
findings = []
|
|
76
|
+
parsed = self._parse_config(text, filename, ext, path)
|
|
77
|
+
|
|
78
|
+
if parsed:
|
|
79
|
+
findings.extend(self._validate_config(parsed, path, filename, ext, config))
|
|
80
|
+
findings.extend(self._check_secrets_in_config(parsed, path, filename, ext))
|
|
81
|
+
findings.extend(self._check_duplicates(parsed, path, filename))
|
|
82
|
+
|
|
83
|
+
return EngineResult(engine=self.name, findings=findings, ok=True)
|
|
84
|
+
|
|
85
|
+
def _parse_config(self, text: str, filename: str, ext: str, path: str) -> dict | None:
|
|
86
|
+
try:
|
|
87
|
+
if ext in (".yaml", ".yml"):
|
|
88
|
+
import yaml
|
|
89
|
+
return yaml.safe_load(text) or {}
|
|
90
|
+
elif ext == ".json":
|
|
91
|
+
return json.loads(text) or {}
|
|
92
|
+
elif ext == ".toml":
|
|
93
|
+
return tomllib.loads(text) or {}
|
|
94
|
+
elif ext in (".env",) or filename.startswith(".env"):
|
|
95
|
+
return self._parse_env(text)
|
|
96
|
+
elif ext in (".ini", ".cfg", ".conf"):
|
|
97
|
+
return self._parse_ini(text)
|
|
98
|
+
elif ext == ".properties":
|
|
99
|
+
return self._parse_properties(text)
|
|
100
|
+
elif ext == ".py" and self._is_config_file(filename, ext):
|
|
101
|
+
return self._parse_python_config(text, path)
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
def _parse_env(self, text: str) -> dict:
|
|
107
|
+
result = {}
|
|
108
|
+
for line in text.splitlines():
|
|
109
|
+
line = line.strip()
|
|
110
|
+
if not line or line.startswith("#"):
|
|
111
|
+
continue
|
|
112
|
+
if "=" in line:
|
|
113
|
+
key, value = line.split("=", 1)
|
|
114
|
+
result[key.strip()] = value.strip().strip('"\'')
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
def _parse_ini(self, text: str) -> dict:
|
|
118
|
+
import configparser
|
|
119
|
+
parser = configparser.ConfigParser()
|
|
120
|
+
parser.read_string(text)
|
|
121
|
+
result = {}
|
|
122
|
+
for section in parser.sections():
|
|
123
|
+
for key, value in parser[section].items():
|
|
124
|
+
result[f"{section}.{key}"] = value
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
def _parse_properties(self, text: str) -> dict:
|
|
128
|
+
result = {}
|
|
129
|
+
for line in text.splitlines():
|
|
130
|
+
line = line.strip()
|
|
131
|
+
if not line or line.startswith("#") or line.startswith("!"):
|
|
132
|
+
continue
|
|
133
|
+
if "=" in line:
|
|
134
|
+
key, value = line.split("=", 1)
|
|
135
|
+
result[key.strip()] = value.strip()
|
|
136
|
+
elif ":" in line:
|
|
137
|
+
key, value = line.split(":", 1)
|
|
138
|
+
result[key.strip()] = value.strip()
|
|
139
|
+
return result
|
|
140
|
+
|
|
141
|
+
def _parse_python_config(self, text: str, path: str) -> dict:
|
|
142
|
+
result = {}
|
|
143
|
+
try:
|
|
144
|
+
tree = ast.parse(text, filename=path)
|
|
145
|
+
for node in ast.walk(tree):
|
|
146
|
+
if isinstance(node, ast.Assign):
|
|
147
|
+
for target in node.targets:
|
|
148
|
+
if isinstance(target, ast.Name) and (target.id.isupper() or "_" in target.id):
|
|
149
|
+
value = self._extract_value(node.value)
|
|
150
|
+
if value is not None:
|
|
151
|
+
result[target.id] = value
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
return result
|
|
155
|
+
|
|
156
|
+
def _extract_value(self, node: ast.AST):
|
|
157
|
+
if isinstance(node, ast.Constant):
|
|
158
|
+
return node.value
|
|
159
|
+
elif isinstance(node, ast.List):
|
|
160
|
+
return [self._extract_value(e) for e in node.elts]
|
|
161
|
+
elif isinstance(node, ast.Dict):
|
|
162
|
+
return {self._extract_value(k): self._extract_value(v) for k, v in zip(node.keys, node.values)}
|
|
163
|
+
elif isinstance(node, ast.Tuple):
|
|
164
|
+
return tuple(self._extract_value(e) for e in node.elts)
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
def _validate_config(self, config: dict, path: str, filename: str, ext: str, engine_config: EngineConfig) -> list[Finding]:
|
|
168
|
+
findings = []
|
|
169
|
+
flat = self._flatten_config(config)
|
|
170
|
+
|
|
171
|
+
# Check explicitly configured required keys
|
|
172
|
+
required_keys = engine_config.toggles.get("required_config_keys", [])
|
|
173
|
+
for key in required_keys:
|
|
174
|
+
if key not in flat and key.lower() not in {k.lower() for k in flat}:
|
|
175
|
+
findings.append(Finding(
|
|
176
|
+
engine=self.name,
|
|
177
|
+
severity="high",
|
|
178
|
+
title=f"Required config key missing: {key}",
|
|
179
|
+
description=f"Expected configuration key '{key}' not found",
|
|
180
|
+
location=path,
|
|
181
|
+
metadata={
|
|
182
|
+
"confidence": 0.85,
|
|
183
|
+
"key": key,
|
|
184
|
+
"expected_type": "str",
|
|
185
|
+
"issue": "missing",
|
|
186
|
+
"cwe": "CWE-256",
|
|
187
|
+
},
|
|
188
|
+
))
|
|
189
|
+
|
|
190
|
+
for key, value in flat.items():
|
|
191
|
+
if isinstance(value, str):
|
|
192
|
+
for pattern in self._placeholder_regex:
|
|
193
|
+
if pattern.search(value):
|
|
194
|
+
findings.append(Finding(
|
|
195
|
+
engine=self.name,
|
|
196
|
+
severity="high",
|
|
197
|
+
title=f"Placeholder value in config: {key}",
|
|
198
|
+
description=f"Config value appears to be a placeholder: {value[:50]}",
|
|
199
|
+
location=path,
|
|
200
|
+
metadata={
|
|
201
|
+
"confidence": 0.9,
|
|
202
|
+
"key": key,
|
|
203
|
+
"issue": "placeholder_value",
|
|
204
|
+
"value": value[:100],
|
|
205
|
+
"cwe": "CWE-256",
|
|
206
|
+
},
|
|
207
|
+
))
|
|
208
|
+
break
|
|
209
|
+
|
|
210
|
+
if self._looks_like_secret(key, value) and ext not in (".env",):
|
|
211
|
+
findings.append(Finding(
|
|
212
|
+
engine=self.name,
|
|
213
|
+
severity="critical",
|
|
214
|
+
title=f"Secret in non-.env config file: {key}",
|
|
215
|
+
description=f"Sensitive value found in {ext} file (should be in .env)",
|
|
216
|
+
location=path,
|
|
217
|
+
metadata={
|
|
218
|
+
"confidence": 0.95,
|
|
219
|
+
"key": key,
|
|
220
|
+
"issue": "secret_in_config",
|
|
221
|
+
"cwe": "CWE-798",
|
|
222
|
+
},
|
|
223
|
+
))
|
|
224
|
+
|
|
225
|
+
expected_type = self.TYPE_HINTS.get(key)
|
|
226
|
+
if expected_type and not self._type_matches(value, expected_type):
|
|
227
|
+
findings.append(Finding(
|
|
228
|
+
engine=self.name,
|
|
229
|
+
severity="medium",
|
|
230
|
+
title=f"Config type mismatch: {key}",
|
|
231
|
+
description=f"Expected {expected_type.__name__ if hasattr(expected_type, '__name__') else expected_type}, got {type(value).__name__}",
|
|
232
|
+
location=path,
|
|
233
|
+
metadata={
|
|
234
|
+
"confidence": 0.8,
|
|
235
|
+
"key": key,
|
|
236
|
+
"expected_type": str(expected_type),
|
|
237
|
+
"actual_type": type(value).__name__,
|
|
238
|
+
"issue": "type_mismatch",
|
|
239
|
+
"cwe": "CWE-704",
|
|
240
|
+
},
|
|
241
|
+
))
|
|
242
|
+
|
|
243
|
+
if key.lower() in ("url", "endpoint", "uri") and isinstance(value, str):
|
|
244
|
+
if not self._is_valid_url(value):
|
|
245
|
+
findings.append(Finding(
|
|
246
|
+
engine=self.name,
|
|
247
|
+
severity="medium",
|
|
248
|
+
title=f"Invalid URL format: {key}",
|
|
249
|
+
description=f"Config value does not appear to be a valid URL: {value[:50]}",
|
|
250
|
+
location=path,
|
|
251
|
+
metadata={
|
|
252
|
+
"confidence": 0.75,
|
|
253
|
+
"key": key,
|
|
254
|
+
"issue": "invalid_url",
|
|
255
|
+
"value": value[:100],
|
|
256
|
+
},
|
|
257
|
+
))
|
|
258
|
+
|
|
259
|
+
if key.lower() in ("email", "mail") and isinstance(value, str):
|
|
260
|
+
if not self._is_valid_email(value):
|
|
261
|
+
findings.append(Finding(
|
|
262
|
+
engine=self.name,
|
|
263
|
+
severity="medium",
|
|
264
|
+
title=f"Invalid email format: {key}",
|
|
265
|
+
description=f"Config value does not appear to be a valid email: {value[:50]}",
|
|
266
|
+
location=path,
|
|
267
|
+
metadata={
|
|
268
|
+
"confidence": 0.75,
|
|
269
|
+
"key": key,
|
|
270
|
+
"issue": "invalid_email",
|
|
271
|
+
"value": value[:100],
|
|
272
|
+
},
|
|
273
|
+
))
|
|
274
|
+
|
|
275
|
+
return findings
|
|
276
|
+
|
|
277
|
+
def _check_secrets_in_config(self, config: dict, path: str, filename: str, ext: str) -> list[Finding]:
|
|
278
|
+
findings = []
|
|
279
|
+
flat = self._flatten_config(config)
|
|
280
|
+
|
|
281
|
+
for key, value in flat.items():
|
|
282
|
+
if isinstance(value, str) and self._looks_like_secret(key, value):
|
|
283
|
+
if ext not in (".env",) or "example" in filename or "template" in filename or "sample" in filename:
|
|
284
|
+
findings.append(Finding(
|
|
285
|
+
engine=self.name,
|
|
286
|
+
severity="critical" if ext not in (".env",) else "high",
|
|
287
|
+
title=f"Potential secret in config: {key}",
|
|
288
|
+
description=f"Value for '{key}' matches secret patterns",
|
|
289
|
+
location=path,
|
|
290
|
+
metadata={
|
|
291
|
+
"confidence": 0.85,
|
|
292
|
+
"key": key,
|
|
293
|
+
"issue": "potential_secret",
|
|
294
|
+
"cwe": "CWE-798",
|
|
295
|
+
},
|
|
296
|
+
))
|
|
297
|
+
return findings
|
|
298
|
+
|
|
299
|
+
def _check_duplicates(self, config: dict, path: str, filename: str) -> list[Finding]:
|
|
300
|
+
findings = []
|
|
301
|
+
flat = self._flatten_config(config)
|
|
302
|
+
seen = {}
|
|
303
|
+
for key in flat:
|
|
304
|
+
lower_key = key.lower()
|
|
305
|
+
if lower_key in seen:
|
|
306
|
+
findings.append(Finding(
|
|
307
|
+
engine=self.name,
|
|
308
|
+
severity="medium",
|
|
309
|
+
title=f"Duplicate config key (case-insensitive): {key}",
|
|
310
|
+
description=f"Key '{key}' duplicates '{seen[lower_key]}' (case-insensitive)",
|
|
311
|
+
location=path,
|
|
312
|
+
metadata={
|
|
313
|
+
"confidence": 0.9,
|
|
314
|
+
"key": key,
|
|
315
|
+
"duplicate_of": seen[lower_key],
|
|
316
|
+
"issue": "duplicate_key",
|
|
317
|
+
},
|
|
318
|
+
))
|
|
319
|
+
else:
|
|
320
|
+
seen[lower_key] = key
|
|
321
|
+
return findings
|
|
322
|
+
|
|
323
|
+
def _flatten_config(self, config: dict, prefix: str = "") -> dict:
|
|
324
|
+
result = {}
|
|
325
|
+
for key, value in config.items():
|
|
326
|
+
full_key = f"{prefix}.{key}" if prefix else key
|
|
327
|
+
if isinstance(value, dict):
|
|
328
|
+
result.update(self._flatten_config(value, full_key))
|
|
329
|
+
else:
|
|
330
|
+
result[full_key] = value
|
|
331
|
+
return result
|
|
332
|
+
|
|
333
|
+
def _type_matches(self, value, expected_type) -> bool:
|
|
334
|
+
if isinstance(expected_type, tuple):
|
|
335
|
+
return isinstance(value, expected_type)
|
|
336
|
+
if expected_type == int:
|
|
337
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
338
|
+
if expected_type == bool:
|
|
339
|
+
return isinstance(value, bool)
|
|
340
|
+
if expected_type == list:
|
|
341
|
+
return isinstance(value, list)
|
|
342
|
+
if expected_type == float:
|
|
343
|
+
return isinstance(value, (int, float))
|
|
344
|
+
return isinstance(value, expected_type)
|
|
345
|
+
|
|
346
|
+
def _looks_like_secret(self, key: str, value: str) -> bool:
|
|
347
|
+
key_upper = key.upper()
|
|
348
|
+
if any(sk in key_upper for sk in self.SENSITIVE_KEYS):
|
|
349
|
+
if len(value) >= 16 and not value.startswith("${") and not value.startswith("{{"):
|
|
350
|
+
return True
|
|
351
|
+
return False
|
|
352
|
+
|
|
353
|
+
def _is_valid_url(self, url: str) -> bool:
|
|
354
|
+
url_pattern = re.compile(
|
|
355
|
+
r'^(https?|ftp|postgres|mysql|redis|mongodb|amqp|kafka)://'
|
|
356
|
+
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|'
|
|
357
|
+
r'localhost|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'
|
|
358
|
+
r'(?::\d+)?'
|
|
359
|
+
r'(?:/?|[/?]\S+)$', re.IGNORECASE)
|
|
360
|
+
return bool(url_pattern.match(url))
|
|
361
|
+
|
|
362
|
+
def _is_valid_email(self, email: str) -> bool:
|
|
363
|
+
email_pattern = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
|
|
364
|
+
return bool(email_pattern.match(email))
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Orion config self-validation engine - validates EngineConfig passed to run."""
|
|
2
|
+
import re
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, List, Any, Optional
|
|
6
|
+
|
|
7
|
+
from orion.engine.config import EngineConfig, ResourceLimits, NetworkPolicy
|
|
8
|
+
from orion.engine.ir import OrionIR
|
|
9
|
+
from orion.engine.results import EngineResult, Finding
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfigValidationEngine:
|
|
13
|
+
name = "config_validation"
|
|
14
|
+
version = "1.0.0"
|
|
15
|
+
|
|
16
|
+
ALLOWED_OUTPUT_FORMATS = {"json", "sarif", "text", "html", "markdown", "csv"}
|
|
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=5, max_memory_mb=32)
|
|
24
|
+
|
|
25
|
+
def _validate_project_path(self, config: EngineConfig) -> List[Finding]:
|
|
26
|
+
findings = []
|
|
27
|
+
project_path = config.toggles.get("project_path")
|
|
28
|
+
if not project_path:
|
|
29
|
+
findings.append(Finding(
|
|
30
|
+
engine=self.name,
|
|
31
|
+
severity="error",
|
|
32
|
+
title="Missing project_path in config",
|
|
33
|
+
description="EngineConfig.toggles must contain 'project_path'",
|
|
34
|
+
location="config.toggles.project_path",
|
|
35
|
+
metadata={"rule": "missing_project_path"},
|
|
36
|
+
))
|
|
37
|
+
else:
|
|
38
|
+
path = Path(project_path)
|
|
39
|
+
if not path.exists():
|
|
40
|
+
findings.append(Finding(
|
|
41
|
+
engine=self.name,
|
|
42
|
+
severity="error",
|
|
43
|
+
title=f"Project path does not exist: {project_path}",
|
|
44
|
+
description="The specified project_path does not exist on filesystem",
|
|
45
|
+
location="config.toggles.project_path",
|
|
46
|
+
metadata={"rule": "project_path_not_found", "path": project_path},
|
|
47
|
+
))
|
|
48
|
+
elif not path.is_dir():
|
|
49
|
+
findings.append(Finding(
|
|
50
|
+
engine=self.name,
|
|
51
|
+
severity="error",
|
|
52
|
+
title=f"Project path is not a directory: {project_path}",
|
|
53
|
+
description="project_path must be a directory",
|
|
54
|
+
location="config.toggles.project_path",
|
|
55
|
+
metadata={"rule": "project_path_not_dir", "path": project_path},
|
|
56
|
+
))
|
|
57
|
+
return findings
|
|
58
|
+
|
|
59
|
+
def _validate_exclude_patterns(self, config: EngineConfig) -> List[Finding]:
|
|
60
|
+
findings = []
|
|
61
|
+
exclude = config.toggles.get("exclude", [])
|
|
62
|
+
if not isinstance(exclude, list):
|
|
63
|
+
findings.append(Finding(
|
|
64
|
+
engine=self.name,
|
|
65
|
+
severity="error",
|
|
66
|
+
title="Invalid exclude configuration",
|
|
67
|
+
description="config.toggles.exclude must be a list of glob patterns",
|
|
68
|
+
location="config.toggles.exclude",
|
|
69
|
+
metadata={"rule": "exclude_not_list"},
|
|
70
|
+
))
|
|
71
|
+
return findings
|
|
72
|
+
|
|
73
|
+
for i, pattern in enumerate(exclude):
|
|
74
|
+
if not isinstance(pattern, str):
|
|
75
|
+
findings.append(Finding(
|
|
76
|
+
engine=self.name,
|
|
77
|
+
severity="error",
|
|
78
|
+
title=f"Exclude pattern {i} is not a string",
|
|
79
|
+
description="All exclude patterns must be strings",
|
|
80
|
+
location=f"config.toggles.exclude[{i}]",
|
|
81
|
+
metadata={"rule": "exclude_pattern_not_string", "index": i},
|
|
82
|
+
))
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
# Test if pattern is valid glob
|
|
86
|
+
try:
|
|
87
|
+
# Try to compile as a test
|
|
88
|
+
Path("test").match(pattern)
|
|
89
|
+
except Exception as e:
|
|
90
|
+
findings.append(Finding(
|
|
91
|
+
engine=self.name,
|
|
92
|
+
severity="warning",
|
|
93
|
+
title=f"Exclude pattern {i} may be invalid: {pattern}",
|
|
94
|
+
description=f"Glob pattern validation failed: {e}",
|
|
95
|
+
location=f"config.toggles.exclude[{i}]",
|
|
96
|
+
metadata={"rule": "exclude_pattern_invalid", "index": i, "pattern": pattern, "error": str(e)},
|
|
97
|
+
))
|
|
98
|
+
return findings
|
|
99
|
+
|
|
100
|
+
def _validate_resource_limits(self, config: EngineConfig) -> List[Finding]:
|
|
101
|
+
findings = []
|
|
102
|
+
rl = config.resource_limits or {}
|
|
103
|
+
|
|
104
|
+
# max_memory_mb
|
|
105
|
+
memory = rl.get("max_memory_mb")
|
|
106
|
+
if memory is not None:
|
|
107
|
+
if not isinstance(memory, int) or memory <= 0:
|
|
108
|
+
findings.append(Finding(
|
|
109
|
+
engine=self.name,
|
|
110
|
+
severity="error",
|
|
111
|
+
title="Invalid max_memory_mb",
|
|
112
|
+
description="resource_limits.max_memory_mb must be a positive integer",
|
|
113
|
+
location="config.resource_limits.max_memory_mb",
|
|
114
|
+
metadata={"rule": "invalid_max_memory", "value": memory},
|
|
115
|
+
))
|
|
116
|
+
|
|
117
|
+
# max_cpu_seconds
|
|
118
|
+
cpu = rl.get("max_cpu_seconds")
|
|
119
|
+
if cpu is not None:
|
|
120
|
+
if not isinstance(cpu, int) or cpu <= 0:
|
|
121
|
+
findings.append(Finding(
|
|
122
|
+
engine=self.name,
|
|
123
|
+
severity="error",
|
|
124
|
+
title="Invalid max_cpu_seconds",
|
|
125
|
+
description="resource_limits.max_cpu_seconds must be a positive integer",
|
|
126
|
+
location="config.resource_limits.max_cpu_seconds",
|
|
127
|
+
metadata={"rule": "invalid_max_cpu", "value": cpu},
|
|
128
|
+
))
|
|
129
|
+
|
|
130
|
+
# max_file_size_mb
|
|
131
|
+
file_size = rl.get("max_file_size_mb")
|
|
132
|
+
if file_size is not None:
|
|
133
|
+
if not isinstance(file_size, int) or file_size <= 0:
|
|
134
|
+
findings.append(Finding(
|
|
135
|
+
engine=self.name,
|
|
136
|
+
severity="error",
|
|
137
|
+
title="Invalid max_file_size_mb",
|
|
138
|
+
description="resource_limits.max_file_size_mb must be a positive integer",
|
|
139
|
+
location="config.resource_limits.max_file_size_mb",
|
|
140
|
+
metadata={"rule": "invalid_max_file_size", "value": file_size},
|
|
141
|
+
))
|
|
142
|
+
|
|
143
|
+
return findings
|
|
144
|
+
|
|
145
|
+
def _validate_network_policy(self, config: EngineConfig) -> List[Finding]:
|
|
146
|
+
findings = []
|
|
147
|
+
np = config.network_policy or {}
|
|
148
|
+
|
|
149
|
+
allow_outbound = np.get("allow_outbound")
|
|
150
|
+
if allow_outbound is not None and not isinstance(allow_outbound, bool):
|
|
151
|
+
findings.append(Finding(
|
|
152
|
+
engine=self.name,
|
|
153
|
+
severity="error",
|
|
154
|
+
title="Invalid network_policy.allow_outbound",
|
|
155
|
+
description="network_policy.allow_outbound must be a boolean",
|
|
156
|
+
location="config.network_policy.allow_outbound",
|
|
157
|
+
metadata={"rule": "invalid_allow_outbound", "value": allow_outbound},
|
|
158
|
+
))
|
|
159
|
+
|
|
160
|
+
allowed_hosts = np.get("allowed_hosts", [])
|
|
161
|
+
if not isinstance(allowed_hosts, list):
|
|
162
|
+
findings.append(Finding(
|
|
163
|
+
engine=self.name,
|
|
164
|
+
severity="error",
|
|
165
|
+
title="Invalid network_policy.allowed_hosts",
|
|
166
|
+
description="network_policy.allowed_hosts must be a list",
|
|
167
|
+
location="config.network_policy.allowed_hosts",
|
|
168
|
+
metadata={"rule": "invalid_allowed_hosts_type"},
|
|
169
|
+
))
|
|
170
|
+
else:
|
|
171
|
+
for i, host in enumerate(allowed_hosts):
|
|
172
|
+
if not isinstance(host, str):
|
|
173
|
+
findings.append(Finding(
|
|
174
|
+
engine=self.name,
|
|
175
|
+
severity="error",
|
|
176
|
+
title=f"allowed_hosts[{i}] is not a string",
|
|
177
|
+
description="All allowed_hosts entries must be strings",
|
|
178
|
+
location=f"config.network_policy.allowed_hosts[{i}]",
|
|
179
|
+
metadata={"rule": "allowed_host_not_string", "index": i},
|
|
180
|
+
))
|
|
181
|
+
|
|
182
|
+
# Check if allow_outbound=False but allowed_hosts is not empty (contradiction)
|
|
183
|
+
if allow_outbound is False and allowed_hosts:
|
|
184
|
+
findings.append(Finding(
|
|
185
|
+
engine=self.name,
|
|
186
|
+
severity="warning",
|
|
187
|
+
title="network_policy.allow_outbound is false but allowed_hosts is not empty",
|
|
188
|
+
description="allowed_hosts will be ignored when allow_outbound is false",
|
|
189
|
+
location="config.network_policy",
|
|
190
|
+
metadata={"rule": "network_policy_contradiction"},
|
|
191
|
+
))
|
|
192
|
+
|
|
193
|
+
return findings
|
|
194
|
+
|
|
195
|
+
def _validate_parallelism(self, config: EngineConfig) -> List[Finding]:
|
|
196
|
+
findings = []
|
|
197
|
+
parallelism = config.toggles.get("parallelism")
|
|
198
|
+
if parallelism is not None:
|
|
199
|
+
if not isinstance(parallelism, int) or parallelism <= 0:
|
|
200
|
+
findings.append(Finding(
|
|
201
|
+
engine=self.name,
|
|
202
|
+
severity="error",
|
|
203
|
+
title="Invalid parallelism",
|
|
204
|
+
description="parallelism must be a positive integer",
|
|
205
|
+
location="config.toggles.parallelism",
|
|
206
|
+
metadata={"rule": "invalid_parallelism", "value": parallelism},
|
|
207
|
+
))
|
|
208
|
+
else:
|
|
209
|
+
# Check against CPU count
|
|
210
|
+
try:
|
|
211
|
+
cpu_count = os.cpu_count() or 1
|
|
212
|
+
if parallelism > cpu_count * 2:
|
|
213
|
+
findings.append(Finding(
|
|
214
|
+
engine=self.name,
|
|
215
|
+
severity="warning",
|
|
216
|
+
title=f"parallelism ({parallelism}) exceeds 2x CPU count ({cpu_count})",
|
|
217
|
+
description="High parallelism may cause resource contention",
|
|
218
|
+
location="config.toggles.parallelism",
|
|
219
|
+
metadata={"rule": "parallelism_high", "parallelism": parallelism, "cpu_count": cpu_count},
|
|
220
|
+
))
|
|
221
|
+
except Exception:
|
|
222
|
+
pass
|
|
223
|
+
return findings
|
|
224
|
+
|
|
225
|
+
def _validate_timeout(self, config: EngineConfig) -> List[Finding]:
|
|
226
|
+
findings = []
|
|
227
|
+
timeout = config.toggles.get("timeout")
|
|
228
|
+
if timeout is not None:
|
|
229
|
+
if not isinstance(timeout, (int, float)) or timeout <= 0:
|
|
230
|
+
findings.append(Finding(
|
|
231
|
+
engine=self.name,
|
|
232
|
+
severity="error",
|
|
233
|
+
title="Invalid timeout",
|
|
234
|
+
description="timeout must be a positive number",
|
|
235
|
+
location="config.toggles.timeout",
|
|
236
|
+
metadata={"rule": "invalid_timeout", "value": timeout},
|
|
237
|
+
))
|
|
238
|
+
return findings
|
|
239
|
+
|
|
240
|
+
def _validate_output_format(self, config: EngineConfig) -> List[Finding]:
|
|
241
|
+
findings = []
|
|
242
|
+
output_format = config.toggles.get("output_format")
|
|
243
|
+
if output_format is not None:
|
|
244
|
+
if not isinstance(output_format, str):
|
|
245
|
+
findings.append(Finding(
|
|
246
|
+
engine=self.name,
|
|
247
|
+
severity="error",
|
|
248
|
+
title="Invalid output_format",
|
|
249
|
+
description="output_format must be a string",
|
|
250
|
+
location="config.toggles.output_format",
|
|
251
|
+
metadata={"rule": "invalid_output_format_type"},
|
|
252
|
+
))
|
|
253
|
+
elif output_format not in self.ALLOWED_OUTPUT_FORMATS:
|
|
254
|
+
findings.append(Finding(
|
|
255
|
+
engine=self.name,
|
|
256
|
+
severity="warning",
|
|
257
|
+
title=f"Unknown output_format: {output_format}",
|
|
258
|
+
description=f"Allowed formats: {', '.join(sorted(self.ALLOWED_OUTPUT_FORMATS))}",
|
|
259
|
+
location="config.toggles.output_format",
|
|
260
|
+
metadata={"rule": "unknown_output_format", "value": output_format, "allowed": list(self.ALLOWED_OUTPUT_FORMATS)},
|
|
261
|
+
))
|
|
262
|
+
return findings
|
|
263
|
+
|
|
264
|
+
def _validate_engine_toggles(self, config: EngineConfig) -> List[Finding]:
|
|
265
|
+
findings = []
|
|
266
|
+
# Check for unknown engine toggles
|
|
267
|
+
known_toggles = {
|
|
268
|
+
"project_path", "exclude", "parallelism", "timeout", "output_format",
|
|
269
|
+
"fail_on_severity", "include_tests", "include_generated",
|
|
270
|
+
}
|
|
271
|
+
for key in config.toggles:
|
|
272
|
+
if key not in known_toggles and not key.startswith("_"):
|
|
273
|
+
findings.append(Finding(
|
|
274
|
+
engine=self.name,
|
|
275
|
+
severity="warning",
|
|
276
|
+
title=f"Unknown config toggle: {key}",
|
|
277
|
+
description=f"Toggle '{key}' is not a recognized Orion configuration option",
|
|
278
|
+
location=f"config.toggles.{key}",
|
|
279
|
+
metadata={"rule": "unknown_toggle", "toggle": key},
|
|
280
|
+
))
|
|
281
|
+
return findings
|
|
282
|
+
|
|
283
|
+
async def run(self, ir: OrionIR, config: EngineConfig) -> EngineResult:
|
|
284
|
+
all_findings = []
|
|
285
|
+
|
|
286
|
+
# Run all validations
|
|
287
|
+
all_findings.extend(self._validate_project_path(config))
|
|
288
|
+
all_findings.extend(self._validate_exclude_patterns(config))
|
|
289
|
+
all_findings.extend(self._validate_resource_limits(config))
|
|
290
|
+
all_findings.extend(self._validate_network_policy(config))
|
|
291
|
+
all_findings.extend(self._validate_parallelism(config))
|
|
292
|
+
all_findings.extend(self._validate_timeout(config))
|
|
293
|
+
all_findings.extend(self._validate_output_format(config))
|
|
294
|
+
all_findings.extend(self._validate_engine_toggles(config))
|
|
295
|
+
|
|
296
|
+
# Count errors and warnings
|
|
297
|
+
errors = sum(1 for f in all_findings if f.severity == "error")
|
|
298
|
+
warnings = sum(1 for f in all_findings if f.severity == "warning")
|
|
299
|
+
|
|
300
|
+
return EngineResult(
|
|
301
|
+
engine=self.name,
|
|
302
|
+
findings=all_findings,
|
|
303
|
+
ok=errors == 0,
|
|
304
|
+
metrics={
|
|
305
|
+
"validations_run": 7,
|
|
306
|
+
"errors": errors,
|
|
307
|
+
"warnings": warnings,
|
|
308
|
+
"total_findings": len(all_findings),
|
|
309
|
+
},
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
Engine = ConfigValidationEngine
|
|
Binary file
|