@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,143 @@
1
+ """PrivacyShield™ - In-House PII, GDPR, HIPAA & PCI-DSS Data Leak Engine.
2
+
3
+ 100% Proprietary In-House IP for Commercial Privacy, Data Protection & Regulatory Compliance.
4
+ """
5
+
6
+ import re
7
+ from pathlib import Path
8
+ from typing import List, Dict, Any
9
+
10
+ from orion.engine.config import EngineConfig, ResourceLimits
11
+ from orion.engine.ir import OrionIR
12
+ from orion.engine.results import EngineResult, Finding
13
+
14
+
15
+ def luhn_checksum_valid(card_num: str) -> bool:
16
+ """Proprietary in-house Luhn checksum validator for payment cards."""
17
+ digits = [int(c) for c in card_num if c.isdigit()]
18
+ if len(digits) < 13 or len(digits) > 19:
19
+ return False
20
+ checksum = 0
21
+ reverse_digits = digits[::-1]
22
+ for i, digit in enumerate(reverse_digits):
23
+ if i % 2 == 1:
24
+ doubled = digit * 2
25
+ checksum += doubled - 9 if doubled > 9 else doubled
26
+ else:
27
+ checksum += digit
28
+ return checksum % 10 == 0
29
+
30
+
31
+ class SensitiveDataEngine:
32
+ name = "sensitive_data"
33
+ version = "1.0.0"
34
+
35
+ # US Social Security Numbers (SSN)
36
+ SSN_PATTERN = re.compile(r"\b(?!000|666|9\d{2})\d{3}[-\s](?!00)\d{2}[-\s](?!0000)\d{4}\b")
37
+
38
+ # Payment Card Numbers (Visa, Mastercard, Amex, Discover)
39
+ CC_PATTERN = re.compile(r"\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|6(?:011|5[0-9]{2})[0-9]{12})\b")
40
+
41
+ # Health & Medical Identifiers (US NPI, DEA, MRN)
42
+ NPI_PATTERN = re.compile(r"\b(?:npi|medical_record_number|mrn|patient_id)\s*[:=]\s*['\"]?([0-9]{10})['\"]?\b", re.IGNORECASE)
43
+
44
+ # Cleartext Passwords / PII in Logging statements
45
+ LOGGED_PII_PATTERN = re.compile(r"(?:logger|logging|console)\.(?:info|debug|warn|error|log)\s*\([^)]*(?:ssn|credit_card|card_number|password|secret|dob|birth_date|salary)[^)]*\)", re.IGNORECASE)
46
+
47
+ async def check_availability(self) -> bool:
48
+ return True
49
+
50
+ def resource_limits(self) -> ResourceLimits:
51
+ return ResourceLimits(max_cpu_seconds=45, max_memory_mb=128)
52
+
53
+ async def run(self, ir: OrionIR, config: EngineConfig) -> EngineResult:
54
+ filepath = ir.metadata.get("path", "unknown")
55
+ findings: List[Finding] = []
56
+
57
+ try:
58
+ content = ir.content.decode("utf-8", errors="replace")
59
+ except Exception:
60
+ content = str(ir.content)
61
+
62
+ lines = content.splitlines()
63
+
64
+ for i, line in enumerate(lines, 1):
65
+ line_str = line.strip()
66
+
67
+ # Skip comments or mock test files if configured
68
+ if line_str.startswith("#") and "mock" in line_str.lower():
69
+ continue
70
+
71
+ # 1. PCI-DSS: Credit Card Numbers
72
+ for match in self.CC_PATTERN.finditer(line):
73
+ candidate = match.group(0)
74
+ if luhn_checksum_valid(candidate):
75
+ findings.append(Finding(
76
+ engine=self.name,
77
+ severity="high",
78
+ title="PCI-DSS Violation: Hardcoded Payment Card Primary Account Number (PAN)",
79
+ description=f"Luhn-validated payment card number detected in cleartext source, violating PCI-DSS Req 3.4",
80
+ location=f"{filepath}:{i}",
81
+ metadata={
82
+ "regulation": "PCI-DSS-Req-3.4",
83
+ "cwe": "CWE-312",
84
+ "masked_value": candidate[:4] + "********" + candidate[-4:],
85
+ "confidence": 0.95,
86
+ },
87
+ ))
88
+
89
+ # 2. GDPR/CCPA: US Social Security Numbers
90
+ for match in self.SSN_PATTERN.finditer(line):
91
+ ssn = match.group(0)
92
+ findings.append(Finding(
93
+ engine=self.name,
94
+ severity="high",
95
+ title="GDPR/CCPA Sensitive PII: Hardcoded Social Security Number (SSN)",
96
+ description=f"Direct Social Security Number format detected in cleartext",
97
+ location=f"{filepath}:{i}",
98
+ metadata={
99
+ "regulation": "GDPR-Article-9 / CCPA-1798.140",
100
+ "cwe": "CWE-359",
101
+ "confidence": 0.90,
102
+ },
103
+ ))
104
+
105
+ # 3. HIPAA: Medical / Patient Identifiers
106
+ for match in self.NPI_PATTERN.finditer(line):
107
+ findings.append(Finding(
108
+ engine=self.name,
109
+ severity="medium",
110
+ title="HIPAA Security Rule: Unprotected Protected Health Information (PHI/NPI)",
111
+ description="Medical Record / National Provider Identifier (NPI) referenced directly in application source",
112
+ location=f"{filepath}:{i}",
113
+ metadata={
114
+ "regulation": "HIPAA-164.312",
115
+ "cwe": "CWE-359",
116
+ "confidence": 0.85,
117
+ },
118
+ ))
119
+
120
+ # 4. Privacy Logging Violation: Sensitive PII in Loggers
121
+ if self.LOGGED_PII_PATTERN.search(line):
122
+ findings.append(Finding(
123
+ engine=self.name,
124
+ severity="medium",
125
+ title="Privacy & Compliance Violation: Sensitive PII Sent to Application Logger",
126
+ description="Sensitive data fields (passwords, SSNs, credit cards, dates of birth) detected inside logging arguments",
127
+ location=f"{filepath}:{i}",
128
+ metadata={
129
+ "regulation": "SOC2-Privacy / GDPR-Article-32",
130
+ "cwe": "CWE-532",
131
+ "confidence": 0.90,
132
+ },
133
+ ))
134
+
135
+ return EngineResult(
136
+ engine=self.name,
137
+ findings=findings,
138
+ ok=True,
139
+ metrics={"file": filepath, "checks_run": 4, "findings_count": len(findings)},
140
+ )
141
+
142
+
143
+ Engine = SensitiveDataEngine
@@ -0,0 +1,218 @@
1
+ """Mutable state detection engine - find global/class/function mutable state."""
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 = "state"
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=30, max_memory_mb=128)
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
+ text = str(ir.content)
24
+
25
+ path = ir.metadata.get("path", "unknown")
26
+ findings = []
27
+
28
+ if not path.endswith(".py"):
29
+ return EngineResult(engine=self.name, findings=findings, ok=True)
30
+
31
+ try:
32
+ tree = ast.parse(text)
33
+ except SyntaxError:
34
+ return EngineResult(engine=self.name, findings=findings, ok=True, errors=["Syntax error in source"])
35
+
36
+ lines = text.splitlines()
37
+
38
+ for node in ast.walk(tree):
39
+ if isinstance(node, ast.Assign) and self._is_module_level(node, tree):
40
+ findings.extend(self._check_module_assignment(node, lines, path))
41
+ elif isinstance(node, ast.FunctionDef):
42
+ findings.extend(self._check_function_defaults(node, lines, path))
43
+ elif isinstance(node, ast.ClassDef):
44
+ findings.extend(self._check_class_attributes(node, lines, path))
45
+ elif isinstance(node, ast.Global):
46
+ findings.extend(self._check_global_usage(node, lines, path))
47
+ elif isinstance(node, ast.Nonlocal):
48
+ findings.extend(self._check_nonlocal_usage(node, lines, path))
49
+
50
+ return EngineResult(engine=self.name, findings=findings, ok=True)
51
+
52
+ def _is_module_level(self, node: ast.AST, tree: ast.AST) -> bool:
53
+ for n in ast.walk(tree):
54
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
55
+ for child in ast.walk(n):
56
+ if child is node:
57
+ return False
58
+ return True
59
+
60
+ def _check_module_assignment(self, node: ast.Assign, lines: list, path: str):
61
+ findings = []
62
+ for target in node.targets:
63
+ if isinstance(target, ast.Name):
64
+ # Standard Python uppercase constants, lookup maps and registry dicts are not race conditions
65
+ if target.id.isupper() or target.id.startswith("_") or any(target.id.endswith(s) for s in ["_MAP", "_DICT", "_LIST", "_PATTERNS", "_EXTENSIONS", "_TYPES"]):
66
+ continue
67
+
68
+ if self._is_mutable_value(node.value):
69
+ line_no = node.lineno
70
+ snippet = self._get_snippet(lines, line_no)
71
+ findings.append(Finding(
72
+ engine=self.name,
73
+ severity="low",
74
+ title=f"Module-level mutable variable: {target.id}",
75
+ description=f"Global variable '{target.id}' assigned a mutable value ({self._get_type_name(node.value)})",
76
+ location=f"{path}:{line_no}",
77
+ metadata={
78
+ "confidence": 0.65,
79
+ "snippet": snippet,
80
+ "variable": target.id,
81
+ "type": self._get_type_name(node.value),
82
+ "issue": "global_mutable",
83
+ "cwe": "CWE-488",
84
+ },
85
+ ))
86
+ return findings
87
+
88
+ def _is_mutable_value(self, node: ast.AST) -> bool:
89
+ if isinstance(node, (ast.Dict, ast.List, ast.Set)):
90
+ return True
91
+ if isinstance(node, ast.Call):
92
+ if isinstance(node.func, ast.Name):
93
+ if node.func.id in ("dict", "list", "set", "defaultdict", "OrderedDict", "Counter"):
94
+ return True
95
+ if isinstance(node.func, ast.Attribute):
96
+ if node.func.attr in ("dict", "list", "set"):
97
+ return True
98
+ return False
99
+
100
+ def _get_type_name(self, node: ast.AST) -> str:
101
+ if isinstance(node, ast.Dict):
102
+ return "dict"
103
+ elif isinstance(node, ast.List):
104
+ return "list"
105
+ elif isinstance(node, ast.Set):
106
+ return "set"
107
+ elif isinstance(node, ast.Call):
108
+ if isinstance(node.func, ast.Name):
109
+ return node.func.id
110
+ elif isinstance(node.func, ast.Attribute):
111
+ return node.func.attr
112
+ return "unknown"
113
+
114
+ def _check_function_defaults(self, node: ast.FunctionDef, lines: list, path: str):
115
+ findings = []
116
+ for default in node.args.defaults:
117
+ if self._is_mutable_value(default):
118
+ line_no = node.lineno
119
+ snippet = self._get_snippet(lines, line_no)
120
+ arg_index = len(node.args.posonlyargs) + len(node.args.args) - len(node.args.defaults)
121
+ arg_name = node.args.args[arg_index].arg if arg_index < len(node.args.args) else "unknown"
122
+ findings.append(Finding(
123
+ engine=self.name,
124
+ severity="high",
125
+ title=f"Mutable default argument: {node.name}.{arg_name}",
126
+ description=f"Function '{node.name}' has mutable default argument '{arg_name}' ({self._get_type_name(default)})",
127
+ location=f"{path}:{line_no}",
128
+ metadata={
129
+ "confidence": 0.95,
130
+ "snippet": snippet,
131
+ "function": node.name,
132
+ "argument": arg_name,
133
+ "type": self._get_type_name(default),
134
+ "issue": "mutable_default",
135
+ "cwe": "CWE-488",
136
+ "cvss": 5.0,
137
+ },
138
+ ))
139
+ return findings
140
+
141
+ def _check_class_attributes(self, node: ast.ClassDef, lines: list, path: str):
142
+ findings = []
143
+ for item in node.body:
144
+ if isinstance(item, ast.Assign):
145
+ for target in item.targets:
146
+ if isinstance(target, ast.Name):
147
+ if self._is_mutable_value(item.value):
148
+ line_no = item.lineno
149
+ snippet = self._get_snippet(lines, line_no)
150
+ findings.append(Finding(
151
+ engine=self.name,
152
+ severity="medium",
153
+ title=f"Class-level mutable attribute: {node.name}.{target.id}",
154
+ description=f"Class '{node.name}' has mutable class attribute '{target.id}' ({self._get_type_name(item.value)}) - shared across all instances",
155
+ location=f"{path}:{line_no}",
156
+ metadata={
157
+ "confidence": 0.9,
158
+ "snippet": snippet,
159
+ "class": node.name,
160
+ "attribute": target.id,
161
+ "type": self._get_type_name(item.value),
162
+ "issue": "class_mutable_attribute",
163
+ "cwe": "CWE-488",
164
+ "cvss": 4.0,
165
+ },
166
+ ))
167
+ return findings
168
+
169
+ def _check_global_usage(self, node: ast.Global, lines: list, path: str):
170
+ findings = []
171
+ for name in node.names:
172
+ line_no = node.lineno
173
+ snippet = self._get_snippet(lines, line_no)
174
+ findings.append(Finding(
175
+ engine=self.name,
176
+ severity="low",
177
+ title=f"Global variable declaration: {name}",
178
+ description=f"Explicit 'global {name}' declaration - check for mutable state",
179
+ location=f"{path}:{line_no}",
180
+ metadata={
181
+ "confidence": 0.7,
182
+ "snippet": snippet,
183
+ "variable": name,
184
+ "issue": "global_declaration",
185
+ "cwe": "CWE-488",
186
+ "cvss": 3.0,
187
+ },
188
+ ))
189
+ return findings
190
+
191
+ def _check_nonlocal_usage(self, node: ast.Nonlocal, lines: list, path: str):
192
+ findings = []
193
+ for name in node.names:
194
+ line_no = node.lineno
195
+ snippet = self._get_snippet(lines, line_no)
196
+ findings.append(Finding(
197
+ engine=self.name,
198
+ severity="low",
199
+ title=f"Nonlocal variable declaration: {name}",
200
+ description=f"Explicit 'nonlocal {name}' declaration - check for mutable state in closure",
201
+ location=f"{path}:{line_no}",
202
+ metadata={
203
+ "confidence": 0.7,
204
+ "snippet": snippet,
205
+ "variable": name,
206
+ "issue": "nonlocal_declaration",
207
+ "cwe": "CWE-488",
208
+ "cvss": 3.0,
209
+ },
210
+ ))
211
+ return findings
212
+
213
+ def _get_snippet(self, lines: list, line_no: int, context: int = 2) -> str:
214
+ if line_no <= 0:
215
+ return ""
216
+ start = max(0, line_no - context - 1)
217
+ end = min(len(lines), line_no + context)
218
+ return "\n".join(lines[start:end])
@@ -0,0 +1,290 @@
1
+ """SBOM generation engine - creates CycloneDX-like SBOM from dependencies."""
2
+ import json
3
+ import hashlib
4
+ import re
5
+ import subprocess
6
+ import tomllib
7
+ from pathlib import Path
8
+ from orion.engine.config import EngineConfig
9
+ from orion.engine.ir import OrionIR
10
+ from orion.engine.results import EngineResult, Finding
11
+
12
+
13
+ class Engine:
14
+ name = "supply_chain_sbom"
15
+ version = "1.0.0"
16
+
17
+ DEP_FILES = {
18
+ "requirements.txt", "requirements-dev.txt", "requirements-test.txt",
19
+ "pyproject.toml", "setup.py", "setup.cfg",
20
+ "Pipfile", "Pipfile.lock",
21
+ "poetry.lock", "pnpm-lock.yaml", "yarn.lock", "package-lock.json",
22
+ "package.json", "go.mod", "go.sum",
23
+ "Cargo.toml", "Cargo.lock",
24
+ "pom.xml", "build.gradle", "build.gradle.kts",
25
+ "composer.json", "composer.lock",
26
+ }
27
+
28
+ async def check_availability(self) -> bool:
29
+ return True
30
+
31
+ def resource_limits(self):
32
+ from orion.engine.config import ResourceLimits
33
+ return ResourceLimits(max_cpu_seconds=120, max_memory_mb=512)
34
+
35
+ async def run(self, ir: OrionIR, config: EngineConfig) -> EngineResult:
36
+ path = ir.metadata.get("path", "unknown")
37
+ project_root = ir.metadata.get("project_root", ".")
38
+
39
+ deps = self._get_dependencies(ir, project_root)
40
+ if not deps:
41
+ return EngineResult(engine=self.name, findings=[], ok=True, metrics={"sbom": {"components": []}})
42
+
43
+ sbom = self._generate_sbom(deps, project_root)
44
+
45
+ return EngineResult(
46
+ engine=self.name,
47
+ findings=[],
48
+ ok=True,
49
+ metrics={"sbom": sbom},
50
+ )
51
+
52
+ def _get_dependencies(self, ir: OrionIR, project_root: str) -> dict[str, str]:
53
+ deps = {}
54
+
55
+ if ir.metadata.get("metrics", {}).get("dependencies"):
56
+ deps.update(ir.metadata["metrics"]["dependencies"])
57
+
58
+ if not deps:
59
+ deps = self._parse_dep_files(project_root)
60
+
61
+ return deps
62
+
63
+ def _parse_dep_files(self, project_root: str) -> dict[str, str]:
64
+ deps = {}
65
+ root_path = Path(project_root)
66
+
67
+ for dep_file in self.DEP_FILES:
68
+ for found in root_path.rglob(dep_file):
69
+ try:
70
+ text = found.read_text(encoding="utf-8", errors="replace")
71
+ parsed = self._parse_dep_file(text, found.name)
72
+ deps.update(parsed)
73
+ except Exception:
74
+ pass
75
+
76
+ return deps
77
+
78
+ def _parse_dep_file(self, text: str, filename: str) -> dict[str, str]:
79
+ deps = {}
80
+ filename_lower = filename.lower()
81
+
82
+ if filename_lower in ("requirements.txt",) or filename_lower.startswith("requirements-") and filename_lower.endswith(".txt"):
83
+ deps = self._parse_requirements(text)
84
+ elif filename_lower == "pyproject.toml":
85
+ deps = self._parse_pyproject_toml(text)
86
+ elif filename_lower == "pipfile":
87
+ deps = self._parse_pipfile(text)
88
+ elif filename_lower == "poetry.lock":
89
+ deps = self._parse_poetry_lock(text)
90
+ elif filename_lower == "package.json":
91
+ deps = self._parse_package_json(text)
92
+ elif filename_lower in ("pnpm-lock.yaml", "yarn.lock", "package-lock.json"):
93
+ deps = self._parse_lock_file(text, filename_lower)
94
+ elif filename_lower == "go.mod":
95
+ deps = self._parse_go_mod(text)
96
+ elif filename_lower == "cargo.toml":
97
+ deps = self._parse_cargo_toml(text)
98
+
99
+ return deps
100
+
101
+ def _parse_requirements(self, text: str) -> dict[str, str]:
102
+ deps = {}
103
+ for line in text.splitlines():
104
+ line = line.strip()
105
+ if not line or line.startswith("#") or line.startswith("-"):
106
+ continue
107
+ match = re.match(r"^([A-Za-z0-9_\-\.]+)(?:[=<>!~]+([A-Za-z0-9_\-\.]+))?", line)
108
+ if match:
109
+ name = match.group(1).lower()
110
+ version = match.group(2) or "unknown"
111
+ deps[name] = version
112
+ return deps
113
+
114
+ def _parse_pyproject_toml(self, text: str) -> dict[str, str]:
115
+ deps = {}
116
+ try:
117
+ data = tomllib.loads(text)
118
+ for section in ("project", "tool.poetry"):
119
+ if section in data:
120
+ dep_section = data[section].get("dependencies", {})
121
+ for name, spec in dep_section.items():
122
+ if name.lower() != "python":
123
+ version = spec if isinstance(spec, str) else spec.get("version", "unknown")
124
+ deps[name.lower()] = version
125
+ except Exception:
126
+ pass
127
+ return deps
128
+
129
+ def _parse_pipfile(self, text: str) -> dict[str, str]:
130
+ deps = {}
131
+ current_section = None
132
+ for line in text.splitlines():
133
+ line = line.strip()
134
+ if line.startswith("[") and line.endswith("]"):
135
+ current_section = line[1:-1].lower()
136
+ continue
137
+ if current_section in ("packages", "dev-packages") and "=" in line:
138
+ parts = line.split("=", 1)
139
+ name = parts[0].strip().lower()
140
+ version = parts[1].strip().strip('"\'')
141
+ deps[name] = version
142
+ return deps
143
+
144
+ def _parse_poetry_lock(self, text: str) -> dict[str, str]:
145
+ deps = {}
146
+ try:
147
+ data = tomllib.loads(text)
148
+ for pkg in data.get("package", []):
149
+ deps[pkg["name"].lower()] = pkg["version"]
150
+ except Exception:
151
+ pass
152
+ return deps
153
+
154
+ def _parse_package_json(self, text: str) -> dict[str, str]:
155
+ deps = {}
156
+ try:
157
+ data = json.loads(text)
158
+ for section in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
159
+ for name, version in data.get(section, {}).items():
160
+ deps[name.lower()] = version.lstrip("^~>=")
161
+ except Exception:
162
+ pass
163
+ return deps
164
+
165
+ def _parse_lock_file(self, text: str, filename: str) -> dict[str, str]:
166
+ deps = {}
167
+ try:
168
+ if filename == "package-lock.json":
169
+ data = json.loads(text)
170
+ for name, info in data.get("packages", {}).items():
171
+ if name and name != "":
172
+ clean_name = name.split("node_modules/")[-1].lower()
173
+ deps[clean_name] = info.get("version", "unknown")
174
+ elif filename == "pnpm-lock.yaml":
175
+ for line in text.splitlines():
176
+ match = re.match(r"^\s+([^:]+):\s+\"([^\"]+)\"", line)
177
+ if match and not match.group(1).startswith(" "):
178
+ deps[match.group(1).lower()] = match.group(2)
179
+ elif filename == "yarn.lock":
180
+ current_name = None
181
+ for line in text.splitlines():
182
+ match = re.match(r'^"([^@]+)@[^"]+":', line)
183
+ if match:
184
+ current_name = match.group(1).lower()
185
+ elif current_name and "version" in line:
186
+ version_match = re.search(r'version\s+"([^"]+)"', line)
187
+ if version_match:
188
+ deps[current_name] = version_match.group(1)
189
+ current_name = None
190
+ except Exception:
191
+ pass
192
+ return deps
193
+
194
+ def _parse_go_mod(self, text: str) -> dict[str, str]:
195
+ deps = {}
196
+ for line in text.splitlines():
197
+ line = line.strip()
198
+ if line.startswith("require "):
199
+ parts = line.split()
200
+ if len(parts) >= 3:
201
+ deps[parts[1].lower()] = parts[2]
202
+ return deps
203
+
204
+ def _parse_cargo_toml(self, text: str) -> dict[str, str]:
205
+ deps = {}
206
+ try:
207
+ data = tomllib.loads(text)
208
+ for section in ("dependencies", "dev-dependencies"):
209
+ for name, spec in data.get(section, {}).items():
210
+ version = spec if isinstance(spec, str) else spec.get("version", "unknown")
211
+ deps[name.lower()] = version
212
+ except Exception:
213
+ pass
214
+ return deps
215
+
216
+ def _generate_sbom(self, deps: dict[str, str], project_root: str) -> dict:
217
+ components = []
218
+
219
+ for name, version in sorted(deps.items()):
220
+ comp = {
221
+ "name": name,
222
+ "version": version,
223
+ "purl": self._generate_purl(name, version),
224
+ "hashes": self._fetch_hashes(name, version),
225
+ "licenses": self._fetch_license(name, version),
226
+ "externalReferences": self._fetch_references(name),
227
+ }
228
+ components.append(comp)
229
+
230
+ return {
231
+ "bomFormat": "CycloneDX",
232
+ "specVersion": "1.5",
233
+ "serialNumber": f"urn:uuid:{hashlib.sha256(str(sorted(deps.items())).encode()).hexdigest()[:32]}",
234
+ "version": 1,
235
+ "components": components,
236
+ }
237
+
238
+ def _generate_purl(self, name: str, version: str) -> str:
239
+ if re.match(r"^[a-z0-9_\-]+$", name.lower()):
240
+ return f"pkg:pypi/{name.lower()}@{version}"
241
+ elif "/" in name:
242
+ return f"pkg:npm/{name}@{version}"
243
+ else:
244
+ return f"pkg:generic/{name}@{version}"
245
+
246
+ def _fetch_hashes(self, name: str, version: str) -> dict[str, str]:
247
+ try:
248
+ result = subprocess.run(
249
+ ["pip", "download", "--no-deps", "-d", "/tmp", f"{name}=={version}"],
250
+ capture_output=True, text=True, timeout=30
251
+ )
252
+ if result.returncode == 0:
253
+ for line in result.stdout.splitlines():
254
+ if ".whl" in line or ".tar.gz" in line:
255
+ filepath = line.strip()
256
+ sha256 = self._file_sha256(filepath)
257
+ if sha256:
258
+ return {"sha256": sha256}
259
+ except Exception:
260
+ pass
261
+ return {}
262
+
263
+ def _file_sha256(self, filepath: str) -> str | None:
264
+ try:
265
+ with open(filepath, "rb") as f:
266
+ return hashlib.sha256(f.read()).hexdigest()
267
+ except Exception:
268
+ return None
269
+
270
+ def _fetch_license(self, name: str, version: str) -> list[dict]:
271
+ try:
272
+ result = subprocess.run(
273
+ ["pip", "show", f"{name}=={version}"],
274
+ capture_output=True, text=True, timeout=10
275
+ )
276
+ if result.returncode == 0:
277
+ for line in result.stdout.splitlines():
278
+ if line.startswith("License:"):
279
+ license_str = line.split(":", 1)[1].strip()
280
+ return [{"license": {"id": license_str}}]
281
+ except Exception:
282
+ pass
283
+ return [{"license": {"id": "UNKNOWN"}}]
284
+
285
+ def _fetch_references(self, name: str) -> list[dict]:
286
+ refs = []
287
+ if re.match(r"^[a-z0-9_\-]+$", name.lower()):
288
+ refs.append({"type": "website", "url": f"https://pypi.org/project/{name}"})
289
+ refs.append({"type": "repository", "url": f"https://github.com/pypi/{name}"})
290
+ return refs