@orionscanner/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +248 -0
  3. package/assets/logo.png +0 -0
  4. package/bin/orion.js +48 -0
  5. package/docs/API_REFERENCE.md +211 -0
  6. package/docs/ARCHITECTURE.md +183 -0
  7. package/docs/CHANGELOG.md +112 -0
  8. package/docs/CONTRIBUTING.md +157 -0
  9. package/docs/ENGINE_CATALOG.md +87 -0
  10. package/docs/GETTING_STARTED.md +226 -0
  11. package/package.json +48 -0
  12. package/pyproject.toml +16 -0
  13. package/src/orion/__init__.py +0 -0
  14. package/src/orion/__pycache__/__init__.cpython-313.pyc +0 -0
  15. package/src/orion/engine/__init__.py +17 -0
  16. package/src/orion/engine/__pycache__/__init__.cpython-313.pyc +0 -0
  17. package/src/orion/engine/__pycache__/config.cpython-313.pyc +0 -0
  18. package/src/orion/engine/__pycache__/core.cpython-313.pyc +0 -0
  19. package/src/orion/engine/__pycache__/ir.cpython-313.pyc +0 -0
  20. package/src/orion/engine/__pycache__/languages.cpython-313.pyc +0 -0
  21. package/src/orion/engine/__pycache__/registry.cpython-313.pyc +0 -0
  22. package/src/orion/engine/__pycache__/results.cpython-313.pyc +0 -0
  23. package/src/orion/engine/__pycache__/runner.cpython-313.pyc +0 -0
  24. package/src/orion/engine/api_security/__pycache__/engine.cpython-313.pyc +0 -0
  25. package/src/orion/engine/api_security/engine.py +125 -0
  26. package/src/orion/engine/architecture/__pycache__/engine.cpython-313.pyc +0 -0
  27. package/src/orion/engine/architecture/engine.py +463 -0
  28. package/src/orion/engine/authorization/__pycache__/engine.cpython-313.pyc +0 -0
  29. package/src/orion/engine/authorization/engine.py +337 -0
  30. package/src/orion/engine/callgraph/__pycache__/engine.cpython-313.pyc +0 -0
  31. package/src/orion/engine/callgraph/engine.py +236 -0
  32. package/src/orion/engine/cfg/__pycache__/engine.cpython-313.pyc +0 -0
  33. package/src/orion/engine/cfg/engine.py +509 -0
  34. package/src/orion/engine/chaos/__pycache__/engine.cpython-313.pyc +0 -0
  35. package/src/orion/engine/chaos/engine.py +268 -0
  36. package/src/orion/engine/chaos_config/__pycache__/engine.cpython-313.pyc +0 -0
  37. package/src/orion/engine/chaos_config/engine.py +322 -0
  38. package/src/orion/engine/complexity/__pycache__/engine.cpython-313.pyc +0 -0
  39. package/src/orion/engine/complexity/engine.py +144 -0
  40. package/src/orion/engine/compliance/__pycache__/engine.cpython-313.pyc +0 -0
  41. package/src/orion/engine/compliance/engine.py +325 -0
  42. package/src/orion/engine/config.py +41 -0
  43. package/src/orion/engine/config_engine/__pycache__/engine.cpython-313.pyc +0 -0
  44. package/src/orion/engine/config_engine/engine.py +364 -0
  45. package/src/orion/engine/config_validation/__pycache__/engine.cpython-313.pyc +0 -0
  46. package/src/orion/engine/config_validation/engine.py +313 -0
  47. package/src/orion/engine/container/__pycache__/engine.cpython-313.pyc +0 -0
  48. package/src/orion/engine/container/engine.py +546 -0
  49. package/src/orion/engine/contract_probe/__pycache__/engine.cpython-313.pyc +0 -0
  50. package/src/orion/engine/contract_probe/engine.py +316 -0
  51. package/src/orion/engine/core.py +75 -0
  52. package/src/orion/engine/crypto_guard/__pycache__/engine.cpython-313.pyc +0 -0
  53. package/src/orion/engine/crypto_guard/engine.py +134 -0
  54. package/src/orion/engine/data_residency/__pycache__/engine.cpython-313.pyc +0 -0
  55. package/src/orion/engine/data_residency/engine.py +238 -0
  56. package/src/orion/engine/dataflow/__pycache__/engine.cpython-313.pyc +0 -0
  57. package/src/orion/engine/dataflow/engine.py +398 -0
  58. package/src/orion/engine/dead_import/__pycache__/engine.cpython-313.pyc +0 -0
  59. package/src/orion/engine/dead_import/engine.py +293 -0
  60. package/src/orion/engine/deps/__pycache__/engine.cpython-313.pyc +0 -0
  61. package/src/orion/engine/deps/engine.py +244 -0
  62. package/src/orion/engine/dispatcher.py +350 -0
  63. package/src/orion/engine/docs/AUDIT_PROMPT.md +1248 -0
  64. package/src/orion/engine/duplication/__pycache__/engine.cpython-313.pyc +0 -0
  65. package/src/orion/engine/duplication/engine.py +120 -0
  66. package/src/orion/engine/error_handling/__pycache__/engine.cpython-313.pyc +0 -0
  67. package/src/orion/engine/error_handling/engine.py +256 -0
  68. package/src/orion/engine/fp_learn/__pycache__/engine.cpython-313.pyc +0 -0
  69. package/src/orion/engine/fp_learn/engine.py +248 -0
  70. package/src/orion/engine/hunter/__pycache__/engine.cpython-313.pyc +0 -0
  71. package/src/orion/engine/hunter/engine.py +324 -0
  72. package/src/orion/engine/iac_security/__pycache__/engine.cpython-313.pyc +0 -0
  73. package/src/orion/engine/iac_security/engine.py +218 -0
  74. package/src/orion/engine/ir.py +22 -0
  75. package/src/orion/engine/languages.py +454 -0
  76. package/src/orion/engine/license/__pycache__/engine.cpython-313.pyc +0 -0
  77. package/src/orion/engine/license/engine.py +172 -0
  78. package/src/orion/engine/lint/__pycache__/engine.cpython-313.pyc +0 -0
  79. package/src/orion/engine/lint/engine.py +558 -0
  80. package/src/orion/engine/llm_security/__pycache__/engine.cpython-313.pyc +0 -0
  81. package/src/orion/engine/llm_security/engine.py +125 -0
  82. package/src/orion/engine/logging_c/__pycache__/engine.cpython-313.pyc +0 -0
  83. package/src/orion/engine/logging_c/engine.py +164 -0
  84. package/src/orion/engine/noise/__pycache__/engine.cpython-313.pyc +0 -0
  85. package/src/orion/engine/noise/engine.py +140 -0
  86. package/src/orion/engine/orphan/__pycache__/engine.cpython-313.pyc +0 -0
  87. package/src/orion/engine/orphan/engine.py +244 -0
  88. package/src/orion/engine/perf_guard/__pycache__/engine.cpython-313.pyc +0 -0
  89. package/src/orion/engine/perf_guard/engine.py +345 -0
  90. package/src/orion/engine/prompt_security/__pycache__/engine.cpython-313.pyc +0 -0
  91. package/src/orion/engine/prompt_security/detectors/__init__.py +4 -0
  92. package/src/orion/engine/prompt_security/engine.py +170 -0
  93. package/src/orion/engine/provenance/__pycache__/engine.cpython-313.pyc +0 -0
  94. package/src/orion/engine/provenance/engine.py +242 -0
  95. package/src/orion/engine/registry.py +55 -0
  96. package/src/orion/engine/regression/__pycache__/engine.cpython-313.pyc +0 -0
  97. package/src/orion/engine/regression/engine.py +213 -0
  98. package/src/orion/engine/results.py +21 -0
  99. package/src/orion/engine/runner.py +1310 -0
  100. package/src/orion/engine/sandbox_escape/__pycache__/engine.cpython-313.pyc +0 -0
  101. package/src/orion/engine/sandbox_escape/engine.py +284 -0
  102. package/src/orion/engine/sast/__pycache__/engine.cpython-313.pyc +0 -0
  103. package/src/orion/engine/sast/engine.py +217 -0
  104. package/src/orion/engine/sast/patterns/__init__.py +513 -0
  105. package/src/orion/engine/sast/patterns/__pycache__/__init__.cpython-313.pyc +0 -0
  106. package/src/orion/engine/secrets/__pycache__/engine.cpython-313.pyc +0 -0
  107. package/src/orion/engine/secrets/engine.py +77 -0
  108. package/src/orion/engine/secrets/patterns/__init__.py +74 -0
  109. package/src/orion/engine/secrets/patterns/__pycache__/__init__.cpython-313.pyc +0 -0
  110. package/src/orion/engine/sensitive_data/__pycache__/engine.cpython-313.pyc +0 -0
  111. package/src/orion/engine/sensitive_data/engine.py +143 -0
  112. package/src/orion/engine/state/__pycache__/engine.cpython-313.pyc +0 -0
  113. package/src/orion/engine/state/engine.py +218 -0
  114. package/src/orion/engine/supply_chain_sbom/__pycache__/engine.cpython-313.pyc +0 -0
  115. package/src/orion/engine/supply_chain_sbom/engine.py +290 -0
  116. package/src/orion/engine/symbol/__pycache__/engine.cpython-313.pyc +0 -0
  117. package/src/orion/engine/symbol/engine.py +463 -0
  118. package/src/orion/engine/taint/__pycache__/engine.cpython-313.pyc +0 -0
  119. package/src/orion/engine/taint/engine.py +464 -0
  120. package/src/orion/engine/test_coverage/__pycache__/engine.cpython-313.pyc +0 -0
  121. package/src/orion/engine/test_coverage/engine.py +445 -0
  122. package/src/orion/engine/threat_model/__pycache__/engine.cpython-313.pyc +0 -0
  123. package/src/orion/engine/threat_model/engine.py +126 -0
  124. package/src/orion/engine/wiring_trace/__pycache__/engine.cpython-313.pyc +0 -0
  125. package/src/orion/engine/wiring_trace/engine.py +200 -0
  126. package/src/orion_engine.egg-info/PKG-INFO +7 -0
  127. package/src/orion_engine.egg-info/SOURCES.txt +65 -0
  128. package/src/orion_engine.egg-info/dependency_links.txt +1 -0
  129. package/src/orion_engine.egg-info/entry_points.txt +2 -0
  130. package/src/orion_engine.egg-info/top_level.txt +1 -0
