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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """SafeRun: secure verification of AI-generated code."""
2
+
3
+ __version__ = "0.0.2"
saferun/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
saferun/cli.py ADDED
@@ -0,0 +1,533 @@
1
+ """SafeRun command line: `saferun scan PATH`. A thin front end over `saferun.engine.validate_project`.
2
+
3
+ Exit codes (deterministic, documented in docs/cli.md):
4
+ 0 PASS all applicable checks completed with no findings above informational
5
+ 1 FAIL at least one high or critical finding
6
+ 2 WARNING lower-severity findings, or the project was not fully checked (never reported as PASS)
7
+ 64 usage invalid command line, or the path is missing / not a directory
8
+ 70 fatal the project could not be validated at all (e.g. the safe copy failed) or an internal error
9
+ 130 interrupted
10
+
11
+ `saferun verify-images` uses 0 (all pinned images verified), 1 (a pinned image is missing or its digest does not match),
12
+ 64 (usage) and 70 (Docker unusable).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import math
19
+ import re
20
+ import sys
21
+ import time
22
+ from pathlib import Path
23
+
24
+ from .engine import osvdb
25
+ from .engine import ValidationOptions, ValidationReport, Verdict, validate_project
26
+ from .engine.model import CheckResult, clean_text
27
+ from .sandbox import SandboxError
28
+ from .sandbox.verify import verify_images
29
+ from . import __version__, hostos
30
+ from . import setup as setup_mod
31
+ from . import ui
32
+
33
+ EXIT_PASS, EXIT_FAIL, EXIT_WARNING, EXIT_USAGE, EXIT_FATAL, EXIT_INTERRUPTED = 0, 1, 2, 64, 70, 130
34
+ VERDICT_EXIT = {Verdict.PASS: EXIT_PASS, Verdict.FAIL: EXIT_FAIL, Verdict.WARNING: EXIT_WARNING}
35
+ MAX_TIMEOUT_S = 3600.0
36
+
37
+ EPILOG = """\
38
+ examples:
39
+ saferun scan ./my-project human-readable report
40
+ saferun scan ./my-project --format json machine-readable report (schema_version 1)
41
+ saferun init first-run setup: install and verify what the checks need (--all for scripts/CI)
42
+ saferun run script.py scan a script, then run it ONLY inside the sandbox (refused on FAIL)
43
+ saferun doctor diagnose the installation (PASS / WARNING / FAIL)
44
+ saferun status show which components are installed and verified
45
+ saferun osv-db update download the offline OSV vulnerability data used by the dependency scan
46
+ saferun scan . --no-javascript-check --timeout 60
47
+
48
+ exit codes:
49
+ 0 PASS 1 FAIL (high/critical finding) 2 WARNING (lower-severity findings or incomplete scan)
50
+ 64 invalid usage / bad path 70 fatal error (no verdict possible) 130 interrupted
51
+
52
+ PASS means the checks that exist found nothing; it does not prove the code is safe. The project is copied
53
+ by the sandbox sanitizer first; project code is never executed on the host and dependencies are never installed.
54
+ """
55
+
56
+
57
+ class _Parser(argparse.ArgumentParser):
58
+ def error(self, message): # argparse exits 2 by default, which here means WARNING
59
+ self.print_usage(sys.stderr)
60
+ self.exit(EXIT_USAGE, f"{self.prog}: error: {message}\n")
61
+
62
+
63
+ def _timeout(value: str) -> float:
64
+ try:
65
+ t = float(value)
66
+ except ValueError:
67
+ raise argparse.ArgumentTypeError(f"not a number: {value!r}") from None
68
+ if not math.isfinite(t) or not 0 < t <= MAX_TIMEOUT_S:
69
+ raise argparse.ArgumentTypeError(f"must be greater than 0 and at most {MAX_TIMEOUT_S:g} seconds")
70
+ return t
71
+
72
+
73
+ def build_parser() -> argparse.ArgumentParser:
74
+ p = _Parser(prog="saferun", description="SafeRun: static validation of a local project (scan), and sandboxed execution of a script that passed it (run).",
75
+ epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter)
76
+ p.add_argument("--version", action="version", version=f"saferun {__version__}")
77
+ sub = p.add_subparsers(dest="command", metavar="COMMAND", parser_class=_Parser)
78
+ common = argparse.ArgumentParser(add_help=False)
79
+ common.add_argument("--color", choices=("auto", "always", "never"), default="auto",
80
+ help="coloured output and progress animation: auto = only on a terminal (honours NO_COLOR), always, never")
81
+ v = sub.add_parser("scan", help="validate a local project directory", description=(
82
+ "Validate a local project directory with the SafeRun engine and print the verdict."),
83
+ epilog=EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter, parents=[common])
84
+ v.add_argument("path", help="project directory to validate")
85
+ v.add_argument("--format", choices=("text", "json"), default="text", help="output format (default: text)")
86
+ v.add_argument("--timeout", type=_timeout, default=120.0, metavar="SECONDS",
87
+ help="time limit per external tool / sandbox run (default: 120)")
88
+ v.add_argument("--osv-offline", action="store_true",
89
+ help="accepted for compatibility: the dependency scan is always offline (nothing is sent to OSV.dev); "
90
+ "it needs the data from `saferun osv-db update`")
91
+ v.add_argument("--no-python-syntax", action="store_true", help="skip the sandboxed Python syntax check")
92
+ v.add_argument("--no-python-analysis", action="store_true", help="skip Semgrep analysis of Python files")
93
+ v.add_argument("--no-dependency-scan", action="store_true", help="skip the OSV dependency scan (no network use)")
94
+ v.add_argument("--no-javascript-check", action="store_true", help="skip the sandboxed `node --check`")
95
+ w = sub.add_parser("verify-images", help="check that local Docker images match the pinned digests", description=(
96
+ "Check that the container images SafeRun runs are present locally with the digests pinned in the code. "
97
+ "Read-only: nothing is pulled or changed. Exit 0 when every image is verified, 1 when one is missing or "
98
+ "does not match, 70 when Docker is unusable."), parents=[common])
99
+ w.add_argument("--format", choices=("text", "json"), default="text", help="output format (default: text)")
100
+ d = sub.add_parser("osv-db", help="manage the offline OSV vulnerability data", description=(
101
+ "The dependency scan runs without network access, against local OSV data. `update` downloads it (public data from "
102
+ "osv-vulnerabilities.storage.googleapis.com; nothing about any project is sent)."))
103
+ dsub = d.add_subparsers(dest="osv_db_command", metavar="ACTION", parser_class=_Parser)
104
+ u = dsub.add_parser("update", help="download/refresh the OSV data for PyPI and npm")
105
+ u.add_argument("--if-stale", action="store_true", help="only download when the local data is missing, invalid or older than --max-age-days")
106
+ u.add_argument("--max-age-days", type=float, default=osvdb.DEFAULT_MAX_AGE_DAYS, help="age limit used by --if-stale (default: 30)")
107
+ u.add_argument("--dir", help="database directory (default: $SAFERUN_OSV_DB or ~/.local/share/saferun/osv-db)")
108
+ u.add_argument("--allow-unverified", action="store_true",
109
+ help="accept a download for which the server sent no MD5 hash (refused by default); it is recorded as unverified")
110
+ st = dsub.add_parser("status", help="validate the local OSV data and show its age; exit 0 if usable and fresh, 1 otherwise", parents=[common])
111
+ st.add_argument("--dir", help="database directory (default: $SAFERUN_OSV_DB or ~/.local/share/saferun/osv-db)")
112
+ st.add_argument("--max-age-days", type=float, default=osvdb.DEFAULT_MAX_AGE_DAYS, help="older data is reported as stale (default: 30)")
113
+ w.add_argument("--base-only", action="store_true", help="only the python/node base images, not the locally built analyzer image")
114
+ w.add_argument("--only", metavar="NAME[,NAME]", help="only these pinned images (python, node); implies --base-only")
115
+ w.add_argument("--signature", action="store_true", help="ONLINE: also verify the cosign signature of the published analyzer release "
116
+ "(needs cosign and internet; fails if no release is configured)")
117
+ ini = sub.add_parser("init", help="first-run setup: install and verify what the checks need", description=(
118
+ "Interactive (or scripted) setup. Detects what is already installed and verified, skips it, and installs/repairs/updates the rest: "
119
+ "the Python and Node.js sandbox images (pulled by pinned digest), the analyzer image (Semgrep, OSV-Scanner, rules) and the local OSV "
120
+ "vulnerability data. Nothing is trusted because a tag or directory exists; everything is verified after installation."), parents=[common])
121
+ ini.add_argument("--all", action="store_true", help="install/verify every component, without prompts")
122
+ ini.add_argument("--only", metavar="COMPONENT[,COMPONENT]", help=f"only these components ({', '.join(setup_mod.KEYS)}), without prompts")
123
+ ini.add_argument("--skip", metavar="COMPONENT[,COMPONENT]", help="exclude these components (everything else if --only is not given), without prompts")
124
+ ini.add_argument("--yes", "-y", action="store_true", help="answer yes to confirmations in interactive mode")
125
+ ini.add_argument("--build-local", action="store_true", help="build the analyzer image locally (docker/analyzer/build.py) instead of installing the "
126
+ "signed published image when a signed release exists (today init always builds locally); never done automatically")
127
+ ini.add_argument("--repair", action="store_true", help="allow rebuilding an analyzer image that failed verification (it replaces the image and its lock)")
128
+ r = sub.add_parser("run", help="scan a Python script and, if the scan allows it, run it inside the sandbox", description=(
129
+ "Scans the script, refuses to run it on FAIL (or on WARNING unless --allow-warnings), otherwise runs it ONLY inside the hardened Docker sandbox "
130
+ "(no network, read-only filesystem, non-root, resource limits). It never runs on the host. A passing scan is not proof of safety."), parents=[common])
131
+ r.add_argument("script", help="Python script (.py) to scan and run")
132
+ r.add_argument("script_args", nargs=argparse.REMAINDER, help="arguments for the script; saferun options must come BEFORE the script name")
133
+ r.add_argument("--project-dir", metavar="DIR", help="stage and scan this whole directory (it must contain the script) instead of only the script")
134
+ r.add_argument("--timeout", type=_timeout, default=30.0, metavar="SECONDS", help="run time limit inside the sandbox (default: 30)")
135
+ r.add_argument("--allow-warnings", action="store_true", help="run despite a WARNING that comes from a COMPLETE scan with no high/critical finding (never on FAIL, an incomplete check or a serious finding)")
136
+ dc = sub.add_parser("doctor", help="diagnose the installation (read-only): PASS / WARNING / FAIL per check", description=(
137
+ "Checks Python, the operating system, Docker, the sandbox and analyzer images, cosign, the OSV data (age and integrity) and the data directory. "
138
+ "Changes nothing. Exit 0 if nothing FAILED, 1 otherwise."), parents=[common])
139
+ dc.add_argument("--online", action="store_true", help="also verify the cosign signature of the published analyzer release (uses the network)")
140
+ dc.add_argument("--format", choices=("text", "json"), default="text", help="output format (default: text)")
141
+ st = sub.add_parser("status", help="show which components are installed and verified", description=(
142
+ "Verifies every component now (image digests, analyzer lock/labels/rules, OSV data integrity and age). Exit 0 if all selected are ready, 1 otherwise."), parents=[common])
143
+ st.add_argument("--only", metavar="COMPONENT[,COMPONENT]", help=f"only these components ({', '.join(setup_mod.KEYS)})")
144
+ st.add_argument("--format", choices=("text", "json"), default="text", help="output format (default: text)")
145
+ return p
146
+
147
+
148
+ _LINE_BREAKS = str.maketrans({c: " " for c in "\n\t\u2028\u2029\x85\x0b\x0c"})
149
+
150
+
151
+ def _s(value) -> str:
152
+ """One printed field = one line: untrusted values (file names, messages) cannot add lines to the text report."""
153
+ return clean_text(value, 300).translate(_LINE_BREAKS)
154
+
155
+
156
+ def _location(f) -> str:
157
+ if f.path is None:
158
+ return "-"
159
+ loc = _s(f.path)
160
+ if f.line is not None:
161
+ loc += f":{f.line}"
162
+ return loc
163
+
164
+
165
+ _HASH = re.compile(r"^[0-9a-f]{40}$", re.I)
166
+
167
+
168
+ def _vkey(v: str):
169
+ """Sortable key for a release number ('1.26.17' -> (1, 26, 17)); None for commit hashes and anything non-numeric."""
170
+ if _HASH.match(v) or not re.match(r"^\d+(\.\d+)*", v):
171
+ return None
172
+ return tuple(int(n) for n in re.match(r"^\d+(?:\.\d+)*", v).group(0).split("."))
173
+
174
+
175
+ def upgrade_plan(report: ValidationReport) -> list[dict]:
176
+ """One row per vulnerable package: how many findings it has, the worst severity, and the lowest release that fixes
177
+ every one of them (for each finding the nearest fixed release above the installed one; for the package the highest of
178
+ those). `upgrade_to` is None when no finding names a usable release. Rows are worst-first."""
179
+ rows: dict[tuple, dict] = {}
180
+ for f in report.findings:
181
+ if not f.package:
182
+ continue
183
+ key = (f.package.ecosystem, f.package.name, f.package.version)
184
+ row = rows.setdefault(key, {"ecosystem": key[0], "name": key[1], "version": key[2], "findings": 0,
185
+ "severity": f.severity, "upgrade_to": None, "_key": None})
186
+ row["findings"] += 1
187
+ if f.severity.rank > row["severity"].rank:
188
+ row["severity"] = f.severity
189
+ cur = _vkey(str(f.package.version))
190
+ fixes = [(k, v) for v in f.fixed_versions if (k := _vkey(v)) is not None]
191
+ above = [x for x in fixes if cur is None or x[0] > cur] or fixes
192
+ if above:
193
+ best = min(above)
194
+ if row["_key"] is None or best[0] > row["_key"]:
195
+ row["_key"], row["upgrade_to"] = best
196
+ return sorted(rows.values(), key=lambda r: (-r["severity"].rank, r["name"]))
197
+
198
+
199
+ def _plan_lines(plan: list[dict], paint=None) -> list[str]:
200
+ """The upgrade-plan table as aligned lines (shared by the plain and the styled report)."""
201
+ paint = paint or (lambda t, *a: t)
202
+ head = ("PACKAGE", "INSTALLED", "UPGRADE TO", "FINDINGS", "WORST")
203
+ body = [(_s(r["name"]), _s(r["version"]), ("\u2265 " + _s(r["upgrade_to"])) if r["upgrade_to"] else "no fix listed",
204
+ str(r["findings"]), r["severity"].value.upper()) for r in plan]
205
+ w = [max(len(x[i]) for x in [head] + body) for i in range(5)]
206
+ fmt = lambda row: " " + " ".join(c.ljust(w[i]) for i, c in enumerate(row)).rstrip()
207
+ return [paint(fmt(head), "dim")] + [fmt(row) for row in body]
208
+
209
+
210
+ def render_text(report: ValidationReport, path: str) -> str:
211
+ out = [f"SafeRun validation of {_s(path)}", "",
212
+ f"Verdict: {report.verdict.value} Scan complete: {'yes' if report.complete else 'NO - see coverage gaps'}",
213
+ "Reasons:"]
214
+ out += [f" - {_s(r)}" for r in report.reasons]
215
+ out += ["", "Checks:"]
216
+ for c in report.checks:
217
+ extra = f" - {_s(c.detail)}" if c.detail else ""
218
+ out.append(f" {c.status.value:<14} {_s(c.name)} ({len(c.findings)} finding(s)){extra}")
219
+ findings = sorted(report.findings, key=lambda f: (-f.severity.rank, f.check, f.path or "", f.line or 0, f.rule_id))
220
+ plan = upgrade_plan(report)
221
+ if plan:
222
+ out += ["", f"Upgrade plan ({len(plan)} package(s)):"] + _plan_lines(plan)
223
+ out += ["", f"Findings ({len(findings)}):" if findings else "Findings: none"]
224
+ for f in findings:
225
+ out.append(f" [{f.severity.value.upper()}] {_s(f.rule_id)} {_location(f)}")
226
+ out.append(f" {_s(f.message)}")
227
+ if f.package:
228
+ out.append(f" package: {_s(f.package.name)} {_s(f.package.version)} ({_s(f.package.ecosystem)})")
229
+ if f.fixed_versions:
230
+ out.append(f" fixed in: {', '.join(_s(v) for v in f.fixed_versions)}")
231
+ gaps = _gaps(report)
232
+ if gaps:
233
+ out += ["", "Coverage gaps and operational problems:"] + [f" - {g}" for g in gaps]
234
+ out += ["", "PASS means the existing checks found nothing; it is not a proof that the code is safe."]
235
+ return "\n".join(out) + "\n"
236
+
237
+
238
+ def _gaps(report: ValidationReport) -> list[str]:
239
+ gaps = []
240
+ for c in report.checks:
241
+ if c.status.operational_problem or c.errors or c.truncated_findings:
242
+ head = f"{_s(c.name)}: {c.status.value}" + (f" - {_s(c.detail)}" if c.detail else "")
243
+ gaps.append(head)
244
+ gaps += [f" error: {_s(e)}" for e in c.errors]
245
+ if c.truncated_findings:
246
+ gaps.append(f" {c.truncated_findings} further finding(s) not listed (cap reached)")
247
+ gaps += [f" note: {_s(n)}" for n in c.notes]
248
+ for s in report.workspace.get("skipped", []):
249
+ gaps.append(f"not analysed (skipped): {_s(s['path'])} ({_s(s['reason'])})")
250
+ for w in report.workspace.get("withheld", []):
251
+ gaps.append(f"not analysed (withheld): {_s(w['path'])} ({_s(w['reason'])})")
252
+ return gaps
253
+
254
+
255
+ def _next_steps(report: ValidationReport) -> list[str]:
256
+ cats = {f.category for f in report.findings}
257
+ steps = []
258
+ if "dependency" in cats:
259
+ steps.append("Upgrade the vulnerable packages to the 'fixed in' versions, then scan again.")
260
+ if "code" in cats:
261
+ steps.append("Review or fix the flagged lines, then scan again.")
262
+ if "syntax" in cats:
263
+ steps.append("Fix the syntax errors, then scan again.")
264
+ if "workspace" in cats:
265
+ steps.append("Remove or rotate the credential-like files that were found.")
266
+ if _gaps(report):
267
+ steps.append("Some checks did not fully run: `saferun status` shows what is missing and the exact fix.")
268
+ return steps
269
+
270
+
271
+ def render_styled(report: ValidationReport, path: str, th: "ui.Theme", elapsed: float | None = None) -> str:
272
+ """The same information as render_text, laid out for quick reading on a terminal: coloured verdict banner, one icon per check,
273
+ severity tags in colour, wrapped messages, a severity tally and the next steps. Every value goes through the same
274
+ one-line sanitizer as render_text BEFORE it is painted, so untrusted text cannot inject escape sequences."""
275
+ import shutil
276
+ import textwrap
277
+ width = max(60, min(110, shutil.get_terminal_size((100, 24)).columns - 8))
278
+ p = th.paint
279
+ out = [f"{p('SafeRun', 'bold', 'cyan')} validation of {p(_s(path), 'bold')}", ""]
280
+ verdict = report.verdict.value
281
+ banner = p(f" {th.icons['ok'] if verdict == 'PASS' else th.icons['bad'] if verdict == 'FAIL' else th.icons['warn']} {verdict} ", *ui.VERDICT_STYLE[verdict])
282
+ done = p("scan complete", "green") if report.complete else p("scan NOT complete: see coverage gaps", "yellow", "bold")
283
+ out.append(f"{banner} {done}" + (p(f" {elapsed:.1f} s", "dim") if elapsed is not None else ""))
284
+ out += ["", p("Why this verdict", "bold", "cyan")]
285
+ out += [f" {th.icons['dot']} {_s(r)}" for r in report.reasons]
286
+ out += ["", p("Checks", "bold", "cyan")]
287
+ for c in report.checks:
288
+ worst = max((f.severity.rank for f in c.findings), default=0)
289
+ if c.status.value == "passed":
290
+ icon, col = th.icon("ok", "green", "bold"), "green"
291
+ elif c.status.value == "findings":
292
+ icon, col = (th.icon("bad", "red", "bold"), "red") if worst >= 4 else (th.icon("warn", "yellow", "bold"), "yellow")
293
+ elif c.status.value == "error":
294
+ icon, col = th.icon("bad", "red", "bold"), "red"
295
+ elif c.status.value in ("incomplete", "timeout", "unavailable"):
296
+ icon, col = th.icon("warn", "yellow", "bold"), "yellow"
297
+ else:
298
+ icon, col = th.icon("skip", "dim"), "dim"
299
+ n = len(c.findings)
300
+ extra = f" {p('- ' + _s(c.detail), 'dim')}" if c.detail else ""
301
+ out.append(f" {icon} {p(f'{c.status.value:<14}', col)} {p(_s(c.name), 'bold')} {p(f'({n} finding(s))', 'dim')}{extra}")
302
+ findings = sorted(report.findings, key=lambda f: (-f.severity.rank, f.check, f.path or "", f.line or 0, f.rule_id))
303
+ plan = upgrade_plan(report)
304
+ if plan:
305
+ out += ["", p(f"Upgrade plan ({len(plan)} package(s))", "bold", "cyan")]
306
+ lines = _plan_lines(plan, p)
307
+ out.append(lines[0])
308
+ for r, line in zip(plan, lines[1:]):
309
+ sev = r["severity"].value
310
+ out.append(line[:-len(sev)] + p(sev.upper(), *ui.SEVERITY_STYLE[sev]))
311
+ if findings:
312
+ tally = {}
313
+ for f in findings:
314
+ tally[f.severity.value] = tally.get(f.severity.value, 0) + 1
315
+ order = ("critical", "high", "medium", "unknown", "low", "info")
316
+ out += ["", p(f"Findings ({len(findings)})", "bold", "cyan") + " "
317
+ + " ".join(p(f"{k.upper()} {tally[k]}", *ui.SEVERITY_STYLE[k]) for k in order if k in tally)]
318
+ else:
319
+ out += ["", p("Findings: none", "green")]
320
+ for f in findings:
321
+ out.append("")
322
+ tag = p(f"[{f.severity.value.upper()}]", *ui.SEVERITY_STYLE[f.severity.value])
323
+ out.append(f" {tag} {p(_s(f.rule_id), 'bold')} {_location(f)}")
324
+ for line in textwrap.wrap(_s(f.message), width=width) or [""]:
325
+ out.append(" " + p(line, "dim"))
326
+ if f.package:
327
+ out.append(f" package: {_s(f.package.name)} {_s(f.package.version)} ({_s(f.package.ecosystem)})")
328
+ if f.fixed_versions:
329
+ out.append(" " + p(f"fixed in: {', '.join(_s(v) for v in f.fixed_versions)}", "green"))
330
+ gaps = _gaps(report)
331
+ if gaps:
332
+ out += ["", p("Coverage gaps and operational problems", "bold", "yellow")] + [f" {th.icons['warn']} {g}" if not g.startswith(" ") else g for g in gaps]
333
+ steps = _next_steps(report)
334
+ if steps:
335
+ out += ["", p("What to do next", "bold", "cyan")] + [f" {th.icons['arrow']} {s}" for s in steps]
336
+ out += ["", p("PASS means the existing checks found nothing; it is not a proof that the code is safe.", "dim")]
337
+ return "\n".join(out) + "\n"
338
+
339
+
340
+ def _fail(message: str, code: int) -> int:
341
+ print(f"saferun: error: {clean_text(message, 1000)}", file=sys.stderr)
342
+ return code
343
+
344
+
345
+ def _osv_db(args) -> int:
346
+ db_dir = Path(args.dir) if getattr(args, "dir", None) else osvdb.default_dir()
347
+ if args.osv_db_command == "status":
348
+ if not math.isfinite(args.max_age_days) or args.max_age_days <= 0:
349
+ return _fail("--max-age-days must be a positive number", EXIT_USAGE)
350
+ rep = osvdb.validate(db_dir, max_age_days=args.max_age_days)
351
+ print(f"OSV database: {_s(db_dir)}")
352
+ for line in rep.notes:
353
+ print(f" {_s(line)}")
354
+ for line in rep.problems:
355
+ print(f" PROBLEM: {_s(line)}")
356
+ if rep.stale:
357
+ print(f" STALE: {_s(rep.stale)}")
358
+ print(" usable and fresh" if rep.ok and not rep.stale else " NOT usable for a clean scan result")
359
+ return EXIT_PASS if rep.ok and not rep.stale else EXIT_FAIL
360
+ if args.osv_db_command != "update":
361
+ return _fail("usage: saferun osv-db update|status [--dir DIR]", EXIT_USAGE)
362
+ if args.if_stale:
363
+ if not math.isfinite(args.max_age_days) or args.max_age_days <= 0:
364
+ return _fail("--max-age-days must be a positive number", EXIT_USAGE)
365
+ rep = osvdb.validate(db_dir, list(osvdb.ECOSYSTEMS), max_age_days=args.max_age_days)
366
+ if rep.ok and not rep.stale:
367
+ print(f"OSV database is current ({rep.age_days:.1f} days old); nothing to do")
368
+ return EXIT_PASS
369
+ try:
370
+ result = osvdb.update(db_dir, allow_unverified=args.allow_unverified)
371
+ except KeyboardInterrupt:
372
+ return _fail("interrupted", EXIT_INTERRUPTED)
373
+ except Exception as e: # noqa: BLE001 - report, never traceback
374
+ return _fail(f"OSV database update failed (existing data left in place): {type(e).__name__}: {e}", EXIT_FATAL)
375
+ for eco, e in result.items():
376
+ print(f"{eco}: {e['size']} bytes, {e['entries']} advisories, sha256 {e['sha256'][:16]}…, MD5 {e['md5']} -> "
377
+ f"{db_dir / 'osv-scalibr' / eco / 'all.zip'}")
378
+ if any(e["md5"] != "verified" for e in result.values()):
379
+ print("warning: at least one download was accepted WITHOUT a server-provided hash (--allow-unverified)", file=sys.stderr)
380
+ return EXIT_PASS
381
+
382
+
383
+ def _init(args) -> int:
384
+ try:
385
+ chosen = setup_mod.select(args.all, args.only, args.skip)
386
+ except ValueError as e:
387
+ return _fail(str(e), EXIT_USAGE)
388
+ tty = sys.stdin.isatty() and sys.stdout.isatty()
389
+ if chosen is None and not tty:
390
+ return _fail("not a terminal: choose components with --all, --only or --skip (e.g. `saferun init --all`)", EXIT_USAGE)
391
+ theme = ui.theme_for(sys.stdout, args.color)
392
+ s = setup_mod.Setup(interactive=chosen is None, tty=sys.stdout.isatty(), theme=theme, build_local=args.build_local)
393
+ try:
394
+ if chosen is None:
395
+ try:
396
+ chosen = s.choose()
397
+ except ValueError as e:
398
+ return _fail(str(e), EXIT_USAGE)
399
+ else:
400
+ for line in setup_mod.banner_lines(theme):
401
+ s.say(line)
402
+ s.say(setup_mod.INTRO)
403
+ s.say()
404
+ return s.run(chosen, repair=args.repair, assume_yes=args.yes)
405
+ except KeyboardInterrupt:
406
+ return _fail("interrupted", EXIT_INTERRUPTED)
407
+
408
+
409
+ def _status(args) -> int:
410
+ try:
411
+ keys = setup_mod.parse_list(args.only) or list(setup_mod.KEYS)
412
+ except ValueError as e:
413
+ return _fail(str(e), EXIT_USAGE)
414
+ statuses = setup_mod.all_status(keys)
415
+ sys.stdout.write((setup_mod.status_json(statuses) if args.format == "json"
416
+ else setup_mod.status_report(statuses, ui.theme_for(sys.stdout, args.color))) + "\n")
417
+ return EXIT_PASS if all(x.ready for x in statuses) else EXIT_FAIL
418
+
419
+
420
+ def _check_signature() -> dict:
421
+ """`verify-images --signature`: the cosign signature of the configured analyzer release (online). Fails closed."""
422
+ from .sandbox import images as im
423
+ from .sandbox.signature import SignatureError, verify_release_signature
424
+ rel = im.ANALYZER_RELEASE
425
+ if rel is None:
426
+ return {"ok": False, "reference": "", "detail": "no analyzer release is configured (ANALYZER_RELEASE is not set), so there is nothing to verify"}
427
+ try:
428
+ n = verify_release_signature(rel)
429
+ except SignatureError as e:
430
+ return {"ok": False, "reference": rel.reference, "detail": str(e)}
431
+ return {"ok": True, "reference": rel.reference, "detail": f"{rel.reference}: {n} valid signature(s) from the expected release workflow"}
432
+
433
+
434
+ def _verify_images(fmt: str, analyzer: bool = True, only: str | None = None, signature: bool = False) -> int:
435
+ from .sandbox.images import PINNED_IMAGES
436
+ names = [n for n in (only or "").replace(",", " ").split() if n]
437
+ unknown = [n for n in names if n not in {i.name for i in PINNED_IMAGES}]
438
+ if unknown:
439
+ return _fail(f"unknown image name(s): {', '.join(unknown)} (choose from: {', '.join(i.name for i in PINNED_IMAGES)})", EXIT_USAGE)
440
+ try:
441
+ checks = (verify_images(tuple(i for i in PINNED_IMAGES if i.name in names), analyzer=analyzer) if names
442
+ else verify_images(analyzer=analyzer))
443
+ except SandboxError as e:
444
+ return _fail(f"cannot verify images: {e}", EXIT_FATAL)
445
+ ok = all(c.ok for c in checks)
446
+ sig = _check_signature() if signature else None
447
+ if sig is not None:
448
+ ok = ok and sig["ok"]
449
+ if fmt == "json":
450
+ doc = {"schema_version": 1, "ok": ok, "images": [c.to_dict() for c in checks]}
451
+ if sig is not None:
452
+ doc["signature"] = sig
453
+ sys.stdout.write(json.dumps(doc, indent=2, sort_keys=True) + "\n")
454
+ else:
455
+ for c in checks:
456
+ sys.stdout.write(f"{c.status.upper():<9} {_s(c.reference)}\n")
457
+ if c.detail:
458
+ sys.stdout.write(f" {_s(c.detail)}\n")
459
+ if sig is not None:
460
+ sys.stdout.write(f"{'VERIFIED' if sig['ok'] else 'FAILED':<9} cosign signature: {_s(sig['detail'])}\n")
461
+ sys.stdout.write("all pinned images verified\n" if ok else "NOT VERIFIED: do not rely on these images\n")
462
+ return EXIT_PASS if ok else EXIT_FAIL
463
+
464
+
465
+ def main(argv: list[str] | None = None) -> int:
466
+ hostos.enable_ansi() # Windows consoles: escape sequences and a code page that cannot crash on output; no-op elsewhere
467
+ parser = build_parser()
468
+ try:
469
+ args = parser.parse_args(argv)
470
+ except SystemExit as e: # --help -> 0; usage errors -> 64 (see _Parser.error)
471
+ return e.code if isinstance(e.code, int) else EXIT_USAGE
472
+ if args.command == "init":
473
+ return _init(args)
474
+ if args.command == "status":
475
+ return _status(args)
476
+ if args.command == "run":
477
+ from . import runcmd
478
+ rest = args.script_args[1:] if args.script_args[:1] == ["--"] else args.script_args
479
+ th = ui.theme_for(sys.stdout, args.color)
480
+ try:
481
+ return runcmd.run_script(args.script, rest, project_dir=args.project_dir, timeout_s=args.timeout, allow_warnings=args.allow_warnings,
482
+ out=sys.stdout, err=sys.stderr,
483
+ render=lambda rep: (render_styled(rep, args.script, th) if th.on else render_text(rep, args.script)) + "\n")
484
+ except KeyboardInterrupt:
485
+ return _fail("interrupted", EXIT_INTERRUPTED)
486
+ if args.command == "doctor":
487
+ from . import doctor
488
+ checks = doctor.run_checks(online=args.online)
489
+ sys.stdout.write((doctor.render_json(checks) if args.format == "json" else doctor.render_text(checks)) + "\n")
490
+ return doctor.exit_code(checks)
491
+ if args.command == "osv-db":
492
+ return _osv_db(args)
493
+ if args.command == "verify-images":
494
+ return _verify_images(args.format, analyzer=not (args.base_only or args.only), only=args.only, signature=args.signature)
495
+ if args.command != "scan":
496
+ parser.print_help(sys.stderr)
497
+ return EXIT_USAGE
498
+ project = Path(args.path)
499
+ if not project.exists():
500
+ return _fail(f"path does not exist: {args.path}", EXIT_USAGE)
501
+ if not project.is_dir():
502
+ return _fail(f"not a directory: {args.path}", EXIT_USAGE)
503
+ options = ValidationOptions(
504
+ python_analysis=not args.no_python_analysis, python_syntax=not args.no_python_syntax,
505
+ dependency_scan=not args.no_dependency_scan, javascript_check=not args.no_javascript_check,
506
+ tool_timeout_s=args.timeout, osv_offline=True) # always offline; --osv-offline is a no-op
507
+ t0 = time.monotonic()
508
+ try:
509
+ with ui.Spinner(sys.stderr, "Scanning: syntax, static analysis, dependencies (each in a locked-down container)", ui.theme_for(sys.stderr, args.color)):
510
+ report = validate_project(project, options)
511
+ except KeyboardInterrupt:
512
+ return _fail("interrupted", EXIT_INTERRUPTED)
513
+ except SandboxError as e:
514
+ return _fail(f"cannot validate (no verdict): {e}", EXIT_FATAL)
515
+ except Exception as e: # noqa: BLE001 - never crash with a traceback or a misleading success code
516
+ return _fail(f"internal error (no verdict): {type(e).__name__}: {e}", EXIT_FATAL)
517
+ if args.format == "json":
518
+ sys.stdout.write(json.dumps(report.to_dict(), indent=2, sort_keys=True) + "\n")
519
+ else:
520
+ th = ui.theme_for(sys.stdout, args.color)
521
+ sys.stdout.write(render_styled(report, args.path, th, time.monotonic() - t0) if th.on else render_text(report, args.path))
522
+ sys.stdout.flush()
523
+ try:
524
+ eth = ui.theme_for(sys.stderr, args.color)
525
+ for hint in setup_mod.scan_hints(report): # stderr only, after the report: stdout and exit codes are unaffected
526
+ print(eth.paint(hint, "yellow") if eth.on else hint, file=sys.stderr)
527
+ except Exception: # noqa: BLE001 - a hint must never change the outcome of a scan
528
+ pass
529
+ return VERDICT_EXIT[report.verdict]
530
+
531
+
532
+ if __name__ == "__main__":
533
+ sys.exit(main())
saferun/dockerdiag.py ADDED
@@ -0,0 +1,65 @@
1
+ """Classify why Docker is (not) usable and say what to do about it, per platform. Read-only: it only runs `docker version/info`."""
2
+ from __future__ import annotations
3
+
4
+ import shutil
5
+ import subprocess
6
+ from dataclasses import dataclass
7
+
8
+ from . import hostos
9
+ from .sandbox import SandboxError
10
+ from .sandbox import docker as dk
11
+
12
+ OK, NOT_INSTALLED, DAEMON_DOWN, PERMISSION, REMOTE, UNKNOWN = "ok", "not-installed", "daemon-not-running", "permission-denied", "remote", "unknown"
13
+
14
+ INSTALL_URLS = {
15
+ "linux": "https://docs.docker.com/engine/install/ (then: sudo usermod -aG docker $USER, and log in again)",
16
+ "macos": "https://docs.docker.com/desktop/setup/install/mac-install/ (Docker Desktop, or Colima/OrbStack; start it once)",
17
+ "windows": "https://docs.docker.com/desktop/setup/install/windows-install/ (Docker Desktop with the WSL 2 backend, Linux containers)",
18
+ }
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class DockerDiagnosis:
23
+ state: str
24
+ detail: str
25
+ guidance: str = ""
26
+
27
+ @property
28
+ def ok(self) -> bool:
29
+ return self.state == OK
30
+
31
+ def message(self) -> str:
32
+ return self.detail + (f". {self.guidance}" if self.guidance else "")
33
+
34
+
35
+ def platform_name() -> str:
36
+ return "windows" if hostos.IS_WINDOWS else "macos" if hostos.IS_MACOS else "linux"
37
+
38
+
39
+ def diagnose(*, which=shutil.which, run=subprocess.run, check_local=dk.check_local_daemon, platform: str | None = None) -> DockerDiagnosis:
40
+ """Never raises."""
41
+ plat = platform or platform_name()
42
+ if not which("docker"):
43
+ return DockerDiagnosis(NOT_INSTALLED, "Docker is not installed (no `docker` on PATH)", f"Install it: {INSTALL_URLS[plat]}")
44
+ try:
45
+ r = run([which("docker"), "info", "--format", "{{.ServerVersion}}"], capture_output=True, text=True, errors="replace",
46
+ timeout=30, stdin=subprocess.DEVNULL)
47
+ except (OSError, subprocess.TimeoutExpired) as e:
48
+ return DockerDiagnosis(UNKNOWN, f"`docker info` did not complete: {e}", "Check that Docker is running")
49
+ if r.returncode != 0:
50
+ err = " ".join((r.stderr or "").split())
51
+ low = err.lower()
52
+ if "permission denied" in low:
53
+ fix = ("Add your user to the docker group (sudo usermod -aG docker $USER) and log in again" if plat == "linux"
54
+ else "Make sure your user may use Docker (Docker Desktop is running under your account)")
55
+ return DockerDiagnosis(PERMISSION, "Docker is installed but your user may not use it (permission denied)", fix)
56
+ start = {"linux": "Start it: sudo systemctl start docker", "macos": "Start Docker Desktop (or Colima/OrbStack)",
57
+ "windows": "Start Docker Desktop and wait until it reports it is running"}[plat]
58
+ if any(w in low for w in ("cannot connect", "is the docker daemon running", "error during connect", "pipe", "no such file")):
59
+ return DockerDiagnosis(DAEMON_DOWN, "Docker is installed but its daemon is not running", start)
60
+ return DockerDiagnosis(UNKNOWN, f"`docker info` failed: {err[:200] or 'no message'}", start)
61
+ try:
62
+ check_local()
63
+ except SandboxError as e:
64
+ return DockerDiagnosis(REMOTE, str(e), "SafeRun needs a local Docker daemon (unset DOCKER_HOST / use the default context)")
65
+ return DockerDiagnosis(OK, f"Docker {r.stdout.strip() or '?'} is running and local")