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.
Files changed (37) hide show
  1. universal_dev_security/__init__.py +3 -0
  2. universal_dev_security/__main__.py +4 -0
  3. universal_dev_security/cli.py +376 -0
  4. universal_dev_security/cli_extra.py +244 -0
  5. universal_dev_security/config.py +171 -0
  6. universal_dev_security/detector.py +201 -0
  7. universal_dev_security/engine.py +203 -0
  8. universal_dev_security/findings.py +199 -0
  9. universal_dev_security/fixes.py +138 -0
  10. universal_dev_security/gitguard.py +269 -0
  11. universal_dev_security/history.py +149 -0
  12. universal_dev_security/initializer.py +112 -0
  13. universal_dev_security/integrations/__init__.py +46 -0
  14. universal_dev_security/integrations/agents.py +45 -0
  15. universal_dev_security/integrations/claude_code.py +192 -0
  16. universal_dev_security/integrations/github.py +149 -0
  17. universal_dev_security/integrations/vscode.py +44 -0
  18. universal_dev_security/policy.py +384 -0
  19. universal_dev_security/projectfiles.py +80 -0
  20. universal_dev_security/recovery.py +168 -0
  21. universal_dev_security/redaction.py +132 -0
  22. universal_dev_security/reports.py +466 -0
  23. universal_dev_security/scanner.py +36 -0
  24. universal_dev_security/scanners/__init__.py +36 -0
  25. universal_dev_security/scanners/base.py +269 -0
  26. universal_dev_security/scanners/gitleaks.py +132 -0
  27. universal_dev_security/scanners/osv.py +156 -0
  28. universal_dev_security/scanners/semgrep.py +130 -0
  29. universal_dev_security/scanners/trivy.py +139 -0
  30. universal_dev_security/testing.py +100 -0
  31. universal_dev_security/updates.py +84 -0
  32. universal_dev_security-1.0.0.dist-info/METADATA +128 -0
  33. universal_dev_security-1.0.0.dist-info/RECORD +37 -0
  34. universal_dev_security-1.0.0.dist-info/WHEEL +5 -0
  35. universal_dev_security-1.0.0.dist-info/entry_points.txt +2 -0
  36. universal_dev_security-1.0.0.dist-info/licenses/LICENSE +163 -0
  37. 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