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/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)
|