saferun-cli 0.0.2__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 (51) hide show
  1. saferun/__init__.py +3 -0
  2. saferun/__main__.py +5 -0
  3. saferun/cli.py +533 -0
  4. saferun/dockerdiag.py +65 -0
  5. saferun/doctor.py +159 -0
  6. saferun/engine/__init__.py +7 -0
  7. saferun/engine/analyzer.py +30 -0
  8. saferun/engine/context.py +27 -0
  9. saferun/engine/engine.py +100 -0
  10. saferun/engine/model.py +146 -0
  11. saferun/engine/node.py +145 -0
  12. saferun/engine/osv.py +291 -0
  13. saferun/engine/osvdb.py +367 -0
  14. saferun/engine/paths.py +41 -0
  15. saferun/engine/pysyntax.py +135 -0
  16. saferun/engine/rules/python.yml +103 -0
  17. saferun/engine/semgrep.py +173 -0
  18. saferun/engine/tools.py +167 -0
  19. saferun/engine/verdict.py +47 -0
  20. saferun/hostos/__init__.py +188 -0
  21. saferun/hostos/_win.py +316 -0
  22. saferun/hostos/acl.py +172 -0
  23. saferun/hostos/fsops.py +41 -0
  24. saferun/hostos/process.py +82 -0
  25. saferun/hostos/winfs.py +164 -0
  26. saferun/runcmd.py +139 -0
  27. saferun/sandbox/__init__.py +11 -0
  28. saferun/sandbox/analyzer.py +192 -0
  29. saferun/sandbox/attestation.py +110 -0
  30. saferun/sandbox/config.py +121 -0
  31. saferun/sandbox/controls.py +104 -0
  32. saferun/sandbox/copy.py +338 -0
  33. saferun/sandbox/copy_windows.py +281 -0
  34. saferun/sandbox/cosign_tool.py +134 -0
  35. saferun/sandbox/docker.py +167 -0
  36. saferun/sandbox/images.py +110 -0
  37. saferun/sandbox/profiles.py +40 -0
  38. saferun/sandbox/reaper.py +127 -0
  39. saferun/sandbox/runner.py +268 -0
  40. saferun/sandbox/secrets.py +91 -0
  41. saferun/sandbox/signature.py +117 -0
  42. saferun/sandbox/verify.py +75 -0
  43. saferun/sandbox/workspace.py +92 -0
  44. saferun/setup.py +559 -0
  45. saferun/ui.py +133 -0
  46. saferun_cli-0.0.2.dist-info/METADATA +136 -0
  47. saferun_cli-0.0.2.dist-info/RECORD +51 -0
  48. saferun_cli-0.0.2.dist-info/WHEEL +5 -0
  49. saferun_cli-0.0.2.dist-info/entry_points.txt +2 -0
  50. saferun_cli-0.0.2.dist-info/licenses/LICENSE +21 -0
  51. saferun_cli-0.0.2.dist-info/top_level.txt +1 -0