@@ -0,0 +1,268 @@
1
+ """Chaos injection analysis engine - AST mutation analysis for resilience patterns."""
2
+ import ast
3
+ from orion.engine.config import EngineConfig
4
+ from orion.engine.ir import OrionIR
5
+ from orion.engine.results import EngineResult, Finding
6
+
7
+
8
+ class Engine:
9
+ name = "chaos"
10
+ version = "1.0.0"
11
+
12
+ EXTERNAL_CALL_PATTERNS = {
13
+ "requests.get", "requests.post", "requests.put", "requests.delete", "requests.request",
14
+ "httpx.get", "httpx.post", "httpx.put", "httpx.delete", "httpx.request",
15
+ "aiohttp.ClientSession.get", "aiohttp.ClientSession.post",
16
+ "urllib.request.urlopen", "urllib3.request",
17
+ "boto3.client", "boto3.resource",
18
+ "redis.Redis", "redis.StrictRedis",
19
+ "psycopg2.connect", "mysql.connector.connect", "pymongo.MongoClient",
20
+ "sqlalchemy.create_engine", "asyncpg.connect",
21
+ "kafka.KafkaProducer", "kafka.KafkaConsumer",
22
+ "pika.BlockingConnection", "pika.SelectConnection",
23
+ }
24
+
25
+ RETRY_PATTERNS = {"retry", "backoff", "tenacity", "retrying"}
26
+ CIRCUIT_BREAKER_PATTERNS = {"circuitbreaker", "circuit_breaker", "pybreaker", "hystrix"}
27
+ TIMEOUT_PATTERNS = {"timeout", "connect_timeout", "read_timeout", "request_timeout"}
28
+
29
+ async def check_availability(self) -> bool:
30
+ return True
31
+
32
+ def resource_limits(self):
33
+ from orion.engine.config import ResourceLimits
34
+ return ResourceLimits(max_cpu_seconds=120, max_memory_mb=512)
35
+
36
+ async def run(self, ir: OrionIR, config: EngineConfig) -> EngineResult:
37
+ try:
38
+ text = ir.content.decode("utf-8", errors="replace")
39
+ except Exception:
40
+ return EngineResult(engine=self.name, findings=[], ok=True)
41
+
42
+ path = ir.metadata.get("path", "unknown")
43
+ is_test = self._is_test_file(path)
44
+
45
+ try:
46
+ tree = ast.parse(text, filename=path)
47
+ except SyntaxError:
48
+ return EngineResult(engine=self.name, findings=[], ok=True)
49
+
50
+ findings = []
51
+ visitor = ChaosVisitor(path, is_test, text.splitlines())
52
+ visitor.visit(tree)
53
+ findings.extend(visitor.findings)
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
+
61
+ class ChaosVisitor(ast.NodeVisitor):
62
+ EXTERNAL_CALL_PATTERNS = {
63
+ "requests.get", "requests.post", "requests.put", "requests.delete", "requests.request",
64
+ "httpx.get", "httpx.post", "httpx.put", "httpx.delete", "httpx.request",
65
+ "aiohttp.ClientSession.get", "aiohttp.ClientSession.post",
66
+ "urllib.request.urlopen", "urllib3.request",
67
+ "boto3.client", "boto3.resource",
68
+ "redis.Redis", "redis.StrictRedis",
69
+ "psycopg2.connect", "mysql.connector.connect", "pymongo.MongoClient",
70
+ "sqlalchemy.create_engine", "asyncpg.connect",
71
+ "kafka.KafkaProducer", "kafka.KafkaConsumer",
72
+ "pika.BlockingConnection", "pika.SelectConnection",
73
+ }
74
+
75
+ RETRY_PATTERNS = {"retry", "backoff", "tenacity", "retrying"}
76
+ CIRCUIT_BREAKER_PATTERNS = {"circuitbreaker", "circuit_breaker", "pybreaker", "hystrix"}
77
+ TIMEOUT_PATTERNS = {"timeout", "connect_timeout", "read_timeout", "request_timeout"}
78
+
79
+ def __init__(self, filepath: str, is_test: bool, lines: list[str]):
80
+ self.filepath = filepath
81
+ self.is_test = is_test
82
+ self.lines = lines
83
+ self.findings = []
84
+ self.external_calls = []
85
+ self.has_retry = False
86
+ self.has_circuit_breaker = False
87
+ self.has_timeout = False
88
+ self.has_idempotency = False
89
+ self.global_state = set()
90
+ self.global_locks = set()
91
+ self.direct_instantiations = []
92
+ self.health_check_endpoints = []
93
+ self.module_level_threads = []
94
+ self.in_function = False
95
+ self.current_function = None
96
+
97
+ def visit_FunctionDef(self, node: ast.FunctionDef):
98
+ old_func = self.current_function
99
+ self.current_function = node.name
100
+ old_in_func = self.in_function
101
+ self.in_function = True
102
+
103
+ # First visit body to collect external calls
104
+ for stmt in node.body:
105
+ self.visit(stmt)
106
+
107
+ # Then check for chaos patterns
108
+ self._check_function_for_chaos_patterns(node)
109
+
110
+ self.in_function = old_in_func
111
+ self.current_function = old_func
112
+
113
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
114
+ self.visit_FunctionDef(node)
115
+
116
+ def visit_Call(self, node: ast.Call):
117
+ self._analyze_call(node)
118
+ self.generic_visit(node)
119
+
120
+ def visit_Assign(self, node: ast.Assign):
121
+ if not self.in_function:
122
+ for target in node.targets:
123
+ if isinstance(target, ast.Name):
124
+ self.global_state.add(target.id)
125
+ if self._is_lock_creation(node.value):
126
+ self.global_locks.add(target.id)
127
+ self.generic_visit(node)
128
+
129
+ def visit_ClassDef(self, node: ast.ClassDef):
130
+ self.generic_visit(node)
131
+
132
+ def _analyze_call(self, node: ast.Call):
133
+ func_name = self._get_call_name(node)
134
+ if not func_name:
135
+ return
136
+
137
+ if any(pattern in func_name for pattern in self.EXTERNAL_CALL_PATTERNS):
138
+ self.external_calls.append((node, func_name))
139
+
140
+ if any(pattern in func_name.lower() for pattern in self.RETRY_PATTERNS):
141
+ self.has_retry = True
142
+
143
+ if any(pattern in func_name.lower() for pattern in self.CIRCUIT_BREAKER_PATTERNS):
144
+ self.has_circuit_breaker = True
145
+
146
+ if "timeout" in func_name.lower():
147
+ self.has_timeout = True
148
+
149
+ for kw in node.keywords:
150
+ if kw.arg in ("timeout", "connect_timeout", "read_timeout", "request_timeout"):
151
+ self.has_timeout = True
152
+ if kw.arg in ("idempotency_key", "idempotent"):
153
+ self.has_idempotency = True
154
+
155
+ if self._is_direct_instantiation(node):
156
+ self.direct_instantiations.append((node, func_name))
157
+
158
+ def _get_call_name(self, node: ast.Call) -> str | None:
159
+ if isinstance(node.func, ast.Name):
160
+ return node.func.id
161
+ elif isinstance(node.func, ast.Attribute):
162
+ parts = []
163
+ current = node.func
164
+ while isinstance(current, ast.Attribute):
165
+ parts.append(current.attr)
166
+ current = current.value
167
+ if isinstance(current, ast.Name):
168
+ parts.append(current.id)
169
+ return ".".join(reversed(parts))
170
+ return None
171
+
172
+ def _is_lock_creation(self, node: ast.AST) -> bool:
173
+ if isinstance(node, ast.Call):
174
+ name = self._get_call_name(node)
175
+ return name and ("Lock" in name or "RLock" in name or "Semaphore" in name)
176
+ return False
177
+
178
+ def _is_direct_instantiation(self, node: ast.Call) -> bool:
179
+ if isinstance(node.func, ast.Name):
180
+ return node.func.id[0].isupper() if node.func.id else False
181
+ elif isinstance(node.func, ast.Attribute):
182
+ return node.func.attr[0].isupper() if node.func.attr else False
183
+ return False
184
+
185
+ def _check_function_for_chaos_patterns(self, node: ast.FunctionDef):
186
+ if self.external_calls and not self.has_retry:
187
+ for call_node, call_name in self.external_calls:
188
+ self._add_finding(call_node, "no_retry",
189
+ f"External call '{call_name}' without retry logic",
190
+ f"Add retry with exponential backoff for resilience",
191
+ confidence=0.8)
192
+
193
+ if self.external_calls and not self.has_circuit_breaker:
194
+ for call_node, call_name in self.external_calls:
195
+ self._add_finding(call_node, "no_circuit_breaker",
196
+ f"External call '{call_name}' without circuit breaker",
197
+ f"Add circuit breaker pattern to prevent cascade failures",
198
+ confidence=0.75)
199
+
200
+ if self.external_calls and not self.has_timeout:
201
+ for call_node, call_name in self.external_calls:
202
+ self._add_finding(call_node, "no_timeout",
203
+ f"External call '{call_name}' without timeout",
204
+ f"Add timeout parameter (e.g., timeout=5)",
205
+ confidence=0.85)
206
+
207
+ if self.external_calls and not self.has_idempotency:
208
+ for call_node, call_name in self.external_calls:
209
+ if "post" in call_name.lower() or "put" in call_name.lower() or "patch" in call_name.lower():
210
+ self._add_finding(call_node, "no_idempotency_key",
211
+ f"Mutating external call '{call_name}' without idempotency key",
212
+ f"Add idempotency_key parameter for safe retries",
213
+ confidence=0.7)
214
+
215
+ def visit_Module(self, node: ast.Module):
216
+ for stmt in node.body:
217
+ if isinstance(stmt, (ast.Import, ast.ImportFrom)):
218
+ for alias in stmt.names:
219
+ if alias.name in ("threading", "multiprocessing", "concurrent.futures"):
220
+ pass
221
+ elif isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
222
+ pass
223
+ elif isinstance(stmt, ast.Assign):
224
+ self.visit(stmt)
225
+ elif isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
226
+ call_name = self._get_call_name(stmt.value)
227
+ if call_name and "Thread" in call_name:
228
+ self.module_level_threads.append((stmt, call_name))
229
+
230
+ self._check_module_level_patterns()
231
+ self.generic_visit(node)
232
+
233
+ def _check_module_level_patterns(self):
234
+ for thread_node, thread_name in self.module_level_threads:
235
+ self._add_finding(thread_node, "module_level_threading",
236
+ f"Thread/process started at module level: {thread_name}",
237
+ "Move thread initialization to function or use lazy initialization",
238
+ confidence=0.8)
239
+
240
+ if len(self.global_locks) > 0:
241
+ for lock in self.global_locks:
242
+ self._add_finding(None, "global_lock_bottleneck",
243
+ f"Global lock '{lock}' may create single-threaded bottleneck",
244
+ "Consider fine-grained locking or lock-free data structures",
245
+ confidence=0.75, line_no=1)
246
+
247
+ def _add_finding(self, node: ast.AST | None, pattern: str, title: str, fix: str, confidence: float, line_no: int | None = None):
248
+ if node:
249
+ line_no = getattr(node, "lineno", 1)
250
+ elif line_no is None:
251
+ line_no = 1
252
+
253
+ snippet = self.lines[line_no - 1].strip() if 0 < line_no <= len(self.lines) else ""
254
+
255
+ self.findings.append(Finding(
256
+ engine="chaos",
257
+ severity="high" if confidence >= 0.8 else "medium",
258
+ title=title,
259
+ description=fix,
260
+ location=f"{self.filepath}:{line_no}",
261
+ metadata={
262
+ "confidence": confidence,
263
+ "pattern": pattern,
264
+ "location": f"{self.filepath}:{line_no}",
265
+ "fix": fix,
266
+ "snippet": snippet[:200],
267
+ },
268
+ ))
@@ -0,0 +1,322 @@
1
+ """Config chaos analysis engine - edge case testing for configuration files."""
2
+ import json
3
+ import re
4
+ import tomllib
5
+ from pathlib import Path
6
+ from orion.engine.config import EngineConfig
7
+ from orion.engine.ir import OrionIR
8
+ from orion.engine.results import EngineResult, Finding
9
+
10
+
11
+ class Engine:
12
+ name = "chaos_config"
13
+ version = "1.0.0"
14
+
15
+ POSITIVE_ONLY_KEYS = {
16
+ "port", "PORT", "timeout", "TIMEOUT", "retries", "RETRIES", "max_retries",
17
+ "workers", "WORKERS", "pool_size", "POOL_SIZE", "max_connections",
18
+ "MAX_CONNECTIONS", "buffer_size", "BUFFER_SIZE", "batch_size", "BATCH_SIZE",
19
+ "limit", "LIMIT", "page_size", "PAGE_SIZE", "ttl", "TTL", "expire", "EXPIRE",
20
+ "interval", "INTERVAL", "rate_limit", "RATE_LIMIT", "max_size", "MAX_SIZE",
21
+ }
22
+
23
+ ENUM_KEYS = {
24
+ "log_level": {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"},
25
+ "LOG_LEVEL": {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"},
26
+ "environment": {"development", "staging", "production", "test"},
27
+ "ENVIRONMENT": {"development", "staging", "production", "test"},
28
+ "mode": {"sync", "async", "threaded", "multiprocess"},
29
+ "protocol": {"http", "https", "grpc", "tcp", "udp"},
30
+ }
31
+
32
+ REQUIRED_SECTIONS = {
33
+ "database", "redis", "kafka", "rabbitmq", "email", "storage", "auth",
34
+ "logging", "monitoring", "security", "cache", "queue",
35
+ }
36
+
37
+ async def check_availability(self) -> bool:
38
+ return True
39
+
40
+ def resource_limits(self):
41
+ from orion.engine.config import ResourceLimits
42
+ return ResourceLimits(max_cpu_seconds=60, max_memory_mb=256)
43
+
44
+ async def run(self, ir: OrionIR, config: EngineConfig) -> EngineResult:
45
+ try:
46
+ text = ir.content.decode("utf-8", errors="replace")
47
+ except Exception:
48
+ return EngineResult(engine=self.name, findings=[], ok=True)
49
+
50
+ path = ir.metadata.get("path", "unknown")
51
+ filename = Path(path).name.lower()
52
+ ext = Path(path).suffix.lower()
53
+
54
+ findings = []
55
+ parsed = self._parse_config(text, filename, ext)
56
+
57
+ if parsed is not None:
58
+ flat = self._flatten_config(parsed)
59
+ findings.extend(self._check_edge_cases(flat, path, filename, ext))
60
+ findings.extend(self._check_circular_refs(flat, path))
61
+ findings.extend(self._check_overly_long(flat, path))
62
+ findings.extend(self._check_special_chars(flat, path))
63
+
64
+ return EngineResult(engine=self.name, findings=findings, ok=True)
65
+
66
+ def _parse_config(self, text: str, filename: str, ext: str) -> dict | None:
67
+ try:
68
+ if ext in (".yaml", ".yml"):
69
+ import yaml
70
+ return yaml.safe_load(text) or {}
71
+ elif ext == ".json":
72
+ return json.loads(text) or {}
73
+ elif ext == ".toml":
74
+ return tomllib.loads(text) or {}
75
+ elif ext in (".env",):
76
+ return self._parse_env(text)
77
+ elif ext in (".ini", ".cfg", ".conf"):
78
+ return self._parse_ini(text)
79
+ elif ext == ".properties":
80
+ return self._parse_properties(text)
81
+ except Exception:
82
+ pass
83
+ return None
84
+
85
+ def _parse_env(self, text: str) -> dict:
86
+ result = {}
87
+ for line in text.splitlines():
88
+ line = line.strip()
89
+ if not line or line.startswith("#"):
90
+ continue
91
+ if "=" in line:
92
+ key, value = line.split("=", 1)
93
+ result[key.strip()] = value.strip().strip('"\'')
94
+ return result
95
+
96
+ def _parse_ini(self, text: str) -> dict:
97
+ import configparser
98
+ parser = configparser.ConfigParser()
99
+ parser.read_string(text)
100
+ result = {}
101
+ for section in parser.sections():
102
+ for key, value in parser[section].items():
103
+ result[f"{section}.{key}"] = value
104
+ return result
105
+
106
+ def _parse_properties(self, text: str) -> dict:
107
+ result = {}
108
+ for line in text.splitlines():
109
+ line = line.strip()
110
+ if not line or line.startswith("#") or line.startswith("!"):
111
+ continue
112
+ if "=" in line:
113
+ key, value = line.split("=", 1)
114
+ result[key.strip()] = value.strip()
115
+ elif ":" in line:
116
+ key, value = line.split(":", 1)
117
+ result[key.strip()] = value.strip()
118
+ return result
119
+
120
+ def _flatten_config(self, config: dict, prefix: str = "") -> dict:
121
+ result = {}
122
+ for key, value in config.items():
123
+ full_key = f"{prefix}.{key}" if prefix else key
124
+ if isinstance(value, dict):
125
+ result.update(self._flatten_config(value, full_key))
126
+ else:
127
+ result[full_key] = value
128
+ return result
129
+
130
+ def _check_edge_cases(self, flat: dict, path: str, filename: str, ext: str) -> list[Finding]:
131
+ findings = []
132
+ for key, value in flat.items():
133
+ key_lower = key.lower()
134
+
135
+ if value is None or (isinstance(value, str) and value.strip() == ""):
136
+ findings.append(Finding(
137
+ engine=self.name,
138
+ severity="high",
139
+ title=f"Empty/null config value: {key}",
140
+ description=f"Config key '{key}' has empty or null value which may cause failures",
141
+ location=path,
142
+ metadata={
143
+ "confidence": 0.9,
144
+ "key": key,
145
+ "value": "null" if value is None else '""',
146
+ "risk": "null_or_empty_value",
147
+ },
148
+ ))
149
+
150
+ if isinstance(value, (int, float)):
151
+ if key_lower in self.POSITIVE_ONLY_KEYS and value <= 0:
152
+ findings.append(Finding(
153
+ engine=self.name,
154
+ severity="high",
155
+ title=f"Negative/zero value for positive-only key: {key}",
156
+ description=f"Key '{key}' = {value} but must be positive",
157
+ location=path,
158
+ metadata={
159
+ "confidence": 0.95,
160
+ "key": key,
161
+ "value": str(value),
162
+ "risk": "negative_where_positive_expected",
163
+ },
164
+ ))
165
+ if isinstance(value, (int, float)) and abs(value) > 1e9:
166
+ findings.append(Finding(
167
+ engine=self.name,
168
+ severity="medium",
169
+ title=f"Extremely large numeric value: {key}",
170
+ description=f"Key '{key}' = {value} may cause overflow or performance issues",
171
+ location=path,
172
+ metadata={
173
+ "confidence": 0.75,
174
+ "key": key,
175
+ "value": str(value),
176
+ "risk": "very_large_number",
177
+ },
178
+ ))
179
+
180
+ if isinstance(value, str):
181
+ if key_lower in self.ENUM_KEYS:
182
+ allowed = self.ENUM_KEYS[key_lower]
183
+ if value.upper() not in {v.upper() for v in allowed}:
184
+ findings.append(Finding(
185
+ engine=self.name,
186
+ severity="high",
187
+ title=f"Invalid enum value: {key}",
188
+ description=f"Key '{key}' = '{value}' not in allowed values: {allowed}",
189
+ location=path,
190
+ metadata={
191
+ "confidence": 0.95,
192
+ "key": key,
193
+ "value": value,
194
+ "risk": "invalid_enum",
195
+ "allowed": list(allowed),
196
+ },
197
+ ))
198
+
199
+ return findings
200
+
201
+ def _check_circular_refs(self, flat: dict, path: str) -> list[Finding]:
202
+ findings = []
203
+ visited = set()
204
+ path_stack = []
205
+
206
+ def check_refs(obj, current_path=""):
207
+ if id(obj) in visited:
208
+ return
209
+ visited.add(id(obj))
210
+
211
+ if isinstance(obj, dict):
212
+ for k, v in obj.items():
213
+ new_path = f"{current_path}.{k}" if current_path else k
214
+ if isinstance(v, str) and v.startswith("${") and v.endswith("}"):
215
+ ref_key = v[2:-1]
216
+ if ref_key in path_stack:
217
+ findings.append(Finding(
218
+ engine=self.name,
219
+ severity="high",
220
+ title=f"Circular reference detected: {new_path}",
221
+ description=f"Config reference '{v}' creates circular dependency",
222
+ location=path,
223
+ metadata={
224
+ "confidence": 0.9,
225
+ "key": new_path,
226
+ "value": v,
227
+ "risk": "circular_reference",
228
+ "cycle": " -> ".join(path_stack + [ref_key]),
229
+ },
230
+ ))
231
+ path_stack.append(k)
232
+ check_refs(v, new_path)
233
+ path_stack.pop()
234
+ elif isinstance(obj, list):
235
+ for i, item in enumerate(obj):
236
+ check_refs(item, f"{current_path}[{i}]")
237
+
238
+ check_refs(flat)
239
+ return findings
240
+
241
+ def _check_overly_long(self, flat: dict, path: str) -> list[Finding]:
242
+ findings = []
243
+ for key, value in flat.items():
244
+ if isinstance(value, str) and len(value) > 10000:
245
+ findings.append(Finding(
246
+ engine=self.name,
247
+ severity="medium",
248
+ title=f"Overly long config value: {key}",
249
+ description=f"Config value for '{key}' exceeds 10000 characters ({len(value)} chars)",
250
+ location=path,
251
+ metadata={
252
+ "confidence": 0.8,
253
+ "key": key,
254
+ "value_length": len(value),
255
+ "risk": "overly_long_string",
256
+ },
257
+ ))
258
+ return findings
259
+
260
+ def _check_special_chars(self, flat: dict, path: str) -> list[Finding]:
261
+ findings = []
262
+ dangerous_chars = ["\0", "\r", "\x1b", "\x00", "\x01", "\x02", "\x03", "\x04"]
263
+ for key, value in flat.items():
264
+ if isinstance(value, str):
265
+ for char in dangerous_chars:
266
+ if char in value:
267
+ findings.append(Finding(
268
+ engine=self.name,
269
+ severity="high",
270
+ title=f"Dangerous character in config: {key}",
271
+ description=f"Config value contains control character (0x{ord(char):02x})",
272
+ location=path,
273
+ metadata={
274
+ "confidence": 0.9,
275
+ "key": key,
276
+ "risk": "control_character",
277
+ "char_code": f"0x{ord(char):02x}",
278
+ },
279
+ ))
280
+ break
281
+
282
+ if key.lower().endswith(("path", "dir", "directory", "file", "cert", "key")):
283
+ if any(c in value for c in ["|", "&", ";", "$", "`", ">", "<", "\n", "\r"]):
284
+ findings.append(Finding(
285
+ engine=self.name,
286
+ severity="high",
287
+ title=f"Shell metacharacters in path config: {key}",
288
+ description=f"Path value contains shell metacharacters - potential injection",
289
+ location=path,
290
+ metadata={
291
+ "confidence": 0.85,
292
+ "key": key,
293
+ "risk": "shell_metacharacters_in_path",
294
+ },
295
+ ))
296
+ return findings
297
+
298
+ def _check_missing_sections(self, parsed: dict, path: str, filename: str) -> list[Finding]:
299
+ findings = []
300
+ if not parsed:
301
+ return findings
302
+
303
+ top_keys = {k.lower() for k in parsed.keys() if not isinstance(parsed[k], dict)}
304
+ section_keys = {k.lower() for k in parsed.keys() if isinstance(parsed[k], dict)}
305
+
306
+ for req in self.REQUIRED_SECTIONS:
307
+ if req not in section_keys and req not in top_keys:
308
+ if any(req in k for k in section_keys) or any(req in k for k in top_keys):
309
+ continue
310
+ findings.append(Finding(
311
+ engine=self.name,
312
+ severity="medium",
313
+ title=f"Missing required config section: {req}",
314
+ description=f"Expected configuration section '{req}' not found",
315
+ location=path,
316
+ metadata={
317
+ "confidence": 0.7,
318
+ "key": req,
319
+ "issue": "missing_section",
320
+ },
321
+ ))
322
+ return findings