@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,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
|
|
Binary file
|
|
@@ -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
|
|
Binary file
|