saferun/engine/osv.py ADDED
@@ -0,0 +1,291 @@
1
+ """Dependency vulnerability scanning with OSV-Scanner (v2 CLI), run INSIDE the analyzer container (Phase 5B). Never on the host.
2
+
3
+ Invocation (argv, no shell), in a container with NO network, on the sanitized project copy (read-only /workspace), with the
4
+ offline OSV database directory mounted read-only at /osv-db (see osvdb.py; refreshed only by `saferun osv-db update`):
5
+ /usr/bin/env XDG_CACHE_HOME=/osv-db /usr/local/bin/osv-scanner scan source --format json --no-resolve
6
+ --offline --offline-vulnerabilities --lockfile=./<lockfile> ...
7
+ SafeRun never installs or fetches dependencies and never runs install scripts: OSV-Scanner only *reads* files.
8
+ Scans are always offline: nothing about the project leaves the machine. Without a database for the needed ecosystem the tool errors
9
+ and that is reported as an ERROR (the verdict can never be PASS), with the command that fixes it. The `osv_offline` option is kept only
10
+ for compatibility; online per-scan queries are intentionally not supported (see PROJECT.md, Phase 5B).
11
+ Exit codes: 0 = no vulnerabilities, 1 = vulnerabilities found (JSON on stdout), 128 = no packages found,
12
+ anything else = error.
13
+
14
+ Terminology (verified against osv-scanner 2.7.0): a *lockfile* records resolved, exact versions; a *manifest* /
15
+ requirements file declares wanted versions. Scanned, by exact file name:
16
+ lockfiles - Python: poetry.lock, Pipfile.lock, pdm.lock, uv.lock; npm: package-lock.json, yarn.lock, pnpm-lock.yaml
17
+ requirements - Python: requirements.txt ONLY. It is a manifest, not a lockfile: OSV-Scanner (which labels every
18
+ input "lockfile" in its JSON) reads it, but an unpinned entry such as `flask>=1.0` is evaluated as
19
+ the exact version 1.0 - results for it are unreliable (a note is added), and transitive
20
+ dependencies are not covered (--no-resolve).
21
+ Other manifests (package.json, Pipfile, pyproject.toml) and other names (requirements-dev.txt, ...) are NOT scanned;
22
+ a manifest with no scannable file beside it is reported as a coverage gap (INCOMPLETE/SKIPPED), never as clean.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ import re
29
+ import time
30
+ from pathlib import Path
31
+
32
+ from .context import CheckContext
33
+ from .model import CheckResult, CheckStatus, Finding, PackageRef, Severity, clean_text
34
+ from .paths import rel_path
35
+ from ..sandbox import SandboxError
36
+ from ..sandbox.analyzer import OSV_EXE, AnalyzerIntegrityError
37
+ from . import osvdb
38
+ from .tools import ToolUnavailable
39
+
40
+ DB_MOUNT = "/osv-db"
41
+
42
+ NAME = "dependency-vulnerabilities"
43
+ PY_LOCKFILES = ("poetry.lock", "Pipfile.lock", "pdm.lock", "uv.lock")
44
+ PY_REQUIREMENTS = ("requirements.txt",) # a manifest, not a lockfile
45
+ NPM_LOCKFILES = ("package-lock.json", "yarn.lock", "pnpm-lock.yaml")
46
+ SUPPORTED = frozenset(PY_LOCKFILES + PY_REQUIREMENTS + NPM_LOCKFILES)
47
+ VENDORED = frozenset({"node_modules", ".venv", "venv", "site-packages", ".git"})
48
+ MAX_LOCKFILES = 50
49
+ MAX_MANIFEST_BYTES = 1024 * 1024
50
+ _NAME_OK = re.compile(r"^[A-Za-z0-9._@/+~-][A-Za-z0-9._@/ +~-]*$")
51
+
52
+
53
+ def _basename(p: str) -> str:
54
+ return p.rsplit("/", 1)[-1]
55
+
56
+
57
+ def _dir(p: str) -> str:
58
+ return p.rsplit("/", 1)[0] if "/" in p else ""
59
+
60
+
61
+ def discover(files: list[str]) -> tuple[list[str], list[str], list[str]]:
62
+ """Return (files to scan, unsafe-to-pass paths, coverage gaps). "Files to scan" are lockfiles plus requirements.txt."""
63
+ own = [f for f in files if not any(p in VENDORED for p in f.split("/")[:-1])]
64
+ locks = sorted(f for f in own if _basename(f) in SUPPORTED)
65
+ unsafe = [f for f in locks if not _NAME_OK.match(f) or ":" in f]
66
+ locks = [f for f in locks if f not in unsafe]
67
+ present = set(own)
68
+ gaps = []
69
+ for f in own:
70
+ d, b = _dir(f), _basename(f)
71
+ sib = lambda n: (f"{d}/{n}" if d else n) # noqa: E731
72
+ if b == "package.json" and not any(sib(n) in present for n in NPM_LOCKFILES):
73
+ gaps.append(f"{f}: no npm lockfile (package-lock.json/yarn.lock/pnpm-lock.yaml); dependencies not scanned")
74
+ elif b not in SUPPORTED and re.fullmatch(r"(?i).*requirements.*\.txt", b):
75
+ gaps.append(f"{f}: requirements-file variants are not scanned (only a file named exactly requirements.txt is)")
76
+ elif b == "Pipfile" and sib("Pipfile.lock") not in present:
77
+ gaps.append(f"{f}: no Pipfile.lock; dependencies not scanned")
78
+ elif b == "pyproject.toml" and not any(sib(n) in present for n in PY_LOCKFILES):
79
+ gaps.append(f"{f}: no Python lockfile (poetry.lock/uv.lock/pdm.lock/Pipfile.lock); dependencies declared there are not scanned"
80
+ + (" (a requirements.txt beside it is scanned separately)" if sib("requirements.txt") in present else ""))
81
+ return locks, unsafe, sorted(gaps)
82
+
83
+
84
+ def build_argv(lockfiles: list[str], exe: str = OSV_EXE) -> list[str]:
85
+ """Always offline. `env` sets the cache directory to the read-only database mount (HOME inside the container is a tmpfs)."""
86
+ return ["/usr/bin/env", f"XDG_CACHE_HOME={DB_MOUNT}", exe, "scan", "source", "--format", "json", "--no-resolve",
87
+ "--offline", "--offline-vulnerabilities", *[f"--lockfile=./{lf}" for lf in lockfiles]]
88
+
89
+
90
+ def _severity(vuln: dict, score_by_id: dict[str, str]) -> Severity:
91
+ for i in [vuln.get("id"), *(vuln.get("aliases") or [])]:
92
+ try:
93
+ score = float(score_by_id.get(i, ""))
94
+ except (TypeError, ValueError):
95
+ continue
96
+ return (Severity.CRITICAL if score >= 9 else Severity.HIGH if score >= 7 else
97
+ Severity.MEDIUM if score >= 4 else Severity.LOW if score > 0 else Severity.UNKNOWN)
98
+ ds = vuln.get("database_specific")
99
+ label = str(ds.get("severity", "")).upper() if isinstance(ds, dict) else ""
100
+ return {"CRITICAL": Severity.CRITICAL, "HIGH": Severity.HIGH, "MODERATE": Severity.MEDIUM,
101
+ "MEDIUM": Severity.MEDIUM, "LOW": Severity.LOW}.get(label, Severity.UNKNOWN)
102
+
103
+
104
+ def _fixed(vuln: dict, name: str) -> tuple[str, ...]:
105
+ out: list[str] = []
106
+ for a in vuln.get("affected") or []:
107
+ if not isinstance(a, dict) or (isinstance(a.get("package"), dict) and a["package"].get("name") not in (None, name)):
108
+ continue
109
+ for r in a.get("ranges") or []:
110
+ for ev in (r.get("events") or []) if isinstance(r, dict) else []:
111
+ if isinstance(ev, dict) and isinstance(ev.get("fixed"), str):
112
+ out.append(clean_text(ev["fixed"], 60))
113
+ return tuple(dict.fromkeys(out))
114
+
115
+
116
+ def parse_output(text: str, root: Path) -> list[Finding]:
117
+ """Raises ValueError on anything that is not OSV-Scanner JSON."""
118
+ try:
119
+ data = json.loads(text)
120
+ except (json.JSONDecodeError, RecursionError) as e:
121
+ raise ValueError(f"output is not valid JSON: {e}") from None
122
+ if not isinstance(data, dict) or not isinstance(data.get("results"), list):
123
+ raise ValueError("JSON has no 'results' list")
124
+ findings: list[Finding] = []
125
+ for res in data["results"]:
126
+ if not isinstance(res, dict) or not isinstance(res.get("packages", []), list):
127
+ raise ValueError("malformed entry in 'results'")
128
+ src = res.get("source")
129
+ path = rel_path(src.get("path"), root) if isinstance(src, dict) else None
130
+ for pk in res.get("packages") or []:
131
+ info = pk.get("package") if isinstance(pk, dict) else None
132
+ if not isinstance(info, dict) or not isinstance(info.get("name"), str) or not isinstance(pk.get("vulnerabilities", []), list):
133
+ raise ValueError("malformed package entry")
134
+ ref = PackageRef(clean_text(info["name"], 200), clean_text(info.get("version", ""), 100),
135
+ clean_text(info.get("ecosystem", ""), 50))
136
+ score_by_id: dict[str, str] = {}
137
+ for g in pk.get("groups") or []:
138
+ if isinstance(g, dict) and g.get("max_severity") not in (None, ""):
139
+ for i in g.get("ids") or []:
140
+ score_by_id[str(i)] = str(g["max_severity"])
141
+ seen: set[str] = set()
142
+ for v in pk.get("vulnerabilities") or []:
143
+ if not isinstance(v, dict) or not isinstance(v.get("id"), str):
144
+ raise ValueError("malformed vulnerability entry")
145
+ aliases = [a for a in (v.get("aliases") or []) if isinstance(a, str)]
146
+ ids = [v["id"], *aliases]
147
+ if seen & set(ids): # same vulnerability under another id: report once
148
+ continue
149
+ seen.update(ids)
150
+ summary = v.get("summary") or v.get("details") or ""
151
+ findings.append(Finding(
152
+ check=NAME, rule_id=clean_text(v["id"], 100), severity=_severity(v, score_by_id),
153
+ message=clean_text(f"{ref.name} {ref.version}: {summary}".strip()), category="dependency",
154
+ path=path, package=ref, vulnerability_ids=tuple(clean_text(i, 100) for i in ids),
155
+ fixed_versions=_fixed(v, ref.name),
156
+ ))
157
+ return findings
158
+
159
+
160
+ def npm_unmatched(path: Path) -> int | None:
161
+ """Number of package-lock.json entries OSV-Scanner counts but cannot match (verified with 2.7.0: an entry with no
162
+ registry `resolved` URL, or one resolved from git, yields no vulnerability results and exit 0). Local `link`
163
+ entries are ignored. None if the file cannot be read as a lockfile (OSV-Scanner then reports it)."""
164
+ try:
165
+ if path.stat().st_size > MAX_MANIFEST_BYTES * 20:
166
+ return None
167
+ data = json.loads(path.read_text("utf-8", "replace"))
168
+ except (OSError, ValueError, RecursionError):
169
+ return None
170
+ if not isinstance(data, dict):
171
+ return None
172
+ n = 0
173
+
174
+ def bad(e) -> bool:
175
+ return (isinstance(e, dict) and isinstance(e.get("version"), str) and not e.get("link")
176
+ and not (isinstance(e.get("resolved"), str) and e["resolved"].startswith(("https://", "http://"))))
177
+
178
+ pk = data.get("packages")
179
+ if isinstance(pk, dict):
180
+ n += sum(1 for k, e in pk.items() if k and bad(e))
181
+ else:
182
+ stack = [data.get("dependencies")]
183
+ while stack:
184
+ d = stack.pop()
185
+ for e in (d.values() if isinstance(d, dict) else []):
186
+ n += bad(e)
187
+ if isinstance(e, dict):
188
+ stack.append(e.get("dependencies"))
189
+ return n
190
+
191
+
192
+ _PROGRESS = ("Starting filesystem walk", "Scanned ", "End status")
193
+
194
+
195
+ def _error_lines(stderr: str) -> str:
196
+ """The tool's progress lines hide its error; prefer the non-progress lines."""
197
+ lines = [ln.strip() for ln in stderr.splitlines() if ln.strip() and not ln.startswith(_PROGRESS)]
198
+ return " | ".join(lines or [stderr.strip()])[-300:]
199
+
200
+
201
+ def _unpinned_notes(ctx: CheckContext, locks: list[str]) -> list[str]:
202
+ notes = []
203
+ for lf in locks:
204
+ if _basename(lf) != "requirements.txt":
205
+ continue
206
+ try:
207
+ p = ctx.project / lf
208
+ if p.stat().st_size > MAX_MANIFEST_BYTES:
209
+ continue
210
+ lines = [ln.split("#")[0].strip() for ln in p.read_text("utf-8", "replace").splitlines()]
211
+ except OSError:
212
+ continue
213
+ loose = [ln for ln in lines if ln and not ln.startswith("-") and "==" not in ln]
214
+ if loose:
215
+ notes.append(f"{lf}: {len(loose)} requirement(s) not pinned with '==' (a manifest is not a lockfile): they are evaluated at their stated lower bound and results for them are unreliable")
216
+ return notes
217
+
218
+
219
+ def run_check(ctx: CheckContext) -> CheckResult:
220
+ locks, unsafe, gaps = discover(ctx.files)
221
+ gaps = gaps + [f"{f}: unsupported file name for scanning; skipped" for f in unsafe]
222
+ if not locks and not gaps:
223
+ return CheckResult(NAME, CheckStatus.NOT_APPLICABLE, detail="no dependency files")
224
+ if not locks:
225
+ return CheckResult(NAME, CheckStatus.SKIPPED, tool="osv-scanner", notes=gaps,
226
+ detail="dependency manifests found but no scannable lockfile or requirements.txt; dependencies not scanned")
227
+ if len(locks) > MAX_LOCKFILES:
228
+ gaps.append(f"{len(locks) - MAX_LOCKFILES} lockfile(s) beyond the limit of {MAX_LOCKFILES} not scanned")
229
+ locks = locks[:MAX_LOCKFILES]
230
+ try:
231
+ ctx.analyzer_available()
232
+ except AnalyzerIntegrityError as e:
233
+ return CheckResult(NAME, CheckStatus.ERROR, tool="osv-scanner", notes=gaps,
234
+ detail=clean_text(f"analyzer image failed verification; OSV-Scanner was not run (no host fallback): {e}"))
235
+ except SandboxError as e:
236
+ return CheckResult(NAME, CheckStatus.UNAVAILABLE, tool="osv-scanner", notes=gaps,
237
+ detail=clean_text(f"analyzer image unavailable; OSV-Scanner was not run (no host fallback): {e}"))
238
+ db_dir = Path(ctx.options.get("osv_db_dir") or osvdb.default_dir())
239
+ needed = sorted({"npm" if _basename(lf) in NPM_LOCKFILES else "PyPI" for lf in locks})
240
+ db = osvdb.validate(db_dir, needed, max_age_days=ctx.options.get("osv_db_max_age_days") or osvdb.DEFAULT_MAX_AGE_DAYS)
241
+ if not db.ok:
242
+ return CheckResult(NAME, CheckStatus.ERROR, tool="osv-scanner", notes=gaps,
243
+ detail=clean_text("the offline OSV vulnerability database cannot be trusted, so dependencies were NOT checked: "
244
+ + "; ".join(db.problems) + ". Fix or refresh it with: saferun osv-db update", 700))
245
+ t0 = time.monotonic()
246
+ try:
247
+ run = ctx.analyzer(build_argv(locks), cwd=ctx.project, env=None, timeout_s=ctx.timeout_s, max_output=ctx.max_output,
248
+ mounts=((db_dir, DB_MOUNT),))
249
+ except ToolUnavailable as e:
250
+ return CheckResult(NAME, CheckStatus.UNAVAILABLE, detail=clean_text(e), tool="osv-scanner", notes=gaps)
251
+ except SandboxError as e:
252
+ return CheckResult(NAME, CheckStatus.ERROR, tool="osv-scanner", notes=gaps, duration_s=time.monotonic() - t0,
253
+ detail=clean_text(f"isolated analyzer run failed; OSV-Scanner results are not available (no host fallback): {e}"))
254
+ res = CheckResult(NAME, CheckStatus.ERROR, tool="osv-scanner", duration_s=time.monotonic() - t0, notes=list(gaps))
255
+ if run.timed_out:
256
+ res.status, res.detail = CheckStatus.TIMEOUT, f"osv-scanner exceeded {ctx.timeout_s:g}s and was killed"
257
+ return res
258
+ if run.output_truncated:
259
+ res.detail = "osv-scanner output exceeded the size limit and the run was killed"
260
+ return res
261
+ if run.exit_code == 128:
262
+ res.status, res.detail = CheckStatus.INCOMPLETE, "osv-scanner found no packages in the scanned file(s); nothing was scanned"
263
+ return res
264
+ if run.exit_code not in (0, 1):
265
+ res.detail = clean_text(f"osv-scanner exited with code {run.exit_code}: {_error_lines(run.stderr)}")
266
+ return res
267
+ try:
268
+ findings = parse_output(run.stdout, ctx.project)
269
+ except ValueError as e:
270
+ res.detail = clean_text(f"osv-scanner output unusable: {e}")
271
+ return res
272
+ if run.exit_code == 1 and not findings:
273
+ res.detail = "osv-scanner reported vulnerabilities (exit 1) but none could be read from its output"
274
+ return res
275
+ res.findings = findings[: ctx.max_findings]
276
+ res.truncated_findings = max(0, len(findings) - ctx.max_findings)
277
+ for lf in locks:
278
+ if _basename(lf) == "package-lock.json" and (n := npm_unmatched(ctx.project / lf)):
279
+ gaps.append(f"{lf}: {n} package(s) without a registry URL (or from git) cannot be matched by osv-scanner")
280
+ res.notes.append(gaps[-1])
281
+ res.notes += [f"scanned file(s): {', '.join(locks)}", *_unpinned_notes(ctx, locks), *db.notes] # database facts last: earlier note positions are unchanged
282
+ if gaps:
283
+ res.status = CheckStatus.INCOMPLETE
284
+ res.detail = f"{len(gaps)} dependency file(s) not scanned: " + "; ".join(gaps)[:400]
285
+ else:
286
+ res.status = CheckStatus.FINDINGS if res.findings else CheckStatus.PASSED
287
+ if db.stale:
288
+ # valid but old data: findings are kept, but the result can never be a clean PASSED
289
+ res.detail = clean_text("; ".join(x for x in (res.detail, db.stale) if x), 700)
290
+ res.status = CheckStatus.INCOMPLETE
291
+ return res
@@ -0,0 +1,367 @@
1
+ """Offline OSV vulnerability databases (Phase 5B, validated since the Phase 5B review).
2
+
3
+ OSV-Scanner runs inside the analyzer container with the network disabled, so it needs a local copy of the OSV data. The
4
+ data lives in `<db_dir>/osv-scalibr/<Ecosystem>/all.zip` (the layout OSV-Scanner reads when XDG_CACHE_HOME=<db_dir>) and is
5
+ mounted read-only. `saferun osv-db update` is the only code that downloads it: an explicit, user-run, host-side HTTPS download of
6
+ public vulnerability data. The request contains only the ecosystem name; nothing about any project is sent.
7
+
8
+ Integrity model (read this before trusting a scan result):
9
+ * At download time: HTTPS (certificate-verified by urllib), the server-provided MD5 (`x-goog-hash`) must be present and match
10
+ (a missing header is refused unless the operator passes `--allow-unverified`, and the manifest then records "unverified"),
11
+ the file must be a readable zip whose central directory lists at least MIN_ENTRIES[ecosystem] advisory files. The host reads only
12
+ the zip's central directory (stdlib `zipfile`, nothing is extracted); the advisories themselves are only ever opened by OSV-Scanner
13
+ inside the container.
14
+ * A `manifest.json` records, per ecosystem, the SHA-256, size, entry count and update time of what was installed.
15
+ * Before every scan `validate()` re-hashes the files and compares them with the manifest, re-checks size/entry-count floors, refuses
16
+ symlinks and unreadable files, rejects timestamps from the future, and reports the data's age. Any problem makes the dependency
17
+ check an ERROR; data older than the age limit makes it INCOMPLETE. Neither can produce a clean result.
18
+ What this does NOT give you: the MD5 comes from the same server over the same connection, so it detects accidental corruption, not a
19
+ malicious or compromised server; the SHA-256 is computed locally after download, so it detects LATER modification of the files but does
20
+ not authenticate the original download; the manifest lives next to the data, so someone who can write the directory can rewrite both
21
+ (same trust level as the user's own files and Docker); the entry-count floor is a sanity check, not proof of completeness; and OSV
22
+ publishes no signature that SafeRun checks.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import base64
27
+ import hashlib
28
+ import json
29
+ import math
30
+ import os
31
+ import stat
32
+ import tempfile
33
+ import time
34
+ import urllib.error
35
+ import urllib.request
36
+ import zipfile
37
+ from dataclasses import dataclass, field
38
+ from datetime import datetime, timezone
39
+ from pathlib import Path
40
+
41
+ from .. import hostos
42
+
43
+ ECOSYSTEMS = ("PyPI", "npm")
44
+ BASE_URL = "https://osv-vulnerabilities.storage.googleapis.com"
45
+ MAX_BYTES = 2 * 1024 ** 3
46
+ MANIFEST_NAME = "manifest.json"
47
+ MANIFEST_SCHEMA = 1
48
+ # Sanity floors (about a fifth of what OSV listed in October 2026: PyPI 26 726, npm 230 914 advisories). They catch empty, stub and badly
49
+ # truncated archives; they are not proof that an archive is complete.
50
+ MIN_ENTRIES = {"PyPI": 5_000, "npm": 40_000}
51
+ DEFAULT_MAX_AGE_DAYS = 30.0
52
+ FUTURE_SKEW_S = 24 * 3600
53
+ _HEX64 = frozenset("0123456789abcdef")
54
+
55
+
56
+ def default_dir() -> Path:
57
+ env = os.environ.get("SAFERUN_OSV_DB")
58
+ if env:
59
+ return Path(env)
60
+ return hostos.user_data_base() / "saferun" / "osv-db"
61
+
62
+
63
+ def available_ecosystems(db_dir: Path) -> list[str]:
64
+ """Ecosystems that have a data file (existence only; use `validate` for trust)."""
65
+ root = db_dir / "osv-scalibr"
66
+ try:
67
+ return sorted(e for e in ECOSYSTEMS if (root / e / "all.zip").is_file())
68
+ except OSError:
69
+ return []
70
+
71
+
72
+ # ---------------------------------------------------------------------------------------------------- validation
73
+ @dataclass
74
+ class DbReport:
75
+ problems: list[str] = field(default_factory=list) # any entry: the data cannot be trusted -> the check is an ERROR
76
+ stale: str = "" # non-empty: valid but older than the limit -> the check is INCOMPLETE
77
+ notes: list[str] = field(default_factory=list) # freshness and integrity facts, always reported
78
+ ecosystems: dict = field(default_factory=dict) # manifest entries of the validated ecosystems
79
+ age_days: float | None = None # age of the OLDEST validated ecosystem
80
+
81
+ @property
82
+ def ok(self) -> bool:
83
+ return not self.problems
84
+
85
+
86
+ def _sha256_file(path: Path) -> str:
87
+ h = hashlib.sha256()
88
+ with open(path, "rb") as f:
89
+ while chunk := f.read(1 << 20):
90
+ h.update(chunk)
91
+ return h.hexdigest()
92
+
93
+
94
+ def count_entries(path: Path) -> int:
95
+ """Number of advisory (`*.json`) files listed in the zip's central directory. Nothing is extracted. Raises ValueError."""
96
+ try:
97
+ with zipfile.ZipFile(path) as z:
98
+ return sum(1 for n in z.namelist() if n.endswith(".json"))
99
+ except (zipfile.BadZipFile, OSError, NotImplementedError, RuntimeError, EOFError) as e:
100
+ raise ValueError(f"not a readable zip archive: {e}") from None
101
+
102
+
103
+ def _iso(epoch: float) -> str:
104
+ return datetime.fromtimestamp(epoch, timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
105
+
106
+
107
+ def _load_manifest(db_dir: Path) -> dict:
108
+ p = db_dir / MANIFEST_NAME
109
+ try:
110
+ st = os.lstat(p)
111
+ except FileNotFoundError:
112
+ raise ValueError(f"no {MANIFEST_NAME} (the data was not installed by `saferun osv-db update`, or by an older version)") from None
113
+ except OSError as e:
114
+ raise ValueError(f"cannot read {MANIFEST_NAME}: {e}") from None
115
+ if not stat.S_ISREG(st.st_mode) or st.st_size > 1 << 20:
116
+ raise ValueError(f"{MANIFEST_NAME} is not a regular file of sane size")
117
+ try:
118
+ data = json.loads(p.read_text())
119
+ except (OSError, ValueError, RecursionError) as e:
120
+ raise ValueError(f"{MANIFEST_NAME} is not valid JSON: {e}") from None
121
+ if not isinstance(data, dict) or data.get("schema") != MANIFEST_SCHEMA or not isinstance(data.get("ecosystems"), dict):
122
+ raise ValueError(f"{MANIFEST_NAME} has an unexpected structure or schema version")
123
+ return data
124
+
125
+
126
+ def _entry_problem(eco: str, e) -> str | None:
127
+ if not isinstance(e, dict):
128
+ return "manifest entry is not an object"
129
+ sha, size, entries, ts = e.get("sha256"), e.get("size"), e.get("entries"), e.get("updated_epoch")
130
+ if not (isinstance(sha, str) and len(sha) == 64 and set(sha) <= _HEX64):
131
+ return "manifest sha256 is malformed"
132
+ if not (isinstance(size, int) and not isinstance(size, bool) and size > 0):
133
+ return "manifest size is malformed"
134
+ if not (isinstance(entries, int) and not isinstance(entries, bool) and entries >= 0):
135
+ return "manifest entry count is malformed"
136
+ if not (isinstance(ts, (int, float)) and not isinstance(ts, bool) and math.isfinite(ts) and ts > 0):
137
+ return "manifest update time is malformed"
138
+ if e.get("md5") not in ("verified", "unverified"):
139
+ return "manifest md5 status is malformed"
140
+ return None
141
+
142
+
143
+ def validate(db_dir: Path, needed=None, *, max_age_days: float = DEFAULT_MAX_AGE_DAYS, now: float | None = None) -> DbReport:
144
+ """Decide whether the data in `db_dir` may be used for `needed` ecosystems (default: everything in the manifest).
145
+ Never raises; every doubt becomes a problem."""
146
+ rep = DbReport()
147
+ now = time.time() if now is None else now
148
+ try:
149
+ st = os.lstat(db_dir)
150
+ except OSError:
151
+ rep.problems.append(f"{db_dir} does not exist")
152
+ return rep
153
+ if not stat.S_ISDIR(st.st_mode):
154
+ rep.problems.append(f"{db_dir} is not a directory (symlinks are refused)")
155
+ return rep
156
+ if not st.st_mode & 0o005 == 0o005:
157
+ rep.problems.append(f"{db_dir} is not readable by other users; the analyzer container runs as uid 65534 (chmod o+rx it)")
158
+ try:
159
+ manifest = _load_manifest(db_dir)
160
+ except ValueError as e:
161
+ rep.problems.append(str(e))
162
+ return rep
163
+ names = sorted(needed) if needed is not None else sorted(manifest["ecosystems"])
164
+ if not names:
165
+ rep.problems.append("the manifest lists no ecosystem")
166
+ oldest = None
167
+ for eco in names:
168
+ entry = manifest["ecosystems"].get(eco)
169
+ if entry is None:
170
+ rep.problems.append(f"no {eco} data in the manifest (run `saferun osv-db update`)")
171
+ continue
172
+ bad = _entry_problem(eco, entry)
173
+ if bad:
174
+ rep.problems.append(f"{eco}: {bad}")
175
+ continue
176
+ path = db_dir / "osv-scalibr" / eco / "all.zip"
177
+ try:
178
+ for d in (db_dir / "osv-scalibr", db_dir / "osv-scalibr" / eco):
179
+ dst = os.lstat(d)
180
+ if not stat.S_ISDIR(dst.st_mode) or not dst.st_mode & 0o005 == 0o005:
181
+ raise ValueError(f"{d} is a symlink/not a directory or not readable by other users")
182
+ fst = os.lstat(path)
183
+ if not stat.S_ISREG(fst.st_mode) or not fst.st_mode & 0o004:
184
+ raise ValueError("data file is a symlink/not a regular file or not readable by other users")
185
+ if fst.st_size != entry["size"]:
186
+ raise ValueError(f"size {fst.st_size} differs from the recorded {entry['size']}")
187
+ if _sha256_file(path) != entry["sha256"]:
188
+ raise ValueError("SHA-256 differs from the recorded value (the file changed after installation)")
189
+ except (OSError, ValueError) as e:
190
+ rep.problems.append(f"{eco}: {e}")
191
+ continue
192
+ floor = MIN_ENTRIES.get(eco, 1)
193
+ if entry["entries"] < floor:
194
+ rep.problems.append(f"{eco}: only {entry['entries']} advisories recorded (minimum {floor}); the data is incomplete or a stub")
195
+ continue
196
+ if entry["updated_epoch"] > now + FUTURE_SKEW_S:
197
+ rep.problems.append(f"{eco}: update time is in the future ({_iso(entry['updated_epoch'])}); the clock or the manifest is wrong")
198
+ continue
199
+ age = max(0.0, (now - entry["updated_epoch"]) / 86400)
200
+ oldest = age if oldest is None else max(oldest, age)
201
+ rep.ecosystems[eco] = entry
202
+ rep.notes.append(f"OSV database {eco}: updated {_iso(entry['updated_epoch'])} ({age:.1f} days ago), {entry['entries']} advisories, "
203
+ f"sha256 {entry['sha256'][:16]}…, download MD5 {entry['md5']}")
204
+ rep.age_days = oldest
205
+ if rep.ok:
206
+ rep.notes.append("OSV database integrity: hashes recorded locally at download time and re-checked now; this detects later changes, "
207
+ "it does not authenticate the original download")
208
+ if any(e["md5"] == "unverified" for e in rep.ecosystems.values()):
209
+ rep.notes.append("OSV database was installed WITHOUT a server-provided hash (--allow-unverified); its download was not integrity-checked")
210
+ if oldest is not None and oldest > max_age_days:
211
+ rep.stale = (f"the OSV database is {oldest:.0f} days old (limit {max_age_days:g}); vulnerabilities published since are not covered. "
212
+ "Refresh with: saferun osv-db update")
213
+ return rep
214
+
215
+
216
+ def stale_ecosystems(db_dir: Path, *, max_age_days: float = DEFAULT_MAX_AGE_DAYS, now: float | None = None) -> list[str]:
217
+ """Ecosystems that must be (re)downloaded: missing, failing validation, or older than the limit. An ecosystem that is valid and
218
+ fresh is not listed, so it is not downloaded again. Never raises."""
219
+ out = []
220
+ for eco in ECOSYSTEMS:
221
+ rep = validate(Path(db_dir), [eco], max_age_days=max_age_days, now=now)
222
+ if not rep.ok or rep.stale:
223
+ out.append(eco)
224
+ return out
225
+
226
+
227
+ # ---------------------------------------------------------------------------------------------------- update
228
+ def _ensure_dir(path: Path) -> bool:
229
+ """Create `path` (mode 0755) if it is missing. Returns True if it was created. An EXISTING directory is never chmod'ed;
230
+ a symlink is refused."""
231
+ if path.is_symlink():
232
+ raise ValueError(f"refusing to use a symlink as a database directory: {path}")
233
+ if path.exists():
234
+ if not path.is_dir():
235
+ raise ValueError(f"not a directory: {path}")
236
+ return False
237
+ if path == default_dir():
238
+ hostos.ensure_private_dir(path.parent) # <data>/saferun also holds the analyzer lock, which requires it to be owner-only
239
+ path.parent.mkdir(parents=True, exist_ok=True)
240
+ path.mkdir(mode=0o755)
241
+ os.chmod(path, 0o755) # only a directory created just now; the umask may have narrowed it
242
+ return True
243
+
244
+
245
+ def _write_manifest(db_dir: Path, base_url: str, eco: str, entry: dict) -> None:
246
+ try:
247
+ manifest = _load_manifest(db_dir)
248
+ except ValueError:
249
+ manifest = {"schema": MANIFEST_SCHEMA, "source": base_url, "ecosystems": {}}
250
+ manifest["source"] = base_url
251
+ manifest["ecosystems"][eco] = entry
252
+ fd, tmp = tempfile.mkstemp(dir=db_dir, prefix=".manifest-")
253
+ try:
254
+ with os.fdopen(fd, "w") as f:
255
+ json.dump(manifest, f, indent=2, sort_keys=True)
256
+ os.chmod(tmp, 0o644)
257
+ os.replace(tmp, db_dir / MANIFEST_NAME)
258
+ except BaseException:
259
+ if os.path.exists(tmp):
260
+ os.unlink(tmp)
261
+ raise
262
+
263
+
264
+ class OsvNetworkError(OSError):
265
+ """The OSV server could not be reached (or kept failing). The existing data and manifest are untouched, and are NOT made fresher by this."""
266
+
267
+
268
+ OPEN_ATTEMPTS = 3
269
+ RETRY_STATUS = frozenset({408, 429, 500, 502, 503, 504})
270
+
271
+
272
+ def _open(opener, url: str, *, sleep=time.sleep, attempts: int = OPEN_ATTEMPTS):
273
+ """Open `url`, retrying transient failures (no connection, timeout, 408/429/5xx) with a short back-off. Other HTTP errors fail at once."""
274
+ last = None
275
+ for i in range(attempts):
276
+ try:
277
+ return opener(url, timeout=120)
278
+ except urllib.error.HTTPError as e:
279
+ if e.code not in RETRY_STATUS:
280
+ raise OsvNetworkError(f"the OSV server answered HTTP {e.code} for {url}") from None
281
+ last = f"HTTP {e.code}"
282
+ except (urllib.error.URLError, TimeoutError, ConnectionError, OSError) as e:
283
+ last = str(getattr(e, "reason", e))
284
+ if i + 1 < attempts:
285
+ sleep(2 ** i)
286
+ raise OsvNetworkError(f"cannot reach the OSV server ({last}) after {attempts} attempts; check your internet connection or proxy. "
287
+ "Existing data was left untouched and keeps its original age")
288
+
289
+
290
+ def update(db_dir: Path, ecosystems=ECOSYSTEMS, *, base_url: str = BASE_URL, opener=None,
291
+ allow_unverified: bool = False, clock=time.time, progress=None, sleep=time.sleep) -> dict[str, dict]:
292
+ """Download `all.zip` for each ecosystem into `db_dir` and record it in the manifest. Returns {ecosystem: manifest entry}.
293
+ Atomic per file; raises on any problem (including an interrupted download) and leaves the previous file and manifest entry in place.
294
+ Never changes the permissions of a directory that already existed. `progress(ecosystem, bytes_done, total_or_None)` is called about once per MiB."""
295
+ if not base_url.startswith("https://"):
296
+ raise ValueError("OSV database downloads must use https")
297
+ opener = opener or urllib.request.urlopen
298
+ for eco in ecosystems:
299
+ if eco not in ECOSYSTEMS:
300
+ raise ValueError(f"unsupported ecosystem {eco!r}; supported: {', '.join(ECOSYSTEMS)}")
301
+ db_dir = Path(db_dir)
302
+ _ensure_dir(db_dir)
303
+ _ensure_dir(db_dir / "osv-scalibr")
304
+ result = {}
305
+ for eco in ecosystems:
306
+ target_dir = db_dir / "osv-scalibr" / eco
307
+ _ensure_dir(target_dir)
308
+ fd, tmp = tempfile.mkstemp(dir=target_dir, prefix=".dl-")
309
+ try:
310
+ md5, sha, size = hashlib.md5(usedforsecurity=False), hashlib.sha256(), 0
311
+ with os.fdopen(fd, "wb") as out, _open(opener, f"{base_url}/{eco}/all.zip", sleep=sleep) as resp: # noqa: S310 - https enforced
312
+ expected_md5 = _goog_md5(_header_values(resp.headers, "x-goog-hash"))
313
+ if not expected_md5 and not allow_unverified:
314
+ raise ValueError(f"{eco}: the server sent no usable MD5 header, so the download cannot be integrity-checked; "
315
+ "refusing (re-run with --allow-unverified to accept it and record it as unverified)")
316
+ try:
317
+ total = int(resp.headers.get("Content-Length")) if resp.headers.get("Content-Length") else None
318
+ except (TypeError, ValueError):
319
+ total = None
320
+ while chunk := resp.read(1 << 20):
321
+ size += len(chunk)
322
+ if progress:
323
+ progress(eco, size, total)
324
+ if size > MAX_BYTES:
325
+ raise ValueError(f"{eco}: download exceeds {MAX_BYTES} bytes")
326
+ md5.update(chunk)
327
+ sha.update(chunk)
328
+ out.write(chunk)
329
+ if expected_md5 and md5.hexdigest() != expected_md5:
330
+ raise ValueError(f"{eco}: MD5 mismatch against the server-provided hash (corrupt download)")
331
+ if size == 0:
332
+ raise ValueError(f"{eco}: empty download")
333
+ entries = count_entries(Path(tmp))
334
+ if entries < MIN_ENTRIES[eco]:
335
+ raise ValueError(f"{eco}: the archive lists only {entries} advisories (minimum {MIN_ENTRIES[eco]}); refusing an empty or incomplete database")
336
+ os.chmod(tmp, 0o644)
337
+ os.replace(tmp, target_dir / "all.zip")
338
+ except BaseException:
339
+ if os.path.exists(tmp):
340
+ os.unlink(tmp)
341
+ raise
342
+ now = clock()
343
+ entry = {"sha256": sha.hexdigest(), "size": size, "entries": entries, "updated_epoch": int(now), "updated_at": _iso(now),
344
+ "md5": "verified" if expected_md5 else "unverified"}
345
+ _write_manifest(db_dir, base_url, eco, entry)
346
+ result[eco] = entry
347
+ return result
348
+
349
+
350
+ def _header_values(headers, name: str) -> str:
351
+ """All values of a header joined with commas. The real server sends `x-goog-hash` as TWO separate headers (crc32c, md5);
352
+ `headers.get()` returns only the first, which made the original MD5 check a silent no-op."""
353
+ if hasattr(headers, "get_all"):
354
+ return ",".join(headers.get_all(name) or [])
355
+ return str(headers.get(name, "") or "")
356
+
357
+
358
+ def _goog_md5(header: str) -> str:
359
+ """x-goog-hash value(s): crc32c=...,md5=<base64>; returns the md5 as hex, or ''."""
360
+ for part in (header or "").replace(" ", "").split(","):
361
+ if part.startswith("md5="):
362
+ try:
363
+ raw = base64.b64decode(part[4:], validate=True)
364
+ except ValueError:
365
+ return ""
366
+ return raw.hex() if len(raw) == 16 else ""
367
+ return ""