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/setup.py ADDED
@@ -0,0 +1,559 @@
1
+ """First-run setup (`saferun init`) and installation status (`saferun status`).
2
+
3
+ This module only ORCHESTRATES things that already exist: the pinned images and their digest check (`sandbox.images`, `sandbox.verify`),
4
+ the analyzer image/lock/label/rules verification (`sandbox.analyzer`), the local build recipe (`docker/analyzer/build.py`) and the
5
+ validated OSV data (`engine.osvdb`). It adds no new trust: a component is READY only if that existing verification passes now, never
6
+ because a tag or a directory happens to exist, and nothing is installed that cannot then be verified.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import shutil
13
+ import socket
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ import time
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ from . import dockerdiag, ui
22
+ from .engine import osvdb
23
+ from .engine.model import clean_text
24
+ from .sandbox import SandboxError
25
+ from .sandbox import analyzer as sa
26
+ from .sandbox import docker as dk
27
+ from .sandbox import images as im
28
+ from .sandbox import verify as vf
29
+ from .sandbox import cosign_tool
30
+ from .sandbox.signature import CosignMissing, SignatureError, verify_release_signature
31
+
32
+ ROOT = Path(__file__).resolve().parent.parent
33
+ BUILD_SCRIPT = ROOT / "docker" / "analyzer" / "build.py" # Python, so the same recipe runs on Linux, macOS and Windows
34
+
35
+ READY, MISSING, INVALID, OUTDATED, UNKNOWN = "ready", "missing", "invalid", "outdated", "unknown"
36
+ MB = 1_000_000
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Component:
41
+ key: str
42
+ title: str
43
+ purpose: str
44
+ checks: tuple[str, ...] # report check names that need it
45
+ size: str # approximate download / disk use, shown before anything is fetched
46
+
47
+
48
+ COMPONENTS: tuple[Component, ...] = (
49
+ Component("python", "Python sandbox image", "Runs the Python syntax check in a locked-down container.", ("python-syntax",), "about 190 MB"),
50
+ Component("node", "Node.js sandbox image", "Runs the JavaScript syntax check in a locked-down container.", ("javascript-syntax",), "about 330 MB"),
51
+ Component("analyzer", "Analyzer image (Semgrep, OSV-Scanner, rules)", "Runs the static code analysis, and holds the dependency scanner.",
52
+ ("python-static-analysis", "dependency-vulnerabilities"), "about 740 MB, a signed image download (a local build takes a few minutes)"),
53
+ Component("osv-db", "OSV vulnerability database", "Local vulnerability data so dependency scans run offline.", ("dependency-vulnerabilities",),
54
+ "about 260 MB download"),
55
+ )
56
+ KEYS = tuple(c.key for c in COMPONENTS)
57
+ BY_KEY = {c.key: c for c in COMPONENTS}
58
+ CHECK_NEEDS = {"python-syntax": ("python",), "javascript-syntax": ("node",), "python-static-analysis": ("analyzer",),
59
+ "dependency-vulnerabilities": ("analyzer", "osv-db")}
60
+ IMAGE_FOR = {"python": im.PYTHON_IMAGE, "node": im.NODE_IMAGE}
61
+ HOSTS = {"image": ("registry-1.docker.io",), "build": ("pypi.org", "github.com"), "osv": ("osv-vulnerabilities.storage.googleapis.com",),
62
+ # a published analyzer: the registry, plus the Sigstore trust root and transparency log that `cosign verify` contacts
63
+ "release": ("ghcr.io", "tuf-repo-cdn.sigstore.dev", "rekor.sigstore.dev")}
64
+ HOSTS_COSIGN = ("github.com",) # the pinned cosign download (only when no cosign is installed yet)
65
+
66
+ INTRO = ("SafeRun checks a project WITHOUT running it: Python/JavaScript syntax, static analysis (Semgrep) and known-vulnerable dependencies (OSV).\n"
67
+ "Every check runs in a locked-down container and scans work offline; setup fetches what each check needs, once.")
68
+
69
+ def banner_lines(th: "ui.Theme") -> list[str]:
70
+ """The SafeRun logo, only on a colour-capable terminal: plain, piped and CI output stays exactly as it was."""
71
+ if not th.on:
72
+ return []
73
+ return [th.paint(line, "bold", "cyan") for line in ui.BANNER] + [th.paint(ui.BANNER_TAGLINE, "dim"), ""]
74
+
75
+
76
+ PRELOAD_HELP = """\
77
+ To set up without internet access, prepare the missing parts on a connected machine and copy them over:
78
+ * OSV data: saferun osv-db update --dir /path/to/osv-db (copy the whole directory, then: export SAFERUN_OSV_DB=/path/to/osv-db or place it at {osv_dir})
79
+ verify it with: saferun osv-db status
80
+ * Docker images: docker pull <image>@<digest> (see: saferun verify-images), then docker save / docker load on the target machine. SafeRun only trusts an image
81
+ whose registry digest matches the pin (`saferun status` shows this); an image loaded without that digest is reported as invalid, never trusted.
82
+ * Analyzer: build it where docker/analyzer/build.py can run (needs internet once), or, if you have built it elsewhere, docker save / docker load it (or, without --build-local, install the signed release analyzer-v1.0.1; see docs/release-images.md)."""
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class Status:
87
+ key: str
88
+ state: str # ready | missing | invalid | outdated | unknown
89
+ detail: str = ""
90
+
91
+ @property
92
+ def ready(self) -> bool:
93
+ return self.state == READY
94
+
95
+ def to_dict(self) -> dict:
96
+ c = BY_KEY[self.key]
97
+ return {"component": self.key, "title": c.title, "state": self.state, "detail": self.detail, "enables": list(c.checks)}
98
+
99
+
100
+ # ---------------------------------------------------------------------------------------------------------- detection
101
+ def _osv_dir() -> Path:
102
+ return osvdb.default_dir()
103
+
104
+
105
+ def _osv_state_line(d: Path) -> str:
106
+ """One sentence on what OSV data exists right now (age or the problem)."""
107
+ try:
108
+ rep = osvdb.validate(d, list(osvdb.ECOSYSTEMS))
109
+ except Exception: # noqa: BLE001
110
+ return "The local OSV data could not be examined."
111
+ if not rep.ok:
112
+ return "No usable local OSV data (" + clean_text("; ".join(rep.problems), 160) + ")."
113
+ return f"Local OSV data is {rep.age_days:.0f} days old" + (" and STALE." if rep.stale else ".")
114
+
115
+
116
+ def component_status(key: str, *, max_age_days: float = osvdb.DEFAULT_MAX_AGE_DAYS) -> Status:
117
+ """Verify one component NOW with the existing checks. Never raises."""
118
+ try:
119
+ if key in IMAGE_FOR:
120
+ c = vf.check_image(IMAGE_FOR[key])
121
+ return Status(key, {vf.VERIFIED: READY, vf.MISSING: MISSING, vf.MISMATCH: INVALID}.get(c.status, UNKNOWN),
122
+ c.detail or f"digest {c.pinned_digest[:19]}… verified")
123
+ if key == "analyzer":
124
+ try:
125
+ image_id = sa.verify_analyzer_image()
126
+ except sa.AnalyzerUnavailable as e:
127
+ return Status(key, MISSING, str(e))
128
+ except sa.AnalyzerIntegrityError as e:
129
+ return Status(key, INVALID, str(e))
130
+ except SandboxError as e:
131
+ return Status(key, UNKNOWN, str(e))
132
+ return Status(key, READY, f"{image_id[:19]}… matches the lock; tool versions and rules hash verified")
133
+ if key == "osv-db":
134
+ d = _osv_dir()
135
+ if not (d / osvdb.MANIFEST_NAME).exists() and not (d / "osv-scalibr").exists():
136
+ return Status(key, MISSING, f"no OSV data in {d}")
137
+ rep = osvdb.validate(d, list(osvdb.ECOSYSTEMS), max_age_days=max_age_days)
138
+ if not rep.ok:
139
+ return Status(key, INVALID, "; ".join(rep.problems))
140
+ if rep.stale:
141
+ return Status(key, OUTDATED, rep.stale)
142
+ return Status(key, READY, f"{rep.age_days:.1f} days old, integrity manifest verified")
143
+ except Exception as e: # noqa: BLE001 - a broken probe must read as "not ready", never as ready
144
+ return Status(key, UNKNOWN, f"could not verify: {type(e).__name__}: {e}")
145
+ raise KeyError(key)
146
+
147
+
148
+ def all_status(keys=KEYS) -> list[Status]:
149
+ return [component_status(k) for k in keys]
150
+
151
+
152
+ def check_requirements(check_name: str, statuses: dict[str, Status]) -> list[Status]:
153
+ return [statuses[k] for k in CHECK_NEEDS.get(check_name, ()) if k in statuses and not statuses[k].ready]
154
+
155
+
156
+ def scan_hints(report) -> list[str]:
157
+ """After a scan: for every check that could not do its job, say which component is missing/invalid/outdated and the exact fix.
158
+ Reads component state only when some check is unavailable/error/incomplete; prints nothing for a healthy installation."""
159
+ trouble = [c.name for c in report.checks if c.name in CHECK_NEEDS and c.status.value in ("unavailable", "error", "incomplete")]
160
+ if not trouble:
161
+ return []
162
+ needed = sorted({k for n in trouble for k in CHECK_NEEDS[n]}, key=KEYS.index)
163
+ bad = [s for s in all_status(needed) if not s.ready]
164
+ return [f"setup: {BY_KEY[s.key].title} is {s.state}: {clean_text(s.detail, 220)} -> fix: saferun init --only {s.key}"
165
+ + (" --repair" if s.key == "analyzer" and s.state == INVALID else "") for s in bad]
166
+
167
+
168
+ # ---------------------------------------------------------------------------------------------------------- selection
169
+ def parse_selection(text: str) -> list[str]:
170
+ """'all' / '5' / 'a', numbers or names separated by commas or spaces; a leading '-' or '!' excludes ("all -osv-db", "-2,3").
171
+ Returns component keys in canonical order. Raises ValueError for anything unrecognised or an empty result."""
172
+ tokens = [t for t in text.replace(",", " ").split() if t]
173
+ if not tokens:
174
+ raise ValueError("nothing selected")
175
+ inc, exc = set(), set()
176
+ for t in tokens:
177
+ neg = t[0] in "-!"
178
+ name = t[1:] if neg else t
179
+ low = name.lower()
180
+ if low in ("all", "a", str(len(KEYS) + 1)):
181
+ keys = set(KEYS)
182
+ elif low.isdigit() and 1 <= int(low) <= len(KEYS):
183
+ keys = {KEYS[int(low) - 1]}
184
+ elif low in KEYS:
185
+ keys = {low}
186
+ else:
187
+ raise ValueError(f"not a component: {t!r} (choose from {', '.join(f'{i + 1}={k}' for i, k in enumerate(KEYS))}, or all)")
188
+ (exc if neg else inc).update(keys)
189
+ chosen = (inc or set(KEYS)) - exc
190
+ if not chosen:
191
+ raise ValueError("nothing selected")
192
+ return [k for k in KEYS if k in chosen]
193
+
194
+
195
+ def parse_list(value: str | None) -> list[str]:
196
+ out = []
197
+ for t in (value or "").replace(",", " ").split():
198
+ if t not in KEYS:
199
+ raise ValueError(f"unknown component {t!r}; choose from: {', '.join(KEYS)}")
200
+ out.append(t)
201
+ return out
202
+
203
+
204
+ def select(all_flag: bool, only: str | None, skip: str | None) -> list[str] | None:
205
+ """Selection from command-line flags, or None when no flag was given (interactive)."""
206
+ if not (all_flag or only or skip):
207
+ return None
208
+ base = parse_list(only) if only else list(KEYS)
209
+ drop = set(parse_list(skip))
210
+ chosen = [k for k in KEYS if k in base and k not in drop]
211
+ if not chosen:
212
+ raise ValueError("nothing selected")
213
+ return chosen
214
+
215
+
216
+ # ---------------------------------------------------------------------------------------------------------- actions
217
+ def network_ok(host: str, port: int = 443, timeout: float = 5.0) -> bool:
218
+ try:
219
+ socket.create_connection((host, port), timeout=timeout).close()
220
+ return True
221
+ except OSError:
222
+ return False
223
+
224
+
225
+ def docker_pull(ref: str) -> int:
226
+ """`docker pull <ref>` with Docker's own progress output on the terminal."""
227
+ return subprocess.run([dk.docker_bin(), "pull", ref], stdin=subprocess.DEVNULL).returncode
228
+
229
+
230
+ def build_analyzer(log) -> int:
231
+ """Run docker/analyzer/build.py (the local build workflow; build.sh just calls it), showing a heartbeat and keeping the log."""
232
+ fd, path = tempfile.mkstemp(prefix="saferun-analyzer-build-", suffix=".log")
233
+ start = time.monotonic()
234
+ with os.fdopen(fd, "w") as out:
235
+ proc = subprocess.Popen([sys.executable, str(BUILD_SCRIPT)], stdin=subprocess.DEVNULL, stdout=out, stderr=subprocess.STDOUT,
236
+ env={**os.environ, "PYTHON": sys.executable}, cwd=str(ROOT))
237
+ try:
238
+ while proc.poll() is None:
239
+ time.sleep(5)
240
+ log(f" building… {int(time.monotonic() - start)} s (log: {path})")
241
+ except BaseException:
242
+ proc.terminate()
243
+ raise
244
+ if proc.returncode != 0:
245
+ tail = Path(path).read_text(errors="replace").splitlines()[-15:]
246
+ log(" build failed; last lines of the log:")
247
+ for line in tail:
248
+ log(" " + clean_text(line, 200))
249
+ return proc.returncode
250
+
251
+
252
+ class ProgressPrinter:
253
+ """OSV download progress: a rewritten line on a terminal, a line per 10 % otherwise."""
254
+
255
+ def __init__(self, out, tty: bool, theme: "ui.Theme | None" = None):
256
+ self.out, self.tty, self._last, self.th = out, tty, {}, theme or ui.PLAIN
257
+
258
+ def __call__(self, eco: str, done: int, total: int | None) -> None:
259
+ pct = int(done * 100 / total) if total else None
260
+ step = (pct // 10) if pct is not None else done // (50 * MB)
261
+ if self.tty:
262
+ amount = f"{done / MB:.0f}" + (f" / {total / MB:.0f} MB" if total else " MB")
263
+ self.out.write(f"\r\033[K {eco:<5} {ui.bar(done, total, self.th)} {amount}" if total else f"\r\033[K {eco:<5} {amount}")
264
+ if total and done >= total:
265
+ self.out.write("\n")
266
+ self.out.flush()
267
+ elif self._last.get(eco) != step:
268
+ self._last[eco] = step
269
+ self.out.write(f" {eco}: {done / MB:.0f}" + (f" / {total / MB:.0f} MB ({pct}%)" if total else " MB") + "\n")
270
+
271
+
272
+ # ---------------------------------------------------------------------------------------------------------- the workflow
273
+ class Setup:
274
+ """`saferun init`. All side effects go through injectable callables so the workflow is testable without Docker or a network."""
275
+
276
+ def __init__(self, *, out=None, err=None, ask=input, interactive=False, tty=False, status=component_status, online=network_ok,
277
+ pull=docker_pull, build=build_analyzer, osv_update=None, free_bytes=None, daemon_check=None, theme=None,
278
+ verify_signature=verify_release_signature, build_local=False, ensure_cosign=None, diagnose=None, has_cosign=None):
279
+ self.out, self.err = out or sys.stdout, err or sys.stderr
280
+ self.th = theme or ui.PLAIN
281
+ self.ask, self.interactive, self.tty = ask, interactive, tty
282
+ self.status, self.online, self.pull, self.build = status, online, pull, build
283
+ self.verify_signature, self.build_local = verify_signature, build_local # build_local: --build-local, never chosen automatically
284
+ self.osv_update = osv_update or self._osv_update
285
+ self.ensure_cosign = ensure_cosign or self._ensure_cosign
286
+ self.has_cosign = has_cosign or self._has_cosign
287
+ self.diagnose = diagnose or (dockerdiag.diagnose if daemon_check is None else None) # an injected daemon_check (tests) replaces the real probe
288
+ self.free_bytes = free_bytes or (lambda p: shutil.disk_usage(p).free)
289
+ self.daemon_check = daemon_check or dk.check_local_daemon
290
+
291
+ def show_banner(self) -> None:
292
+ for line in banner_lines(self.th):
293
+ self.say(line)
294
+
295
+ def say(self, text: str = "") -> None:
296
+ self.out.write(text + "\n")
297
+ self.out.flush()
298
+
299
+ # -- individual installers: return an error string, or "" on success (the caller re-verifies afterwards)
300
+ def _osv_update(self, directory: Path, progress) -> None:
301
+ """Download only the ecosystems that are missing, invalid or stale; a valid, fresh one is left alone (no needless 260 MB download)."""
302
+ need = osvdb.stale_ecosystems(directory)
303
+ if need:
304
+ self.say(f" refreshing: {', '.join(need)}" + (f" (kept as is: {', '.join(e for e in osvdb.ECOSYSTEMS if e not in need)})" if len(need) < len(osvdb.ECOSYSTEMS) else ""))
305
+ osvdb.update(directory, need, progress=progress)
306
+
307
+ def _has_cosign(self) -> bool:
308
+ from .sandbox.signature import find_cosign
309
+ return bool(find_cosign())
310
+
311
+ def _ensure_cosign(self) -> None:
312
+ """Make sure a verifiable cosign exists: a managed or PATH copy is used as is, otherwise the pinned, hash-checked one is fetched."""
313
+ from .sandbox.signature import find_cosign
314
+ if find_cosign():
315
+ return
316
+ self.say(" cosign is not installed: fetching SafeRun's pinned copy (SHA-256 checked against the value in the source) …")
317
+ cosign_tool.ensure_cosign()
318
+
319
+ def _install_image(self, key: str) -> str:
320
+ img = IMAGE_FOR[key]
321
+ self.say(f" pulling {img.pull_reference} (verified by digest) …")
322
+ rc = self.pull(img.pull_reference)
323
+ return "" if rc == 0 else f"docker pull failed (exit {rc})"
324
+
325
+ def _install_analyzer(self) -> str:
326
+ rel = self._release()
327
+ if rel is not None:
328
+ return self._install_analyzer_release(rel)
329
+ self.say(" building the analyzer image locally with docker/analyzer/build.py" if im.ANALYZER_RELEASE is not None else
330
+ " no signed prebuilt analyzer image has been published yet (docs/release-images.md): building it locally with docker/analyzer/build.py")
331
+ if not BUILD_SCRIPT.is_file():
332
+ return ("this installation does not include the build recipe (docker/ is not part of the pip package): "
333
+ "use a SafeRun repository checkout, or run `saferun init --only analyzer` without --build-local to install the signed release")
334
+ quiet = lambda t: None if t.lstrip().startswith("building…") else self.say(t) # noqa: E731 - the spinner replaces the heartbeat lines
335
+ with ui.Spinner(sys.stderr, "Building the analyzer image (a few minutes)", self.th) as sp:
336
+ rc = self.build(quiet if sp.enabled else self.say)
337
+ return "" if rc == 0 else f"the analyzer build failed (exit {rc})"
338
+
339
+ def _release(self) -> "im.AnalyzerRelease | None":
340
+ """The published analyzer to install, or None when the local build is to be used (no release is recorded, or --build-local)."""
341
+ return None if self.build_local else im.ANALYZER_RELEASE
342
+
343
+ def _install_analyzer_release(self, rel: im.AnalyzerRelease) -> str:
344
+ """Used when a release is recorded in ANALYZER_RELEASE (and not --build-local). Verify the cosign signature of `repository@digest` BEFORE anything
345
+ is pulled, then pull by digest, check the digest again, verify labels/tools/rules like a local build, then lock. Any failure leaves
346
+ the image untrusted; there is no unsigned install and no automatic fallback to the local build."""
347
+ ref = rel.reference
348
+ try:
349
+ self.ensure_cosign()
350
+ except SandboxError as e:
351
+ return (f"cosign is unavailable and could not be fetched ({clean_text(e, 300)}). Nothing was pulled. Install cosign yourself, "
352
+ "or build the analyzer with `saferun init --only analyzer --build-local`")
353
+ self.say(f" verifying the cosign signature of {ref} (expected signer: this repository's release workflow) …")
354
+ try:
355
+ self.verify_signature(rel)
356
+ except CosignMissing as e:
357
+ return (f"{clean_text(e, 300)}. Nothing was pulled. Install cosign, or build the analyzer yourself with "
358
+ "`saferun init --only analyzer --build-local`")
359
+ except SignatureError as e:
360
+ return f"signature verification failed, the image was NOT pulled or trusted: {clean_text(e, 300)}"
361
+ self.say(" signature verified")
362
+ self.say(f" pulling the published analyzer image {ref} …")
363
+ rc = self.pull(ref)
364
+ if rc != 0:
365
+ return f"docker pull failed (exit {rc})"
366
+ r = dk.docker("image", "inspect", "--format", "{{json .RepoDigests}}", dk.validate_image(ref))
367
+ try:
368
+ ok = r.returncode == 0 and rel.digest in im.repo_digests(r.stdout.strip())
369
+ except ValueError:
370
+ ok = False
371
+ if not ok:
372
+ return "the pulled image does not carry the published digest; it was NOT trusted"
373
+ t = dk.docker("tag", ref, sa.ANALYZER_REPO_TAG)
374
+ if t.returncode != 0:
375
+ return f"could not tag the pulled image: {t.stderr.strip()[:200]}"
376
+ try:
377
+ image_id, labels = sa.inspect_analyzer_tag()
378
+ sa.check_labels(labels)
379
+ sa.write_lock(image_id)
380
+ except SandboxError as e:
381
+ return f"the pulled image failed verification and was NOT trusted: {e}"
382
+ return ""
383
+
384
+ def _install_osv(self) -> str:
385
+ d = _osv_dir()
386
+ probe = d
387
+ while not probe.exists() and probe != probe.parent:
388
+ probe = probe.parent
389
+ free = self.free_bytes(probe)
390
+ if free < 700 * MB:
391
+ return f"not enough free disk space at {probe}: {free // MB} MB free, about 700 MB needed during the download"
392
+ self.say(f" downloading PyPI and npm advisories to {d} (about 260 MB, ~{free // MB // 1000} GB free) …")
393
+ try:
394
+ self.osv_update(d, ProgressPrinter(self.out, self.tty, self.th))
395
+ except KeyboardInterrupt:
396
+ raise
397
+ except Exception as e: # noqa: BLE001 - old data stays in place; say why
398
+ return (f"{type(e).__name__}: {clean_text(e, 300)}. {_osv_state_line(d)} Existing data was left untouched; "
399
+ "scans stay INCOMPLETE (never PASS) until the data is current. Retry later: saferun osv-db update")
400
+ return ""
401
+
402
+ # -- the loop
403
+ def run(self, selected: list[str], *, repair: bool = False, assume_yes: bool = False) -> int:
404
+ statuses = {k: self.status(k) for k in selected}
405
+ needs_docker = any(k in IMAGE_FOR or k == "analyzer" for k in selected)
406
+ docker_error = ""
407
+ if needs_docker:
408
+ d = self.diagnose() if self.diagnose else None
409
+ if d is not None and not d.ok:
410
+ docker_error = d.message()
411
+ else:
412
+ try:
413
+ self.daemon_check()
414
+ except SandboxError as e:
415
+ docker_error = str(e)
416
+ if self.interactive:
417
+ self.say() # space after the answer the user just typed
418
+ self.say(self.th.paint("Checking what is already installed:", "bold", "cyan"))
419
+ for k in selected:
420
+ s = statuses[k]
421
+ self.say(f" {self._tag(s.state)} {BY_KEY[k].title}" + (self.th.paint(f": {clean_text(s.detail, 160)}", "dim") if s.state != READY else ""))
422
+ results: dict[str, str] = {}
423
+ offline: list[str] = []
424
+ for k in selected:
425
+ s = statuses[k]
426
+ c = BY_KEY[k]
427
+ if s.ready:
428
+ results[k] = "skipped (already installed and verified" + (f"; {s.detail}" if k == "osv-db" and s.detail else "") + ")"
429
+ continue
430
+ if k != "osv-db" and docker_error:
431
+ results[k] = f"not installed: Docker is unusable ({clean_text(docker_error, 400)})"
432
+ continue
433
+ if s.state == UNKNOWN:
434
+ results[k] = f"not installed: could not be examined ({clean_text(s.detail, 160)})"
435
+ continue
436
+ self.say() # a blank line separates every component's block
437
+ if k == "analyzer" and s.state == INVALID and not (repair or self._confirm(
438
+ " The analyzer image is present but FAILED verification. Rebuilding replaces the local image and its lock. Rebuild?", assume_yes)):
439
+ results[k] = "left alone: it failed verification; re-run with --repair to rebuild it (investigate first if you did not expect this)"
440
+ continue
441
+ verb = {MISSING: "Install", INVALID: "Repair", OUTDATED: "Update"}[s.state]
442
+ asked = self.interactive and not assume_yes and not (k == "analyzer" and s.state == INVALID)
443
+ if asked and not self._confirm(f" {verb} {c.title} ({c.size})?", assume_yes):
444
+ results[k] = "not installed (declined)"
445
+ self.say(" " + self._outcome(results[k]))
446
+ continue
447
+ hosts = (HOSTS["osv"] if k == "osv-db"
448
+ else (HOSTS["release"] + (HOSTS_COSIGN if not self.has_cosign() else ()) if self._release() is not None else HOSTS["build"]) if k == "analyzer" else HOSTS["image"])
449
+ if not all(self.online(h) for h in hosts):
450
+ offline.append(k)
451
+ results[k] = "not installed: no internet access" + (f". {_osv_state_line(_osv_dir())} Scans stay INCOMPLETE until it is current" if k == "osv-db" else "")
452
+ self.say(" " + self._outcome(results[k]))
453
+ continue
454
+ if not asked: # after a "[Y/n]" question the same line would only repeat it
455
+ self.say(self.th.paint(f" {verb} {c.title} ({c.size}):", "bold"))
456
+ try:
457
+ err = self._install_osv() if k == "osv-db" else self._install_analyzer() if k == "analyzer" else self._install_image(k)
458
+ except KeyboardInterrupt:
459
+ self.say(" interrupted; nothing partial was trusted (run `saferun status`).")
460
+ return 130
461
+ if err:
462
+ results[k] = f"FAILED: {err}"
463
+ self.say(" " + self._outcome(results[k]))
464
+ continue
465
+ after = self.status(k)
466
+ results[k] = "installed and verified" if after.ready else f"FAILED: installed but still {after.state}: {clean_text(after.detail, 200)}"
467
+ self.say(" " + self._outcome(results[k]))
468
+ self.say()
469
+ self.say(self.th.paint("Result:", "bold", "cyan"))
470
+ for k in selected:
471
+ self.say(f" {BY_KEY[k].title}: {self._outcome(results[k])}")
472
+ bad = [k for k in selected if not results[k].startswith(("installed", "skipped"))]
473
+ if offline:
474
+ self.say()
475
+ self.say(PRELOAD_HELP.format(osv_dir=_osv_dir()))
476
+ if bad:
477
+ self.say()
478
+ self.say(self.th.paint("Not ready: " + ", ".join(bad) + ". Checks that need them are reported as unavailable/incomplete and a scan can never be PASS.", "yellow", "bold"))
479
+ return 1
480
+ self.say()
481
+ self.say(self.th.paint("Ready.", "green", "bold") + " Scan a project with: saferun scan ./my-project")
482
+ return 0
483
+
484
+ def _tag(self, state: str, pad: bool = True) -> str:
485
+ """`[ ready]` in plain mode; an icon and a coloured upper-case word on a terminal (`pad=False`: no trailing spaces)."""
486
+ if not self.th.on:
487
+ return f"[{state:>8}]"
488
+ icon, col = {READY: ("ok", "green"), MISSING: ("warn", "yellow"), OUTDATED: ("warn", "yellow"), INVALID: ("bad", "red")}.get(state, ("warn", "yellow"))
489
+ word = state.upper().ljust(8) if pad else state.upper()
490
+ return f"{self.th.icon(icon, col, 'bold')} {self.th.paint(word, col, 'bold')}"
491
+
492
+ def _outcome(self, text: str) -> str:
493
+ if not self.th.on:
494
+ return text
495
+ if text.startswith("installed"):
496
+ return self.th.paint(f"{self.th.icons['ok']} {text}", "green", "bold")
497
+ if text.startswith("skipped"):
498
+ return self.th.paint(f"{self.th.icons['ok']} {text}", "green")
499
+ if text.startswith("FAILED"):
500
+ return self.th.paint(f"{self.th.icons['bad']} {text}", "red", "bold")
501
+ return self.th.paint(f"{self.th.icons['warn']} {text}", "yellow")
502
+
503
+ def _confirm(self, question: str, assume_yes: bool) -> bool:
504
+ if assume_yes:
505
+ return True
506
+ if not self.interactive:
507
+ return False
508
+ try:
509
+ return self.ask(f"{question} [Y/n] ").strip().lower() in ("", "y", "yes")
510
+ except EOFError:
511
+ return False
512
+
513
+ def choose(self) -> list[str]:
514
+ """Interactive menu. Returns the selected keys; raises ValueError after three invalid answers."""
515
+ self.show_banner()
516
+ self.say(INTRO)
517
+ self.say()
518
+ statuses = {k: self.status(k) for k in KEYS}
519
+ self.say(self.th.paint("Components:", "bold", "cyan"))
520
+ for i, c in enumerate(COMPONENTS, 1):
521
+ tag = f"[{statuses[c.key].state}]" if not self.th.on else self._tag(statuses[c.key].state, pad=False)
522
+ self.say(f" {i}. {c.title} {tag}\n " + self.th.paint(f"{c.purpose} ({c.size})", "dim"))
523
+ self.say(f" {len(KEYS) + 1}. All components")
524
+ self.say()
525
+ self.say("Choose by number or name, comma-separated; 'all -osv-db' installs everything except osv-db. Enter = all.")
526
+ for _ in range(3):
527
+ try:
528
+ answer = self.ask("Install which? ").strip()
529
+ except EOFError:
530
+ break
531
+ try:
532
+ return parse_selection(answer or "all")
533
+ except ValueError as e:
534
+ self.say(f" {e}")
535
+ raise ValueError("no valid selection")
536
+
537
+
538
+ def status_report(statuses: list[Status], th: "ui.Theme | None" = None) -> str:
539
+ th = th or ui.PLAIN
540
+ lines = []
541
+ for s in statuses:
542
+ c = BY_KEY[s.key]
543
+ if th.on:
544
+ icon, col = {READY: ("ok", "green"), INVALID: ("bad", "red")}.get(s.state, ("warn", "yellow"))
545
+ head = f"{th.icon(icon, col, 'bold')} {th.paint(s.state.upper().ljust(8), col, 'bold')} {th.paint(c.title, 'bold')}"
546
+ body = f"\n enables: {', '.join(c.checks)}" + (f"\n {th.paint(clean_text(s.detail, 240), 'dim')}" if s.detail else "")
547
+ fix = f"\n {th.paint('fix:', 'cyan', 'bold')} saferun init --only {s.key}" + (" --repair" if s.key == "analyzer" and s.state == INVALID else "")
548
+ lines.append(head + body + (fix if not s.ready else ""))
549
+ continue
550
+ lines.append(f"{s.state.upper():<9} {c.title}\n enables: {', '.join(c.checks)}"
551
+ + (f"\n {clean_text(s.detail, 240)}" if s.detail else ""))
552
+ if not s.ready:
553
+ lines.append(f" fix: saferun init --only {s.key}" + (" --repair" if s.key == "analyzer" and s.state == INVALID else ""))
554
+ return "\n".join(lines)
555
+
556
+
557
+ def status_json(statuses: list[Status]) -> str:
558
+ return json.dumps({"schema_version": 1, "ok": all(s.ready for s in statuses), "components": [s.to_dict() for s in statuses]},
559
+ indent=2, sort_keys=True)