@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,242 @@
1
+ """Provenance attestation engine - generates SLSA-like provenance metadata."""
2
+ import subprocess
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Dict, List, Optional, Any
9
+ from dataclasses import dataclass, asdict
10
+
11
+ from orion.engine.config import EngineConfig
12
+ from orion.engine.ir import OrionIR
13
+ from orion.engine.results import EngineResult, Finding
14
+
15
+
16
+ @dataclass
17
+ class ProvenanceData:
18
+ build_timestamp: str
19
+ git_commit_sha: Optional[str] = None
20
+ git_tag: Optional[str] = None
21
+ git_branch: Optional[str] = None
22
+ builder_identity: Optional[str] = None
23
+ file_hashes: Dict[str, str] = None
24
+ dependencies: Dict[str, str] = None
25
+ environment: Dict[str, str] = None
26
+
27
+ def __post_init__(self):
28
+ if self.file_hashes is None:
29
+ self.file_hashes = {}
30
+ if self.dependencies is None:
31
+ self.dependencies = {}
32
+ if self.environment is None:
33
+ self.environment = {}
34
+
35
+
36
+ class ProvenanceEngine:
37
+ name = "provenance"
38
+ version = "1.0.0"
39
+
40
+ CI_ENV_VARS = {
41
+ "github": "GITHUB_SHA",
42
+ "gitlab": "GITLAB_COMMIT_SHA",
43
+ "buildkite": "BUILDKITE_COMMIT",
44
+ "circleci": "CIRCLE_SHA1",
45
+ "travis": "TRAVIS_COMMIT",
46
+ "jenkins": "GIT_COMMIT",
47
+ "azure": "BUILD_SOURCEVERSION",
48
+ "bitbucket": "BITBUCKET_COMMIT",
49
+ "drone": "DRONE_COMMIT_SHA",
50
+ "codebuild": "CODEBUILD_RESOLVED_SOURCE_VERSION",
51
+ }
52
+
53
+ async def check_availability(self) -> bool:
54
+ return True
55
+
56
+ def resource_limits(self):
57
+ from orion.engine.config import ResourceLimits
58
+ return ResourceLimits(max_cpu_seconds=30, max_memory_mb=128)
59
+
60
+ def _get_git_info(self, project_root: str) -> Dict[str, Optional[str]]:
61
+ """Extract git information from the project."""
62
+ info = {"commit_sha": None, "tag": None, "branch": None}
63
+ try:
64
+ # Get commit SHA
65
+ result = subprocess.run(
66
+ ["git", "rev-parse", "HEAD"],
67
+ cwd=project_root,
68
+ capture_output=True,
69
+ text=True,
70
+ timeout=5,
71
+ )
72
+ if result.returncode == 0:
73
+ info["commit_sha"] = result.stdout.strip()
74
+
75
+ # Get tag
76
+ result = subprocess.run(
77
+ ["git", "describe", "--tags", "--exact-match"],
78
+ cwd=project_root,
79
+ capture_output=True,
80
+ text=True,
81
+ timeout=5,
82
+ )
83
+ if result.returncode == 0:
84
+ info["tag"] = result.stdout.strip()
85
+
86
+ # Get branch
87
+ result = subprocess.run(
88
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
89
+ cwd=project_root,
90
+ capture_output=True,
91
+ text=True,
92
+ timeout=5,
93
+ )
94
+ if result.returncode == 0:
95
+ info["branch"] = result.stdout.strip()
96
+ except Exception:
97
+ pass
98
+ return info
99
+
100
+ def _get_builder_identity(self) -> Optional[str]:
101
+ """Detect CI environment and get builder identity."""
102
+ for ci_name, env_var in self.CI_ENV_VARS.items():
103
+ value = os.environ.get(env_var)
104
+ if value:
105
+ return f"{ci_name}:{value}"
106
+ return None
107
+
108
+ def _calculate_file_hashes(self, all_files: Dict[str, OrionIR]) -> Dict[str, str]:
109
+ """Calculate SHA256 hashes for all source files."""
110
+ hashes = {}
111
+ for filepath, file_ir in all_files.items():
112
+ try:
113
+ # Only hash source files (not binary, not generated)
114
+ path = Path(filepath)
115
+ if path.suffix in (".py", ".js", ".ts", ".java", ".go", ".rs", ".rb", ".php",
116
+ ".cs", ".cpp", ".c", ".h", ".hpp", ".sh", ".yaml", ".yml",
117
+ ".json", ".toml", ".xml", ".html", ".css", ".sql", ".md",
118
+ ".txt", ".cfg", ".ini", ".conf", ".dockerfile", ".gitignore"):
119
+ content = file_ir.content
120
+ if isinstance(content, str):
121
+ content = content.encode("utf-8")
122
+ sha256 = hashlib.sha256(content).hexdigest()
123
+ hashes[filepath] = sha256
124
+ except Exception:
125
+ pass
126
+ return hashes
127
+
128
+ def _get_dependencies(self, all_files: Dict[str, OrionIR], project_root: str) -> Dict[str, str]:
129
+ """Extract dependencies with hashes from lock files."""
130
+ deps = {}
131
+
132
+ # Python dependencies
133
+ lock_files = [
134
+ "requirements.txt", "requirements.lock", "Pipfile.lock",
135
+ "poetry.lock", "pyproject.toml", "setup.py", "setup.cfg",
136
+ ]
137
+ for lock_file in lock_files:
138
+ path = Path(project_root) / lock_file
139
+ if path.exists():
140
+ try:
141
+ content = path.read_bytes()
142
+ deps[lock_file] = hashlib.sha256(content).hexdigest()
143
+ except Exception:
144
+ pass
145
+
146
+ # Node.js dependencies
147
+ for lock_file in ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"]:
148
+ path = Path(project_root) / lock_file
149
+ if path.exists():
150
+ try:
151
+ content = path.read_bytes()
152
+ deps[lock_file] = hashlib.sha256(content).hexdigest()
153
+ except Exception:
154
+ pass
155
+
156
+ # Go dependencies
157
+ for lock_file in ["go.sum", "go.mod"]:
158
+ path = Path(project_root) / lock_file
159
+ if path.exists():
160
+ try:
161
+ content = path.read_bytes()
162
+ deps[lock_file] = hashlib.sha256(content).hexdigest()
163
+ except Exception:
164
+ pass
165
+
166
+ # Rust dependencies
167
+ path = Path(project_root) / "Cargo.lock"
168
+ if path.exists():
169
+ try:
170
+ content = path.read_bytes()
171
+ deps["Cargo.lock"] = hashlib.sha256(content).hexdigest()
172
+ except Exception:
173
+ pass
174
+
175
+ # Java dependencies
176
+ for lock_file in ["pom.xml", "build.gradle", "build.gradle.kts"]:
177
+ path = Path(project_root) / lock_file
178
+ if path.exists():
179
+ try:
180
+ content = path.read_bytes()
181
+ deps[lock_file] = hashlib.sha256(content).hexdigest()
182
+ except Exception:
183
+ pass
184
+
185
+ return deps
186
+
187
+ def _get_environment(self) -> Dict[str, str]:
188
+ """Capture relevant environment variables."""
189
+ env = {}
190
+ relevant_vars = [
191
+ "CI", "GITHUB_ACTIONS", "GITLAB_CI", "BUILDKITE", "CIRCLECI",
192
+ "TRAVIS", "JENKINS_URL", "AZURE_PIPELINES", "BITBUCKET_BUILD_NUMBER",
193
+ "DRONE", "CODEBUILD_BUILD_ID", "PYTHON_VERSION", "NODE_VERSION",
194
+ "GO_VERSION", "RUST_VERSION", "JAVA_VERSION",
195
+ ]
196
+ for var in relevant_vars:
197
+ value = os.environ.get(var)
198
+ if value:
199
+ env[var] = value
200
+ return env
201
+
202
+ async def run(self, ir: OrionIR, config: EngineConfig) -> EngineResult:
203
+ project_root = ir.metadata.get("project_root", ".")
204
+ all_files = ir.metadata.get("all_files", {})
205
+
206
+ if not all_files:
207
+ all_files = {ir.metadata.get("path", "unknown"): ir}
208
+
209
+ # Gather provenance data
210
+ git_info = self._get_git_info(project_root)
211
+ builder_identity = self._get_builder_identity()
212
+ file_hashes = self._calculate_file_hashes(all_files)
213
+ dependencies = self._get_dependencies(all_files, project_root)
214
+ environment = self._get_environment()
215
+
216
+ provenance = ProvenanceData(
217
+ build_timestamp=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
218
+ git_commit_sha=git_info["commit_sha"],
219
+ git_tag=git_info["tag"],
220
+ git_branch=git_info["branch"],
221
+ builder_identity=builder_identity,
222
+ file_hashes=file_hashes,
223
+ dependencies=dependencies,
224
+ environment=environment,
225
+ )
226
+
227
+ # Store in metrics (no findings for provenance engine)
228
+ return EngineResult(
229
+ engine=self.name,
230
+ findings=[],
231
+ ok=True,
232
+ metrics={
233
+ "provenance": asdict(provenance),
234
+ "files_hashed": len(file_hashes),
235
+ "dependency_files": len(dependencies),
236
+ "git_info_available": git_info["commit_sha"] is not None,
237
+ "ci_detected": builder_identity is not None,
238
+ },
239
+ )
240
+
241
+
242
+ Engine = ProvenanceEngine
@@ -0,0 +1,55 @@
1
+ from typing import Dict, Type
2
+ import importlib
3
+ import pkgutil
4
+ import sys
5
+
6
+
7
+ class EngineRegistry:
8
+ _instance = None
9
+
10
+ def __new__(cls):
11
+ if cls._instance is None:
12
+ cls._instance = super().__new__(cls)
13
+ cls._instance._engines: Dict[str, Type] = {}
14
+ cls._instance._discovered = False
15
+ return cls._instance
16
+
17
+ def register(self, name: str, cls):
18
+ self._engines[name] = cls
19
+
20
+ def get(self, name: str):
21
+ return self._engines.get(name)
22
+
23
+ def all(self):
24
+ if not self._discovered:
25
+ self._discover_engines()
26
+ return dict(self._engines)
27
+
28
+ def _discover_engines(self):
29
+ """Auto-discover all engines in the orion.engine package."""
30
+ if self._discovered:
31
+ return
32
+
33
+ # In-house proprietary commercial engines
34
+ engine_names = [
35
+ "sast", "secrets", "deps", "taint", "callgraph", "cfg", "symbol",
36
+ "dataflow", "authorization", "orphan", "prompt_security", "contract_probe",
37
+ "wiring_trace", "state", "license", "complexity", "duplication", "dead_import",
38
+ "error_handling", "logging_c", "config_engine", "chaos", "chaos_config",
39
+ "regression", "perf_guard", "data_residency", "noise", "supply_chain_sbom",
40
+ "hunter", "lint", "architecture", "test_coverage", "container",
41
+ "compliance", "provenance", "fp_learn", "sandbox_escape", "config_validation",
42
+ "iac_security", "crypto_guard", "llm_security", "api_security",
43
+ "threat_model", "sensitive_data"
44
+ ]
45
+
46
+ for name in engine_names:
47
+ try:
48
+ mod = importlib.import_module(f"orion.engine.{name}.engine")
49
+ if hasattr(mod, "Engine"):
50
+ self._engines[name] = mod.Engine
51
+ except Exception as e:
52
+ # Engine not available or failed to load
53
+ pass
54
+
55
+ self._discovered = True
@@ -0,0 +1,213 @@
1
+ """Regression risk patterns detection engine - AST analysis for fragile 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 = "regression"
10
+ version = "1.0.0"
11
+
12
+ async def check_availability(self) -> bool:
13
+ return True
14
+
15
+ def resource_limits(self):
16
+ from orion.engine.config import ResourceLimits
17
+ return ResourceLimits(max_cpu_seconds=60, max_memory_mb=256)
18
+
19
+ async def run(self, ir: OrionIR, config: EngineConfig) -> EngineResult:
20
+ try:
21
+ text = ir.content.decode("utf-8", errors="replace")
22
+ except Exception:
23
+ return EngineResult(engine=self.name, findings=[], ok=True)
24
+
25
+ path = ir.metadata.get("path", "unknown")
26
+ is_test = self._is_test_file(path)
27
+
28
+ try:
29
+ tree = ast.parse(text, filename=path)
30
+ except SyntaxError:
31
+ return EngineResult(engine=self.name, findings=[], ok=True)
32
+
33
+ findings = []
34
+ visitor = RegressionVisitor(path, is_test, text.splitlines())
35
+ visitor.visit(tree)
36
+ findings.extend(visitor.findings)
37
+
38
+ return EngineResult(engine=self.name, findings=findings, ok=True)
39
+
40
+ def _is_test_file(self, path: str) -> bool:
41
+ return any(p in path for p in ["test_", "_test.py", "/tests/", "/test/", "conftest.py"])
42
+
43
+
44
+ class RegressionVisitor(ast.NodeVisitor):
45
+ def __init__(self, filepath: str, is_test: bool, lines: list[str]):
46
+ self.filepath = filepath
47
+ self.is_test = is_test
48
+ self.lines = lines
49
+ self.findings = []
50
+ self.module_level_code = True
51
+
52
+ def visit_Assign(self, node: ast.Assign):
53
+ if self.module_level_code:
54
+ for target in node.targets:
55
+ if isinstance(target, ast.Attribute):
56
+ if isinstance(target.value, ast.Name):
57
+ self._check_monkey_patching(target.value.id, target.attr, node)
58
+ elif isinstance(target, ast.Subscript):
59
+ # Check for sys.modules['x'] = y pattern
60
+ if (isinstance(target.value, ast.Attribute) and
61
+ isinstance(target.value.value, ast.Name) and
62
+ target.value.value.id == "sys" and
63
+ target.value.attr == "modules"):
64
+ self._add_finding(node, "sys_modules_mutation",
65
+ "sys.modules mutation detected",
66
+ "Modifying sys.modules at runtime causes import system instability",
67
+ confidence=0.9)
68
+ elif isinstance(target.value, ast.Name):
69
+ self._check_registry_mutation(target.value.id, node)
70
+ self.generic_visit(node)
71
+
72
+ def visit_Call(self, node: ast.Call):
73
+ func_name = self._get_call_name(node)
74
+
75
+ if func_name:
76
+ if func_name in ("setattr", "delattr"):
77
+ self._check_setattr_delattr(node, func_name)
78
+ elif func_name in ("__import__", "importlib.import_module", "importlib.util.module_from_spec"):
79
+ self._check_dynamic_import(node, func_name)
80
+ elif func_name == "signal.signal":
81
+ self._check_signal_handler(node)
82
+ elif func_name == "atexit.register":
83
+ self._check_atexit(node)
84
+ elif func_name in ("threading.Thread", "multiprocessing.Process", "concurrent.futures.ThreadPoolExecutor.submit"):
85
+ if self.module_level_code:
86
+ self._check_module_level_concurrency(node, func_name)
87
+
88
+ self.generic_visit(node)
89
+
90
+ def visit_ImportFrom(self, node: ast.ImportFrom):
91
+ if node.module == "builtins":
92
+ for alias in node.names:
93
+ if alias.name in ("__import__",):
94
+ pass
95
+ self.generic_visit(node)
96
+
97
+ def visit_FunctionDef(self, node: ast.FunctionDef):
98
+ old_module = self.module_level_code
99
+ self.module_level_code = False
100
+
101
+ if node.name in ("__getattr__", "__getattribute__", "__del__"):
102
+ self._add_finding(node, f"override_{node.name}",
103
+ f"Overriding {node.name}() - fragile and error-prone",
104
+ f"Consider alternatives to {node.name}() override",
105
+ confidence=0.85 if node.name == "__del__" else 0.8)
106
+
107
+ if self._is_os_environ_mutation(node):
108
+ self._check_os_environ_mutation(node)
109
+
110
+ self.generic_visit(node)
111
+ self.module_level_code = old_module
112
+
113
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
114
+ self.visit_FunctionDef(node)
115
+
116
+ def visit_ClassDef(self, node: ast.ClassDef):
117
+ self.generic_visit(node)
118
+
119
+ def _get_call_name(self, node: ast.Call) -> str | None:
120
+ if isinstance(node.func, ast.Name):
121
+ return node.func.id
122
+ elif isinstance(node.func, ast.Attribute):
123
+ parts = []
124
+ current = node.func
125
+ while isinstance(current, ast.Attribute):
126
+ parts.append(current.attr)
127
+ current = current.value
128
+ if isinstance(current, ast.Name):
129
+ parts.append(current.id)
130
+ return ".".join(reversed(parts))
131
+ return None
132
+
133
+ def _check_monkey_patching(self, module_name: str, attr: str, node: ast.Assign):
134
+ if module_name in ("sys", "os", "builtins", "types", "importlib"):
135
+ self._add_finding(node, "monkey_patching_stdlib",
136
+ f"Monkey patching stdlib module {module_name}.{attr}",
137
+ "Avoid modifying standard library modules at runtime",
138
+ confidence=0.9)
139
+
140
+ def _check_registry_mutation(self, registry_name: str, node: ast.Assign):
141
+ registry_upper = registry_name.upper()
142
+ if "REGISTRY" in registry_upper or "REGISTRY" == registry_upper:
143
+ self._add_finding(node, "global_registry_mutation",
144
+ f"Mutating global registry {registry_name}",
145
+ "Global registry mutations cause test pollution and flaky tests",
146
+ confidence=0.85)
147
+
148
+ def _check_setattr_delattr(self, node: ast.Call, func_name: str):
149
+ if node.args and isinstance(node.args[0], ast.Name):
150
+ target = node.args[0].id
151
+ if target in ("sys", "os", "builtins", "types", "importlib", "logging"):
152
+ self._add_finding(node, f"{func_name}_stdlib",
153
+ f"{func_name}() on stdlib module {target}",
154
+ f"Avoid {func_name}() on standard library modules",
155
+ confidence=0.9)
156
+
157
+ def _check_dynamic_import(self, node: ast.Call, func_name: str):
158
+ # Only flag if importing directly with untrusted eval or non-literal module names
159
+ if node.args and not isinstance(node.args[0], ast.Constant):
160
+ self._add_finding(node, "dynamic_import_untrusted",
161
+ f"Dynamic import ({func_name}) with computed module name",
162
+ "Dynamic imports with non-constant module names can cause arbitrary module execution",
163
+ confidence=0.85)
164
+
165
+ def _check_signal_handler(self, node: ast.Call):
166
+ pass
167
+
168
+ def _check_atexit(self, node: ast.Call):
169
+ pass
170
+
171
+ def _check_module_level_concurrency(self, node: ast.Call, func_name: str):
172
+ self._add_finding(node, "module_level_concurrency",
173
+ f"Thread/process pool created at module level: {func_name}",
174
+ "Module-level concurrency initialization causes import-time side effects",
175
+ confidence=0.9)
176
+
177
+ def _is_os_environ_mutation(self, node: ast.FunctionDef) -> bool:
178
+ for stmt in ast.walk(node):
179
+ if isinstance(stmt, ast.Call):
180
+ func_name = self._get_call_name(stmt)
181
+ if func_name in ("os.environ.__setitem__", "os.environ.update", "os.putenv", "os.unsetenv"):
182
+ return True
183
+ if isinstance(stmt, ast.Assign):
184
+ for target in stmt.targets:
185
+ if isinstance(target, ast.Subscript):
186
+ if isinstance(target.value, ast.Attribute):
187
+ if isinstance(target.value.value, ast.Name) and target.value.value.id == "os":
188
+ if isinstance(target.value.attr, str) and target.value.attr == "environ":
189
+ return True
190
+ return False
191
+
192
+ def _check_os_environ_mutation(self, node: ast.FunctionDef):
193
+ self._add_finding(node, "os_environ_mutation",
194
+ "os.environ mutation detected in function",
195
+ "Environment mutations affect global state and cause test pollution",
196
+ confidence=0.85)
197
+
198
+ def _add_finding(self, node: ast.AST, pattern: str, title: str, description: str, confidence: float):
199
+ line_no = getattr(node, "lineno", 0)
200
+ snippet = self.lines[line_no - 1].strip() if 0 < line_no <= len(self.lines) else ""
201
+ self.findings.append(Finding(
202
+ engine="regression",
203
+ severity="high" if confidence >= 0.85 else "medium",
204
+ title=title,
205
+ description=description,
206
+ location=f"{self.filepath}:{line_no}",
207
+ metadata={
208
+ "confidence": confidence,
209
+ "pattern": pattern,
210
+ "snippet": snippet[:200],
211
+ "cwe": "CWE-474" if "monkey" in pattern or "dynamic_import" in pattern else "CWE-667",
212
+ },
213
+ ))
@@ -0,0 +1,21 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any
3
+
4
+
5
+ @dataclass
6
+ class Finding:
7
+ engine: str
8
+ severity: str
9
+ title: str
10
+ description: str = ""
11
+ location: str = ""
12
+ metadata: dict = field(default_factory=dict)
13
+
14
+
15
+ @dataclass
16
+ class EngineResult:
17
+ engine: str
18
+ findings: list = field(default_factory=list)
19
+ errors: list = field(default_factory=list)
20
+ metrics: dict = field(default_factory=dict)
21
+ ok: bool = True