universal-dev-security 1.0.0__py3-none-any.whl
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.
- universal_dev_security/__init__.py +3 -0
- universal_dev_security/__main__.py +4 -0
- universal_dev_security/cli.py +376 -0
- universal_dev_security/cli_extra.py +244 -0
- universal_dev_security/config.py +171 -0
- universal_dev_security/detector.py +201 -0
- universal_dev_security/engine.py +203 -0
- universal_dev_security/findings.py +199 -0
- universal_dev_security/fixes.py +138 -0
- universal_dev_security/gitguard.py +269 -0
- universal_dev_security/history.py +149 -0
- universal_dev_security/initializer.py +112 -0
- universal_dev_security/integrations/__init__.py +46 -0
- universal_dev_security/integrations/agents.py +45 -0
- universal_dev_security/integrations/claude_code.py +192 -0
- universal_dev_security/integrations/github.py +149 -0
- universal_dev_security/integrations/vscode.py +44 -0
- universal_dev_security/policy.py +384 -0
- universal_dev_security/projectfiles.py +80 -0
- universal_dev_security/recovery.py +168 -0
- universal_dev_security/redaction.py +132 -0
- universal_dev_security/reports.py +466 -0
- universal_dev_security/scanner.py +36 -0
- universal_dev_security/scanners/__init__.py +36 -0
- universal_dev_security/scanners/base.py +269 -0
- universal_dev_security/scanners/gitleaks.py +132 -0
- universal_dev_security/scanners/osv.py +156 -0
- universal_dev_security/scanners/semgrep.py +130 -0
- universal_dev_security/scanners/trivy.py +139 -0
- universal_dev_security/testing.py +100 -0
- universal_dev_security/updates.py +84 -0
- universal_dev_security-1.0.0.dist-info/METADATA +128 -0
- universal_dev_security-1.0.0.dist-info/RECORD +37 -0
- universal_dev_security-1.0.0.dist-info/WHEEL +5 -0
- universal_dev_security-1.0.0.dist-info/entry_points.txt +2 -0
- universal_dev_security-1.0.0.dist-info/licenses/LICENSE +163 -0
- universal_dev_security-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Common scanner adapter contract and safe process execution."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from universal_dev_security.findings import Category, Finding, ScanResult, ScanStatus
|
|
16
|
+
from universal_dev_security.projectfiles import DEFAULT_EXCLUDED_DIRS, ProjectInventory
|
|
17
|
+
|
|
18
|
+
# Batch wrappers are executed through cmd.exe on Windows, where argument
|
|
19
|
+
# quoting cannot be made fully safe (see CVE-2024-24576 class of issues).
|
|
20
|
+
UNSAFE_WINDOWS_SUFFIXES = (".bat", ".cmd")
|
|
21
|
+
VERSION_RE = re.compile(r"(\d+)\.(\d+)(?:\.(\d+))?")
|
|
22
|
+
MAX_OUTPUT_BYTES = 200 * 1024 * 1024
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ScannerExecutionError(RuntimeError):
|
|
26
|
+
"""Raised for scanner failures. Messages never contain raw scanner output."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ScanOptions:
|
|
31
|
+
timeout_seconds: int = 600
|
|
32
|
+
allow_network: bool = False
|
|
33
|
+
semgrep_configs: list[str] = field(default_factory=list)
|
|
34
|
+
exclude_paths: list[str] = field(default_factory=list)
|
|
35
|
+
technologies: list[str] = field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def excluded_dirs(self) -> list[str]:
|
|
39
|
+
return sorted(set(DEFAULT_EXCLUDED_DIRS) | {p.strip("/\\") for p in self.exclude_paths if p.strip("/\\")})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_version(text: str | None) -> tuple[int, int, int] | None:
|
|
43
|
+
if not text:
|
|
44
|
+
return None
|
|
45
|
+
match = VERSION_RE.search(text)
|
|
46
|
+
if not match:
|
|
47
|
+
return None
|
|
48
|
+
return int(match.group(1)), int(match.group(2)), int(match.group(3) or 0)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def format_version(version: tuple[int, int, int] | None) -> str | None:
|
|
52
|
+
return ".".join(map(str, version)) if version else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_safe_executable(path: str) -> bool:
|
|
56
|
+
if os.name == "nt" and path.lower().endswith(UNSAFE_WINDOWS_SUFFIXES):
|
|
57
|
+
return False
|
|
58
|
+
return Path(path).is_file()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run_tool(
|
|
62
|
+
args: list[str],
|
|
63
|
+
*,
|
|
64
|
+
cwd: Path,
|
|
65
|
+
timeout: int,
|
|
66
|
+
env_overrides: dict[str, str] | None = None,
|
|
67
|
+
) -> subprocess.CompletedProcess[str]:
|
|
68
|
+
"""Run a scanner safely: argument list, no shell, no stdin, bounded time.
|
|
69
|
+
|
|
70
|
+
Callers must never print or persist ``stdout``/``stderr`` verbatim because
|
|
71
|
+
scanners may echo secret material.
|
|
72
|
+
"""
|
|
73
|
+
if not args or not all(isinstance(arg, str) for arg in args):
|
|
74
|
+
raise ScannerExecutionError("Invalid scanner command")
|
|
75
|
+
if any("\x00" in arg for arg in args):
|
|
76
|
+
raise ScannerExecutionError("Scanner arguments may not contain NUL bytes")
|
|
77
|
+
if not _is_safe_executable(args[0]):
|
|
78
|
+
raise ScannerExecutionError("Refusing to execute a missing or batch-file scanner executable")
|
|
79
|
+
env = dict(os.environ)
|
|
80
|
+
env.update(env_overrides or {})
|
|
81
|
+
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0
|
|
82
|
+
return subprocess.run( # noqa: S603 - argument list, shell=False, validated executable
|
|
83
|
+
args,
|
|
84
|
+
cwd=str(cwd),
|
|
85
|
+
stdin=subprocess.DEVNULL,
|
|
86
|
+
capture_output=True,
|
|
87
|
+
text=True,
|
|
88
|
+
encoding="utf-8",
|
|
89
|
+
errors="replace",
|
|
90
|
+
timeout=timeout,
|
|
91
|
+
check=False,
|
|
92
|
+
shell=False,
|
|
93
|
+
env=env,
|
|
94
|
+
creationflags=creationflags,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _candidate_dirs(tool: str) -> list[Path]:
|
|
99
|
+
"""Well-known per-user install locations, checked after PATH."""
|
|
100
|
+
home = Path.home()
|
|
101
|
+
dirs = [Path(sys.executable).parent] # same virtual environment as UDS (pip-installed semgrep)
|
|
102
|
+
if os.name == "nt":
|
|
103
|
+
local = os.environ.get("LOCALAPPDATA")
|
|
104
|
+
if local:
|
|
105
|
+
winget = Path(local) / "Microsoft" / "WinGet"
|
|
106
|
+
dirs.append(winget / "Links")
|
|
107
|
+
patterns = {
|
|
108
|
+
"gitleaks": "Gitleaks.Gitleaks_*",
|
|
109
|
+
"trivy": "AquaSecurity.Trivy_*",
|
|
110
|
+
"osv-scanner": "Google.OSVScanner_*",
|
|
111
|
+
}
|
|
112
|
+
if tool in patterns and (winget / "Packages").is_dir():
|
|
113
|
+
dirs.extend(sorted((winget / "Packages").glob(patterns[tool])))
|
|
114
|
+
dirs.append(home / "scoop" / "shims")
|
|
115
|
+
else:
|
|
116
|
+
dirs.extend([Path("/opt/homebrew/bin"), Path("/usr/local/bin"), home / ".local" / "bin"])
|
|
117
|
+
dirs.append(home / "go" / "bin")
|
|
118
|
+
return dirs
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def find_executable(tool: str, names: tuple[str, ...]) -> tuple[str | None, str | None]:
|
|
122
|
+
"""Return (path, note). ``note`` explains refused candidates."""
|
|
123
|
+
override = os.environ.get(f"UDS_{tool.upper().replace('-', '_')}_PATH")
|
|
124
|
+
if override:
|
|
125
|
+
if _is_safe_executable(override):
|
|
126
|
+
return str(Path(override).resolve()), None
|
|
127
|
+
return None, f"UDS_{tool.upper().replace('-', '_')}_PATH does not point to a usable executable."
|
|
128
|
+
refused = None
|
|
129
|
+
for name in names:
|
|
130
|
+
found = shutil.which(name)
|
|
131
|
+
if found:
|
|
132
|
+
if _is_safe_executable(found):
|
|
133
|
+
return found, None
|
|
134
|
+
refused = found
|
|
135
|
+
suffixes = (".exe",) if os.name == "nt" else ("",)
|
|
136
|
+
for directory in _candidate_dirs(tool):
|
|
137
|
+
for name in names:
|
|
138
|
+
for suffix in suffixes:
|
|
139
|
+
candidate = directory / f"{name}{suffix}"
|
|
140
|
+
if candidate.is_file() and _is_safe_executable(str(candidate)):
|
|
141
|
+
return str(candidate), None
|
|
142
|
+
if refused:
|
|
143
|
+
return None, "Only a batch-file wrapper was found; UDS refuses to run .bat/.cmd scanners. Install the native executable."
|
|
144
|
+
return None, None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def relative_path(value: Any, root: Path) -> str:
|
|
148
|
+
"""Normalize a scanner-reported path to a project-relative posix path."""
|
|
149
|
+
text = str(value or "").strip()
|
|
150
|
+
if not text:
|
|
151
|
+
return ""
|
|
152
|
+
candidate = Path(text)
|
|
153
|
+
if candidate.is_absolute():
|
|
154
|
+
try:
|
|
155
|
+
return candidate.resolve().relative_to(root.resolve()).as_posix()
|
|
156
|
+
except (ValueError, OSError):
|
|
157
|
+
return candidate.name # never leak paths outside the project
|
|
158
|
+
normalized = text.replace("\\", "/")
|
|
159
|
+
while normalized.startswith("./"):
|
|
160
|
+
normalized = normalized[2:]
|
|
161
|
+
return normalized
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class ScannerAdapter(ABC):
|
|
165
|
+
name: str = ""
|
|
166
|
+
display_name: str = ""
|
|
167
|
+
category: Category = Category.CODE
|
|
168
|
+
executable_names: tuple[str, ...] = ()
|
|
169
|
+
min_version: tuple[int, int, int] = (0, 0, 0)
|
|
170
|
+
version_args: tuple[str, ...] = ("--version",)
|
|
171
|
+
# Privacy properties surfaced in reports and used by the scan policy.
|
|
172
|
+
transmits_project_metadata: bool = False
|
|
173
|
+
downloads_data: bool = False
|
|
174
|
+
network_note: str = ""
|
|
175
|
+
docs_url: str = ""
|
|
176
|
+
install_hints: dict[str, str] = {}
|
|
177
|
+
|
|
178
|
+
# -- discovery ------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def locate(self) -> tuple[str | None, str | None]:
|
|
181
|
+
return find_executable(self.name, self.executable_names)
|
|
182
|
+
|
|
183
|
+
def detect_version(self, executable: str, cwd: Path) -> str | None:
|
|
184
|
+
try:
|
|
185
|
+
completed = run_tool([executable, *self.version_args], cwd=cwd, timeout=60)
|
|
186
|
+
except (OSError, subprocess.SubprocessError, ScannerExecutionError):
|
|
187
|
+
return None
|
|
188
|
+
return format_version(parse_version(completed.stdout) or parse_version(completed.stderr))
|
|
189
|
+
|
|
190
|
+
def is_compatible(self, version: str | None) -> bool:
|
|
191
|
+
parsed = parse_version(version)
|
|
192
|
+
return parsed is not None and parsed >= self.min_version
|
|
193
|
+
|
|
194
|
+
def info(self, cwd: Path | None = None) -> dict[str, Any]:
|
|
195
|
+
executable, note = self.locate()
|
|
196
|
+
version = self.detect_version(executable, cwd or Path.cwd()) if executable else None
|
|
197
|
+
return {
|
|
198
|
+
"name": self.name,
|
|
199
|
+
"display_name": self.display_name,
|
|
200
|
+
"installed": executable is not None,
|
|
201
|
+
"executable": executable,
|
|
202
|
+
"version": version,
|
|
203
|
+
"minimum_version": format_version(self.min_version),
|
|
204
|
+
"compatible": self.is_compatible(version) if executable else None,
|
|
205
|
+
"note": note,
|
|
206
|
+
"transmits_project_metadata": self.transmits_project_metadata,
|
|
207
|
+
"downloads_data": self.downloads_data,
|
|
208
|
+
"network_note": self.network_note,
|
|
209
|
+
"docs_url": self.docs_url,
|
|
210
|
+
"install_hints": self.install_hints,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
# -- execution ------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
@abstractmethod
|
|
216
|
+
def applicable(self, inventory: ProjectInventory) -> tuple[bool, str]:
|
|
217
|
+
"""Return (applicable, reason)."""
|
|
218
|
+
|
|
219
|
+
@abstractmethod
|
|
220
|
+
def execute(self, executable: str, version: str, target: Path, options: ScanOptions) -> list[Finding]:
|
|
221
|
+
"""Run the scanner and return normalized findings, or raise ScannerExecutionError."""
|
|
222
|
+
|
|
223
|
+
def scan(self, target: Path, inventory: ProjectInventory, options: ScanOptions) -> ScanResult:
|
|
224
|
+
started = time.monotonic()
|
|
225
|
+
root = target.expanduser().resolve()
|
|
226
|
+
|
|
227
|
+
def result(status: ScanStatus, message: str, reason: str | None = None, version: str | None = None,
|
|
228
|
+
findings: list[Finding] | None = None) -> ScanResult:
|
|
229
|
+
return ScanResult(
|
|
230
|
+
tool=self.name, status=status, findings=findings or [], version=version, message=message,
|
|
231
|
+
reason=reason, duration_seconds=round(time.monotonic() - started, 3),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
applicable, why = self.applicable(inventory)
|
|
235
|
+
if not applicable:
|
|
236
|
+
return result(ScanStatus.NOT_APPLICABLE, why, "not_applicable")
|
|
237
|
+
executable, note = self.locate()
|
|
238
|
+
if not executable:
|
|
239
|
+
hint = note or f"{self.display_name} was not found. See {self.docs_url} for official installation."
|
|
240
|
+
return result(ScanStatus.NOT_INSTALLED, hint, "executable_not_found")
|
|
241
|
+
version = self.detect_version(executable, root)
|
|
242
|
+
if not self.is_compatible(version):
|
|
243
|
+
return result(
|
|
244
|
+
ScanStatus.SKIPPED,
|
|
245
|
+
f"{self.display_name} version {version or 'unknown'} is not supported; "
|
|
246
|
+
f"{format_version(self.min_version)} or newer is required.",
|
|
247
|
+
"unsupported_version", version,
|
|
248
|
+
)
|
|
249
|
+
if self.transmits_project_metadata and not options.allow_network:
|
|
250
|
+
return result(
|
|
251
|
+
ScanStatus.SKIPPED,
|
|
252
|
+
f"{self.display_name} was not run because it {self.network_note} "
|
|
253
|
+
"Enable it with `uds scan --allow-network` or `[scanners] allow_network = true`.",
|
|
254
|
+
"network_not_approved", version,
|
|
255
|
+
)
|
|
256
|
+
try:
|
|
257
|
+
findings = self.execute(executable, version or "", root, options)
|
|
258
|
+
except subprocess.TimeoutExpired:
|
|
259
|
+
return result(ScanStatus.ERROR, f"{self.display_name} timed out after {options.timeout_seconds}s.",
|
|
260
|
+
"timeout", version)
|
|
261
|
+
except ScannerExecutionError as exc:
|
|
262
|
+
return result(ScanStatus.ERROR, str(exc), "execution_failed", version)
|
|
263
|
+
except OSError as exc:
|
|
264
|
+
return result(ScanStatus.ERROR, f"{self.display_name} could not be started ({type(exc).__name__}).",
|
|
265
|
+
"execution_failed", version)
|
|
266
|
+
if findings:
|
|
267
|
+
return result(ScanStatus.FINDINGS, f"{len(findings)} finding(s) reported by {self.display_name}.",
|
|
268
|
+
version=version, findings=findings)
|
|
269
|
+
return result(ScanStatus.CLEAN, f"No findings reported by {self.display_name}.", version=version)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Gitleaks adapter: secret detection (https://github.com/gitleaks/gitleaks)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from universal_dev_security.findings import Category, Finding, Severity
|
|
11
|
+
from universal_dev_security.projectfiles import ProjectInventory
|
|
12
|
+
from universal_dev_security.scanners.base import (
|
|
13
|
+
ScannerAdapter,
|
|
14
|
+
ScannerExecutionError,
|
|
15
|
+
ScanOptions,
|
|
16
|
+
relative_path,
|
|
17
|
+
run_tool,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Third-party and UDS-generated content only. Build output (dist/, build/) is
|
|
21
|
+
# deliberately still scanned: bundles frequently leak credentials.
|
|
22
|
+
SECRET_SCAN_EXCLUDES = (
|
|
23
|
+
".venv", "venv", "node_modules", "bower_components", "site-packages", ".tox", ".nox", "__pycache__",
|
|
24
|
+
".mypy_cache", ".pytest_cache", ".ruff_cache", ".terraform", ".git",
|
|
25
|
+
# Framework caches and dev-server builds: never deployed, extremely noisy (found when
|
|
26
|
+
# scanning a real Next.js project: 97% of findings). Deployable output such as
|
|
27
|
+
# .next/static and .next/server is still scanned.
|
|
28
|
+
".next/cache", ".next/dev", ".turbo", ".parcel-cache", ".angular/cache",
|
|
29
|
+
)
|
|
30
|
+
UDS_GENERATED = (".uds/reports", ".uds/history", ".uds/backups", ".uds/cache")
|
|
31
|
+
|
|
32
|
+
REMEDIATION = (
|
|
33
|
+
"Treat the credential as compromised: revoke or rotate it with the issuing service, remove it from the "
|
|
34
|
+
"code, load it from an environment variable or secret manager, and if it was committed, purge it from "
|
|
35
|
+
"Git history with an approved history-rewrite procedure."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_exclusion_config(extra_excludes: list[str]) -> str:
|
|
40
|
+
names = list(SECRET_SCAN_EXCLUDES) + [e.strip("/\\") for e in extra_excludes if e.strip("/\\")]
|
|
41
|
+
parts = [re.escape(name).replace("/", r"[\\/]") for name in names]
|
|
42
|
+
parts += [re.escape(p).replace("/", r"[\\/]") for p in UDS_GENERATED]
|
|
43
|
+
path_regex = r"(?:^|[\\/])(?:" + "|".join(parts) + r")(?:[\\/]|$)"
|
|
44
|
+
if "'''" in path_regex: # cannot happen with re.escape output, but never emit broken TOML
|
|
45
|
+
raise ScannerExecutionError("Invalid exclusion pattern")
|
|
46
|
+
return (
|
|
47
|
+
'title = "UDS default exclusions"\n\n'
|
|
48
|
+
"[extend]\nuseDefault = true\n\n"
|
|
49
|
+
"[allowlist]\n"
|
|
50
|
+
'description = "Third-party and UDS-generated directories"\n'
|
|
51
|
+
f"paths = ['''{path_regex}''']\n"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def normalize(raw: list[dict[str, Any]], root: Path) -> list[Finding]:
|
|
56
|
+
findings = []
|
|
57
|
+
for item in raw:
|
|
58
|
+
if not isinstance(item, dict):
|
|
59
|
+
continue
|
|
60
|
+
rule = str(item.get("RuleID") or "unknown")[:200]
|
|
61
|
+
# Only metadata is copied. Secret, Match, Line and Entropy are never read.
|
|
62
|
+
findings.append(Finding(
|
|
63
|
+
tool="gitleaks",
|
|
64
|
+
category=Category.SECRET,
|
|
65
|
+
rule_id=rule,
|
|
66
|
+
title=f"Potential secret: {rule}",
|
|
67
|
+
message=str(item.get("Description") or "")[:500],
|
|
68
|
+
file=relative_path(item.get("File"), root),
|
|
69
|
+
start_line=item.get("StartLine"),
|
|
70
|
+
end_line=item.get("EndLine"),
|
|
71
|
+
commit=str(item.get("Commit") or "")[:64] or None,
|
|
72
|
+
severity=Severity.UNCLASSIFIED,
|
|
73
|
+
severity_source="none",
|
|
74
|
+
remediation=REMEDIATION,
|
|
75
|
+
references=["https://github.com/gitleaks/gitleaks"],
|
|
76
|
+
))
|
|
77
|
+
return findings
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class GitleaksAdapter(ScannerAdapter):
|
|
81
|
+
name = "gitleaks"
|
|
82
|
+
display_name = "Gitleaks"
|
|
83
|
+
category = Category.SECRET
|
|
84
|
+
executable_names = ("gitleaks",)
|
|
85
|
+
min_version = (8, 19, 0) # `gitleaks dir` was introduced in 8.19.0
|
|
86
|
+
version_args = ("version",)
|
|
87
|
+
network_note = "runs fully offline."
|
|
88
|
+
docs_url = "https://github.com/gitleaks/gitleaks#installing"
|
|
89
|
+
install_hints = {
|
|
90
|
+
"windows": "winget install --id Gitleaks.Gitleaks -e",
|
|
91
|
+
"macos": "brew install gitleaks",
|
|
92
|
+
"linux": "Download a release from https://github.com/gitleaks/gitleaks/releases and verify its checksum",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
def applicable(self, inventory: ProjectInventory) -> tuple[bool, str]:
|
|
96
|
+
return True, "Secret scanning applies to every project."
|
|
97
|
+
|
|
98
|
+
def build_command(self, executable: str, report: Path, config: Path | None) -> list[str]:
|
|
99
|
+
command = [executable, "dir", "--no-banner", "--redact", "--exit-code", "1",
|
|
100
|
+
"--report-format", "json", "--report-path", str(report)]
|
|
101
|
+
if config is not None:
|
|
102
|
+
command += ["--config", str(config)]
|
|
103
|
+
return command + ["."]
|
|
104
|
+
|
|
105
|
+
def execute(self, executable: str, version: str, target: Path, options: ScanOptions) -> list[Finding]:
|
|
106
|
+
with tempfile.TemporaryDirectory(prefix="uds-gitleaks-") as temp_dir:
|
|
107
|
+
report = Path(temp_dir) / "gitleaks-report.json"
|
|
108
|
+
config: Path | None = None
|
|
109
|
+
# Respect a project-provided Gitleaks configuration instead of overriding it.
|
|
110
|
+
if not (target / ".gitleaks.toml").is_file():
|
|
111
|
+
config = Path(temp_dir) / "uds-gitleaks.toml"
|
|
112
|
+
config.write_text(build_exclusion_config(options.exclude_paths), encoding="utf-8")
|
|
113
|
+
completed = run_tool(self.build_command(executable, report, config), cwd=target,
|
|
114
|
+
timeout=options.timeout_seconds)
|
|
115
|
+
if completed.returncode not in (0, 1):
|
|
116
|
+
raise ScannerExecutionError(
|
|
117
|
+
f"Gitleaks exited with code {completed.returncode}; raw output was suppressed."
|
|
118
|
+
)
|
|
119
|
+
if not report.is_file():
|
|
120
|
+
if completed.returncode == 0:
|
|
121
|
+
return []
|
|
122
|
+
raise ScannerExecutionError("Gitleaks reported leaks but produced no report file.")
|
|
123
|
+
try:
|
|
124
|
+
data = json.loads(report.read_text(encoding="utf-8") or "[]")
|
|
125
|
+
except (OSError, ValueError, UnicodeDecodeError) as exc:
|
|
126
|
+
raise ScannerExecutionError("Gitleaks report could not be parsed; raw output was suppressed.") from exc
|
|
127
|
+
if not isinstance(data, list):
|
|
128
|
+
raise ScannerExecutionError("Gitleaks report has an unexpected format.")
|
|
129
|
+
findings = normalize(data, target)
|
|
130
|
+
if completed.returncode == 1 and not findings:
|
|
131
|
+
raise ScannerExecutionError("Gitleaks signalled leaks but the report contained none.")
|
|
132
|
+
return findings
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""OSV-Scanner adapter: dependency vulnerabilities (https://google.github.io/osv-scanner/)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from universal_dev_security.detector import has_dependency_manifests
|
|
9
|
+
from universal_dev_security.findings import Category, Finding, Severity, cvss_score_to_severity
|
|
10
|
+
from universal_dev_security.projectfiles import ProjectInventory
|
|
11
|
+
from universal_dev_security.scanners.base import (
|
|
12
|
+
ScannerAdapter,
|
|
13
|
+
ScannerExecutionError,
|
|
14
|
+
ScanOptions,
|
|
15
|
+
parse_version,
|
|
16
|
+
relative_path,
|
|
17
|
+
run_tool,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# GitHub Advisory Database severities, used only when no CVSS score is present.
|
|
21
|
+
ADVISORY_SEVERITY = {
|
|
22
|
+
"CRITICAL": Severity.CRITICAL,
|
|
23
|
+
"HIGH": Severity.HIGH,
|
|
24
|
+
"MODERATE": Severity.MEDIUM,
|
|
25
|
+
"MEDIUM": Severity.MEDIUM,
|
|
26
|
+
"LOW": Severity.LOW,
|
|
27
|
+
}
|
|
28
|
+
EXIT_NO_PACKAGES = 128
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _fixed_version(vuln: dict[str, Any], package_name: str) -> str | None:
|
|
32
|
+
affected = vuln.get("affected") if isinstance(vuln.get("affected"), list) else []
|
|
33
|
+
fixed: list[str] = []
|
|
34
|
+
for entry in affected:
|
|
35
|
+
if not isinstance(entry, dict):
|
|
36
|
+
continue
|
|
37
|
+
pkg = entry.get("package") if isinstance(entry.get("package"), dict) else {}
|
|
38
|
+
if pkg.get("name") and str(pkg.get("name")).lower() != package_name.lower():
|
|
39
|
+
continue
|
|
40
|
+
for rng in entry.get("ranges") or []:
|
|
41
|
+
for event in (rng.get("events") or []) if isinstance(rng, dict) else []:
|
|
42
|
+
if isinstance(event, dict) and event.get("fixed"):
|
|
43
|
+
fixed.append(str(event["fixed"]))
|
|
44
|
+
return ", ".join(dict.fromkeys(fixed))[:200] or None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def normalize(data: dict[str, Any], root: Path) -> list[Finding]:
|
|
48
|
+
results = data.get("results")
|
|
49
|
+
if results is None:
|
|
50
|
+
return []
|
|
51
|
+
if not isinstance(results, list):
|
|
52
|
+
raise ScannerExecutionError("OSV-Scanner output has an unexpected format.")
|
|
53
|
+
findings: list[Finding] = []
|
|
54
|
+
for result in results:
|
|
55
|
+
if not isinstance(result, dict):
|
|
56
|
+
continue
|
|
57
|
+
source = result.get("source") if isinstance(result.get("source"), dict) else {}
|
|
58
|
+
file = relative_path(source.get("path"), root)
|
|
59
|
+
for pkg_entry in result.get("packages") or []:
|
|
60
|
+
if not isinstance(pkg_entry, dict):
|
|
61
|
+
continue
|
|
62
|
+
package = pkg_entry.get("package") if isinstance(pkg_entry.get("package"), dict) else {}
|
|
63
|
+
name = str(package.get("name") or "unknown")[:200]
|
|
64
|
+
version = str(package.get("version") or "")[:100] or None
|
|
65
|
+
ecosystem = str(package.get("ecosystem") or "")[:50]
|
|
66
|
+
vulns = {str(v.get("id")): v for v in pkg_entry.get("vulnerabilities") or [] if isinstance(v, dict)}
|
|
67
|
+
groups = [g for g in pkg_entry.get("groups") or [] if isinstance(g, dict)]
|
|
68
|
+
if not groups: # older output: one group per vulnerability
|
|
69
|
+
groups = [{"ids": [vid], "max_severity": ""} for vid in vulns]
|
|
70
|
+
for group in groups:
|
|
71
|
+
ids = [str(i) for i in group.get("ids") or []]
|
|
72
|
+
if not ids:
|
|
73
|
+
continue
|
|
74
|
+
primary = next((i for i in ids if i.startswith("GHSA-")), ids[0])
|
|
75
|
+
vuln = vulns.get(primary) or vulns.get(ids[0]) or {}
|
|
76
|
+
severity = cvss_score_to_severity(group.get("max_severity"))
|
|
77
|
+
severity_source = "cvss-qualitative-scale" if severity is not Severity.UNCLASSIFIED else "none"
|
|
78
|
+
db_specific = vuln.get("database_specific") if isinstance(vuln.get("database_specific"), dict) else {}
|
|
79
|
+
if severity is Severity.UNCLASSIFIED:
|
|
80
|
+
advisory = str(db_specific.get("severity") or "").upper()
|
|
81
|
+
if advisory in ADVISORY_SEVERITY:
|
|
82
|
+
severity, severity_source = ADVISORY_SEVERITY[advisory], "advisory-database"
|
|
83
|
+
aliases = [str(a) for a in (group.get("aliases") or vuln.get("aliases") or [])][:10]
|
|
84
|
+
references = [str(r.get("url")) for r in vuln.get("references") or []
|
|
85
|
+
if isinstance(r, dict) and r.get("url")]
|
|
86
|
+
references.insert(0, f"https://osv.dev/vulnerability/{primary}")
|
|
87
|
+
fixed = _fixed_version(vuln, name)
|
|
88
|
+
findings.append(Finding(
|
|
89
|
+
tool="osv-scanner",
|
|
90
|
+
category=Category.DEPENDENCY,
|
|
91
|
+
rule_id=primary[:100],
|
|
92
|
+
title=str(vuln.get("summary") or f"Known vulnerability in {name}")[:300],
|
|
93
|
+
message=f"{ecosystem} package {name} {version or ''} is affected by {', '.join(ids[:5])}"
|
|
94
|
+
+ (f" (aliases: {', '.join(aliases[:5])})" if aliases else "") + ".",
|
|
95
|
+
file=file,
|
|
96
|
+
package=name,
|
|
97
|
+
installed_version=version,
|
|
98
|
+
fixed_version=fixed,
|
|
99
|
+
severity=severity,
|
|
100
|
+
severity_source=severity_source,
|
|
101
|
+
scanner_severity=str(group.get("max_severity") or db_specific.get("severity") or "") or None,
|
|
102
|
+
references=references,
|
|
103
|
+
remediation=(f"Upgrade {name} to {fixed} or later, then run the test suite."
|
|
104
|
+
if fixed else f"No fixed version is listed; assess exposure and consider replacing {name}."),
|
|
105
|
+
))
|
|
106
|
+
return findings
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class OsvScannerAdapter(ScannerAdapter):
|
|
110
|
+
name = "osv-scanner"
|
|
111
|
+
display_name = "OSV-Scanner"
|
|
112
|
+
category = Category.DEPENDENCY
|
|
113
|
+
executable_names = ("osv-scanner",)
|
|
114
|
+
min_version = (1, 4, 0)
|
|
115
|
+
transmits_project_metadata = True
|
|
116
|
+
downloads_data = True
|
|
117
|
+
network_note = "sends package names and versions (not source code) to the osv.dev API."
|
|
118
|
+
docs_url = "https://google.github.io/osv-scanner/installation/"
|
|
119
|
+
install_hints = {
|
|
120
|
+
"windows": "winget install --id Google.OSVScanner -e or download from https://github.com/google/osv-scanner/releases",
|
|
121
|
+
"macos": "brew install osv-scanner",
|
|
122
|
+
"linux": "Download a release from https://github.com/google/osv-scanner/releases and verify its checksum",
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
def applicable(self, inventory: ProjectInventory) -> tuple[bool, str]:
|
|
126
|
+
if has_dependency_manifests(inventory):
|
|
127
|
+
return True, "Dependency manifests or lockfiles detected."
|
|
128
|
+
return False, "No dependency manifests or lockfiles were found."
|
|
129
|
+
|
|
130
|
+
def build_command(self, executable: str, version: str, options: ScanOptions) -> list[str]:
|
|
131
|
+
parsed = parse_version(version) or (0, 0, 0)
|
|
132
|
+
command = [executable]
|
|
133
|
+
if parsed >= (2, 0, 0):
|
|
134
|
+
command += ["scan", "source"]
|
|
135
|
+
command += ["--format", "json", "--recursive"]
|
|
136
|
+
return command + ["."]
|
|
137
|
+
|
|
138
|
+
def execute(self, executable: str, version: str, target: Path, options: ScanOptions) -> list[Finding]:
|
|
139
|
+
completed = run_tool(self.build_command(executable, version, options), cwd=target,
|
|
140
|
+
timeout=options.timeout_seconds)
|
|
141
|
+
if completed.returncode == EXIT_NO_PACKAGES:
|
|
142
|
+
return []
|
|
143
|
+
if completed.returncode not in (0, 1):
|
|
144
|
+
raise ScannerExecutionError(
|
|
145
|
+
f"OSV-Scanner exited with code {completed.returncode}; raw output was suppressed."
|
|
146
|
+
)
|
|
147
|
+
try:
|
|
148
|
+
data = json.loads(completed.stdout or "{}")
|
|
149
|
+
except ValueError as exc:
|
|
150
|
+
raise ScannerExecutionError("OSV-Scanner output could not be parsed; raw output was suppressed.") from exc
|
|
151
|
+
if not isinstance(data, dict):
|
|
152
|
+
raise ScannerExecutionError("OSV-Scanner output has an unexpected format.")
|
|
153
|
+
findings = normalize(data, target)
|
|
154
|
+
if completed.returncode == 1 and not findings:
|
|
155
|
+
raise ScannerExecutionError("OSV-Scanner reported vulnerabilities but none could be parsed.")
|
|
156
|
+
return findings
|