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.
- saferun/__init__.py +3 -0
- saferun/__main__.py +5 -0
- saferun/cli.py +533 -0
- saferun/dockerdiag.py +65 -0
- saferun/doctor.py +159 -0
- saferun/engine/__init__.py +7 -0
- saferun/engine/analyzer.py +30 -0
- saferun/engine/context.py +27 -0
- saferun/engine/engine.py +100 -0
- saferun/engine/model.py +146 -0
- saferun/engine/node.py +145 -0
- saferun/engine/osv.py +291 -0
- saferun/engine/osvdb.py +367 -0
- saferun/engine/paths.py +41 -0
- saferun/engine/pysyntax.py +135 -0
- saferun/engine/rules/python.yml +103 -0
- saferun/engine/semgrep.py +173 -0
- saferun/engine/tools.py +167 -0
- saferun/engine/verdict.py +47 -0
- saferun/hostos/__init__.py +188 -0
- saferun/hostos/_win.py +316 -0
- saferun/hostos/acl.py +172 -0
- saferun/hostos/fsops.py +41 -0
- saferun/hostos/process.py +82 -0
- saferun/hostos/winfs.py +164 -0
- saferun/runcmd.py +139 -0
- saferun/sandbox/__init__.py +11 -0
- saferun/sandbox/analyzer.py +192 -0
- saferun/sandbox/attestation.py +110 -0
- saferun/sandbox/config.py +121 -0
- saferun/sandbox/controls.py +104 -0
- saferun/sandbox/copy.py +338 -0
- saferun/sandbox/copy_windows.py +281 -0
- saferun/sandbox/cosign_tool.py +134 -0
- saferun/sandbox/docker.py +167 -0
- saferun/sandbox/images.py +110 -0
- saferun/sandbox/profiles.py +40 -0
- saferun/sandbox/reaper.py +127 -0
- saferun/sandbox/runner.py +268 -0
- saferun/sandbox/secrets.py +91 -0
- saferun/sandbox/signature.py +117 -0
- saferun/sandbox/verify.py +75 -0
- saferun/sandbox/workspace.py +92 -0
- saferun/setup.py +559 -0
- saferun/ui.py +133 -0
- saferun_cli-0.0.2.dist-info/METADATA +136 -0
- saferun_cli-0.0.2.dist-info/RECORD +51 -0
- saferun_cli-0.0.2.dist-info/WHEEL +5 -0
- saferun_cli-0.0.2.dist-info/entry_points.txt +2 -0
- saferun_cli-0.0.2.dist-info/licenses/LICENSE +21 -0
- 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
|
saferun/engine/osvdb.py
ADDED
|
@@ -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 ""
|