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
@@ -0,0 +1,41 @@
1
+ """The file-system operations the Windows copy engine (sandbox/copy_windows.py) is written against.
2
+
3
+ `copy_windows` contains the whole algorithm (three passes, signatures, listings, limits, exposure rules); an `FsOps` implementation only
4
+ opens, lists, reads and describes things. The real one (`hostos.winfs.WinOps`) uses Win32 handles; the tests also run the algorithm
5
+ against a POSIX implementation so its logic is exercised on every platform.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Any, Protocol
11
+
12
+
13
+ class EntryReplaced(OSError):
14
+ """An opened object is not the one that was named (moved, replaced, or reached through a different path)."""
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Info:
19
+ kind: str # file | dir | symlink | reparse | other
20
+ sig: tuple # identity + state; the two passes and the final check must see the same value
21
+ ident: tuple # identity shared by all hard links of a file
22
+ size: int
23
+ nlink: int
24
+ private: bool = False # only meaningful when the probe asked for it: narrower than the project root, or unverifiable
25
+
26
+
27
+ class Node(Protocol):
28
+ info: Info
29
+ path: str
30
+
31
+
32
+ class FsOps(Protocol):
33
+ def open_root(self, path: str) -> Any: ... # directory node; reparse point or remote path -> OSError
34
+ def listdir(self, d: Any) -> list[str]: ...
35
+ def probe(self, d: Any, name: str, with_acl: bool) -> Info: ... # describe WITHOUT following; FileNotFoundError if gone
36
+ def open_dir(self, d: Any, name: str) -> Any: ... # locks it against rename / delete while open
37
+ def open_file(self, d: Any, name: str) -> Any: ... # read-only; denies others write / delete while open
38
+ def refresh(self, node: Any) -> Info: ... # state of the OPEN object (fstat)
39
+ def read(self, node: Any, n: int) -> bytes: ...
40
+ def streams(self, d: Any, name: str) -> list[str]: ... # data stream names other than the default one
41
+ def close(self, node: Any) -> None: ...
@@ -0,0 +1,82 @@
1
+ """Starting, interrupting and killing a whole process tree.
2
+
3
+ POSIX: a new session / process group, signalled as a group (exactly what SafeRun always did).
4
+ Windows: the child starts SUSPENDED, is put into a kill-on-close job object and only then resumed, so it cannot spawn a process that
5
+ escapes the job; killing the job ends the whole tree, and it also dies if SafeRun itself dies. Interrupt is CTRL_BREAK to the process group.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import signal
11
+ import subprocess
12
+ import sys
13
+
14
+ IS_WINDOWS = sys.platform == "win32"
15
+
16
+ if IS_WINDOWS:
17
+ from . import _win
18
+
19
+ _CREATE_SUSPENDED = 0x00000004
20
+
21
+
22
+ def popen_kwargs() -> dict:
23
+ if IS_WINDOWS:
24
+ return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP | _CREATE_SUSPENDED, "close_fds": True}
25
+ return {"start_new_session": True, "close_fds": True}
26
+
27
+
28
+ def after_spawn(proc: subprocess.Popen) -> None:
29
+ """Windows: confine `proc` to a job and let it run. Raises OSError (after killing it) if that is impossible. POSIX: nothing to do."""
30
+ if not IS_WINDOWS:
31
+ return
32
+ job = None
33
+ try:
34
+ job = _win.create_kill_on_close_job()
35
+ _win.assign_to_job(job, int(proc._handle))
36
+ proc._saferun_job = job
37
+ _win.resume_process(int(proc._handle))
38
+ except BaseException:
39
+ try:
40
+ proc.kill()
41
+ except OSError:
42
+ pass
43
+ if job:
44
+ _win.close(job)
45
+ raise
46
+
47
+
48
+ def kill_tree(proc: subprocess.Popen) -> None:
49
+ if IS_WINDOWS:
50
+ job = getattr(proc, "_saferun_job", None)
51
+ if job:
52
+ _win.terminate_job(job)
53
+ else:
54
+ try:
55
+ os.killpg(proc.pid, signal.SIGKILL)
56
+ except (ProcessLookupError, PermissionError):
57
+ pass
58
+ try:
59
+ proc.kill()
60
+ except OSError:
61
+ pass
62
+
63
+
64
+ def interrupt_tree(proc: subprocess.Popen) -> None:
65
+ if IS_WINDOWS:
66
+ try:
67
+ proc.send_signal(signal.CTRL_BREAK_EVENT)
68
+ except (OSError, ValueError):
69
+ pass
70
+ else:
71
+ try:
72
+ os.killpg(proc.pid, signal.SIGINT)
73
+ except (ProcessLookupError, PermissionError):
74
+ pass
75
+
76
+
77
+ def release(proc: subprocess.Popen) -> None:
78
+ """Windows: close the job handle, which also ends anything the finished process left behind. POSIX: nothing."""
79
+ job = getattr(proc, "_saferun_job", None)
80
+ if IS_WINDOWS and job:
81
+ proc._saferun_job = None
82
+ _win.close(job)
@@ -0,0 +1,164 @@
1
+ """`FsOps` for Windows (NTFS / ReFS) on Win32 handles. Imported only on Windows.
2
+
3
+ Why this is race-resistant (the Windows counterpart of openat + O_NOFOLLOW):
4
+ * every entry is opened with FILE_FLAG_OPEN_REPARSE_POINT, so a symlink / junction / mount point is opened as itself and never followed;
5
+ * every directory stays open, with a share mode that forbids rename and delete, until its subtree has been walked, so NTFS refuses to
6
+ move or replace it or any ancestor on the way down;
7
+ * files are read with a share mode that forbids writers and deleters;
8
+ * the final path of each opened handle must equal parent path + name;
9
+ * identity is read from the open handle (128-bit file id + volume), never from a path.
10
+ Adding or removing entries inside a directory is still possible, so the listing / signature comparison of the three passes stays.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import msvcrt
15
+ import os
16
+ from dataclasses import dataclass, field
17
+
18
+ from . import _win, acl
19
+ from .fsops import EntryReplaced, Info
20
+
21
+ _SIG_ATTRS = 0x1 | 0x2 | 0x4 | 0x10 | 0x400 # READONLY HIDDEN SYSTEM DIRECTORY REPARSE_POINT; ARCHIVE / indexing flags change by themselves
22
+ _ALL_SHARE = _win.SHARE_READ | _win.SHARE_WRITE | _win.SHARE_DELETE
23
+ _BENIGN_STREAMS = frozenset({"::$DATA", ":Zone.Identifier:$DATA"}) # default data; "downloaded from the internet" mark (never copied)
24
+
25
+
26
+ @dataclass
27
+ class WinNode:
28
+ path: str
29
+ info: Info
30
+ handle: int | None = None # directories
31
+ fd: int | None = None # files (owns the handle)
32
+ final: str = ""
33
+ extra: dict = field(default_factory=dict)
34
+
35
+
36
+ def _norm(p: str) -> str:
37
+ return os.path.normcase(p).rstrip("\\")
38
+
39
+
40
+ def _mk_info(h: int, acl_state: tuple | None, with_acl: bool) -> Info:
41
+ fi = _win.file_info(h)
42
+ attrs = fi["attrs"]
43
+ if attrs & _win.ATTR_REPARSE_POINT:
44
+ kind = "symlink" if fi["tag"] == _win.TAG_SYMLINK else "reparse"
45
+ else:
46
+ kind = "dir" if attrs & _win.ATTR_DIRECTORY else "file"
47
+ size = fi["size"] if kind == "file" else 0
48
+ sig = (kind, attrs & _SIG_ATTRS, fi["ident"], size, fi["written"], fi["changed"], fi["nlink"])
49
+ private = False
50
+ if with_acl and kind in ("file", "dir"):
51
+ private = not _readable_like_root(h, kind == "dir", acl_state)
52
+ return Info(kind, sig, fi["ident"], size, fi["nlink"], private)
53
+
54
+
55
+ def _readable_like_root(h: int, directory: bool, state) -> bool:
56
+ caller, baseline = state
57
+ try:
58
+ desc = acl.parse_descriptor(_win.security_descriptor(h))
59
+ return acl.at_least_as_readable(desc.dacl, baseline, caller, directory=directory)
60
+ except (OSError, acl.AclUnverifiable):
61
+ return False # cannot verify -> treated as private (withheld), never as shareable
62
+
63
+
64
+ class WinOps:
65
+ def __init__(self):
66
+ self._caller = _win.current_token()[0]
67
+ self._state: tuple | None = None # (caller token, root DACL) once the root is open
68
+
69
+ def open_root(self, path: str) -> WinNode:
70
+ real = os.path.realpath(path)
71
+ if _win.is_remote_drive(real):
72
+ raise OSError(f"{real} is on a network drive; SafeRun copies local projects only")
73
+ h = _win.open_handle(real, _win.FILE_LIST_DIRECTORY | _win.FILE_READ_ATTRIBUTES | _win.READ_CONTROL, _win.SHARE_READ,
74
+ flags=_win.FLAG_BACKUP_SEMANTICS | _win.FLAG_OPEN_REPARSE_POINT)
75
+ try:
76
+ final = _win.final_path(h)
77
+ if _norm(final) != _norm(real):
78
+ raise EntryReplaced(f"the project root resolves to {final}, not {real}")
79
+ info = _mk_info(h, None, False)
80
+ if info.kind != "dir":
81
+ raise NotADirectoryError(f"not a plain directory (reparse point?): {real}")
82
+ try:
83
+ self._state = (self._caller, acl.parse_descriptor(_win.security_descriptor(h)).dacl)
84
+ except (OSError, acl.AclUnverifiable) as e:
85
+ raise OSError(f"cannot read the access-control list of the project root, so private files cannot be told apart: {e}") from None
86
+ return WinNode(final, info, handle=h, final=final)
87
+ except BaseException:
88
+ _win.close(h)
89
+ raise
90
+
91
+ def listdir(self, d: WinNode) -> list[str]:
92
+ with os.scandir(_win.ext(d.path)) as it:
93
+ return [e.name for e in it]
94
+
95
+ def _child(self, d: WinNode, name: str) -> str:
96
+ return d.path.rstrip("\\") + "\\" + name
97
+
98
+ def probe(self, d: WinNode, name: str, with_acl: bool) -> Info:
99
+ path = self._child(d, name)
100
+ private_forced = False
101
+ try:
102
+ h = _win.open_handle(path, _win.FILE_READ_ATTRIBUTES | _win.READ_CONTROL, _ALL_SHARE,
103
+ flags=_win.FLAG_BACKUP_SEMANTICS | _win.FLAG_OPEN_REPARSE_POINT)
104
+ except PermissionError: # cannot even read its ACL: describe it, but it counts as private
105
+ h = _win.open_handle(path, _win.FILE_READ_ATTRIBUTES, _ALL_SHARE,
106
+ flags=_win.FLAG_BACKUP_SEMANTICS | _win.FLAG_OPEN_REPARSE_POINT)
107
+ private_forced = True
108
+ try:
109
+ info = _mk_info(h, self._state, with_acl and not private_forced)
110
+ if private_forced and with_acl:
111
+ info = Info(info.kind, info.sig, info.ident, info.size, info.nlink, True)
112
+ return info
113
+ finally:
114
+ _win.close(h)
115
+
116
+ def _check_path(self, d: WinNode, name: str, h: int) -> str:
117
+ """Final path of the open handle `h`. Raises without closing `h`: the caller owns it and closes it on any failure."""
118
+ final = _win.final_path(h)
119
+ if _norm(final) != _norm(d.final.rstrip("\\") + "\\" + name):
120
+ raise EntryReplaced(f"{name} is not located where its directory says")
121
+ return final
122
+
123
+ def open_dir(self, d: WinNode, name: str) -> WinNode:
124
+ h = _win.open_handle(self._child(d, name), _win.FILE_LIST_DIRECTORY | _win.FILE_READ_ATTRIBUTES | _win.READ_CONTROL, _win.SHARE_READ,
125
+ flags=_win.FLAG_BACKUP_SEMANTICS | _win.FLAG_OPEN_REPARSE_POINT)
126
+ try:
127
+ final = self._check_path(d, name, h)
128
+ return WinNode(self._child(d, name), _mk_info(h, None, False), handle=h, final=final)
129
+ except BaseException:
130
+ _win.close(h)
131
+ raise
132
+
133
+ def open_file(self, d: WinNode, name: str) -> WinNode:
134
+ h = _win.open_handle(self._child(d, name), _win.GENERIC_READ | _win.FILE_READ_ATTRIBUTES, _win.SHARE_READ,
135
+ flags=_win.FLAG_OPEN_REPARSE_POINT)
136
+ try:
137
+ final = self._check_path(d, name, h)
138
+ fd = msvcrt.open_osfhandle(h, os.O_RDONLY | os.O_BINARY | os.O_NOINHERIT)
139
+ except BaseException:
140
+ _win.close(h)
141
+ raise
142
+ try: # from here the descriptor owns the handle
143
+ return WinNode(self._child(d, name), _mk_info(h, None, False), fd=fd, final=final)
144
+ except BaseException:
145
+ os.close(fd)
146
+ raise
147
+
148
+ def refresh(self, node: WinNode) -> Info:
149
+ h = node.handle if node.handle is not None else msvcrt.get_osfhandle(node.fd)
150
+ return _mk_info(h, None, False)
151
+
152
+ def read(self, node: WinNode, n: int) -> bytes:
153
+ return os.read(node.fd, n)
154
+
155
+ def streams(self, d: WinNode, name: str) -> list[str]:
156
+ return [s for s in _win.stream_names(self._child(d, name)) if s not in _BENIGN_STREAMS]
157
+
158
+ def close(self, node: WinNode) -> None:
159
+ if node.fd is not None:
160
+ os.close(node.fd)
161
+ node.fd = None
162
+ elif node.handle is not None:
163
+ _win.close(node.handle)
164
+ node.handle = None
saferun/runcmd.py ADDED
@@ -0,0 +1,139 @@
1
+ """`saferun run SCRIPT [ARGS...]`: scan a Python script, and only if the scan allows it, run it INSIDE the existing hardened sandbox.
2
+
3
+ The script never runs on the host. The scan is the normal `validate_project` (syntax, Semgrep, dependencies); the run is the normal
4
+ `run_sandboxed` (pinned python image, no network, read-only root and project, non-root, no capabilities, resource limits, output cap and
5
+ secret redaction). By default only the script itself is staged and scanned (a copy in a private temporary directory); `--project-dir`
6
+ stages a whole directory that contains it. A passing scan is NOT a proof of safety: it only means the checks found nothing they know about.
7
+
8
+ Refusal (script not run): FAIL -> exit 1; WARNING -> exit 2. --allow-warnings only accepts a WARNING that comes from a COMPLETE scan whose
9
+ findings are all below high severity; an incomplete, errored, timed-out, skipped or unavailable check, or any high/critical finding, is never overridden.
10
+ Other exit codes: 0 the script ran and exited 0; 3 it ran and exited non-zero / timed out / was killed; 64 usage; 70 could not be scanned or sandboxed.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import re
16
+ import shutil
17
+ import stat
18
+ import tempfile
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ from .engine import ValidationOptions, Verdict, validate_project
23
+ from .engine.model import CheckStatus, Severity
24
+ from .sandbox import SandboxError, SandboxLimits, run_sandboxed
25
+ from .sandbox.profiles import PYTHON
26
+
27
+ EXIT_PASS, EXIT_FAIL, EXIT_WARNING, EXIT_SCRIPT_FAILED, EXIT_USAGE, EXIT_FATAL = 0, 1, 2, 3, 64, 70
28
+ MAX_SCRIPT_BYTES = 5 * 1024 * 1024
29
+
30
+
31
+ _COMPLETED = frozenset({CheckStatus.PASSED, CheckStatus.FINDINGS, CheckStatus.NOT_APPLICABLE})
32
+ _SERIOUS = frozenset({Severity.CRITICAL, Severity.HIGH})
33
+
34
+
35
+ def warning_blockers(report) -> list[str]:
36
+ """Reasons a WARNING can NOT be overridden by --allow-warnings (empty list: it may be). Fail closed."""
37
+ out = []
38
+ if not report.complete:
39
+ out.append("the scan is incomplete")
40
+ out += [f"check {c.name} did not complete ({c.status.value})" for c in report.checks if c.status not in _COMPLETED]
41
+ out += [f"{f.severity.value} finding {f.rule_id}" for f in report.findings if f.severity in _SERIOUS]
42
+ return out
43
+
44
+
45
+ # The script's output is untrusted text: terminal escape sequences in it could clear the screen, rewrite earlier lines or fake SafeRun's own
46
+ # messages. Everything except newline and tab that is a control, escape, C1 or bidi-override character is replaced before it reaches the terminal.
47
+ _UNSAFE_OUTPUT = re.compile("[\x00-\x08\x0b-\x1f\x7f-\x9f\u200e\u200f\u202a-\u202e\u2066-\u2069]")
48
+
49
+
50
+ def safe_output(text: str) -> str:
51
+ return _UNSAFE_OUTPUT.sub("?", text.replace("\r\n", "\n"))
52
+
53
+
54
+ class RunUsageError(ValueError):
55
+ pass
56
+
57
+
58
+ @dataclass
59
+ class Staged:
60
+ root: Path # directory handed to scan and sandbox
61
+ rel_script: str # script path relative to root, posix
62
+
63
+
64
+ def stage(script: Path, project_dir: Path | None, tmp_root: Path) -> Staged:
65
+ """Describe what will be scanned and run. Raises RunUsageError."""
66
+ try:
67
+ st = os.lstat(script)
68
+ except OSError:
69
+ raise RunUsageError(f"script does not exist: {script}") from None
70
+ if stat.S_ISLNK(st.st_mode) or not stat.S_ISREG(st.st_mode):
71
+ raise RunUsageError(f"not a regular file (symlinks are refused): {script}")
72
+ if script.suffix != ".py":
73
+ raise RunUsageError("only Python scripts (.py) can be run")
74
+ if st.st_size > MAX_SCRIPT_BYTES:
75
+ raise RunUsageError(f"script is larger than {MAX_SCRIPT_BYTES} bytes")
76
+ if project_dir is not None:
77
+ root = project_dir.resolve()
78
+ if not root.is_dir():
79
+ raise RunUsageError(f"not a directory: {project_dir}")
80
+ try:
81
+ rel = script.resolve().relative_to(root)
82
+ except ValueError:
83
+ raise RunUsageError("the script must be inside --project-dir") from None
84
+ return Staged(root, rel.as_posix())
85
+ root = tmp_root / "project"
86
+ root.mkdir(mode=0o755)
87
+ os.chmod(root, 0o755) # the sandbox user is "other": it must be able to read what it is given
88
+ dest = root / script.name
89
+ shutil.copyfile(script, dest)
90
+ os.chmod(dest, 0o644)
91
+ return Staged(root, script.name)
92
+
93
+
94
+ def run_script(script: str, script_args: list[str], *, project_dir: str | None = None, timeout_s: float = 30.0,
95
+ allow_warnings: bool = False, out, err, render=None, scan=validate_project, sandbox=run_sandboxed) -> int:
96
+ """Returns the process exit code. `scan` and `sandbox` are injectable for tests."""
97
+ with tempfile.TemporaryDirectory(prefix="saferun-run-") as tmp:
98
+ try:
99
+ staged = stage(Path(script), Path(project_dir) if project_dir else None, Path(tmp))
100
+ except RunUsageError as e:
101
+ err.write(f"saferun: {e}\n")
102
+ return EXIT_USAGE
103
+ try:
104
+ report = scan(staged.root, ValidationOptions())
105
+ except SandboxError as e:
106
+ err.write(f"saferun: cannot scan, so the script was NOT run (no verdict): {e}\n")
107
+ return EXIT_FATAL
108
+ except Exception as e: # noqa: BLE001 - never run anything after an unexpected scan failure
109
+ err.write(f"saferun: internal error while scanning, so the script was NOT run: {type(e).__name__}: {e}\n")
110
+ return EXIT_FATAL
111
+ if render:
112
+ out.write(render(report))
113
+ if report.verdict is Verdict.FAIL:
114
+ err.write("saferun: the scan found high-severity problems: the script was NOT run.\n")
115
+ return EXIT_FAIL
116
+ if report.verdict is not Verdict.PASS:
117
+ blockers = warning_blockers(report) if allow_warnings else ["--allow-warnings was not given"]
118
+ if blockers:
119
+ err.write("saferun: the scan verdict is WARNING: the script was NOT run (" + "; ".join(blockers[:5]) + "). Fix the findings or the "
120
+ "missing setup (`saferun doctor`); --allow-warnings never overrides an incomplete check or a high/critical finding.\n")
121
+ return EXIT_WARNING
122
+ err.write("saferun: scan verdict " + report.verdict.value.upper() + ". A scan is not proof of safety; running inside the sandbox "
123
+ "(no network, read-only filesystem, non-root, resource limits).\n")
124
+ try:
125
+ res = sandbox(staged.root, [PYTHON.interpreter, "-I", f"/workspace/{staged.rel_script}", *script_args],
126
+ limits=SandboxLimits(timeout_s=timeout_s), image=PYTHON.image)
127
+ except SandboxError as e:
128
+ err.write(f"saferun: the sandbox could not run the script: {e}\n")
129
+ return EXIT_FATAL
130
+ out.write(safe_output(res.stdout))
131
+ if res.stderr:
132
+ err.write(safe_output(res.stderr))
133
+ if res.timed_out:
134
+ err.write(f"saferun: the script was stopped after {timeout_s:g} s.\n")
135
+ if res.oom_killed:
136
+ err.write("saferun: the script was killed for exceeding the memory limit.\n")
137
+ if res.output_truncated:
138
+ err.write("saferun: output was truncated at the sandbox limit.\n")
139
+ return EXIT_PASS if res.exit_code == 0 and not res.timed_out and not res.oom_killed else EXIT_SCRIPT_FAILED
@@ -0,0 +1,11 @@
1
+ from .config import (
2
+ DEFAULT_IMAGE, ControlStatus, SandboxError, SandboxLimits, SandboxResult, SensitivePolicy,
3
+ SkippedEntry, WithheldEntry, WorkspaceChangedError, WorkspaceLimitError, WorkspaceLimits,
4
+ )
5
+ from .reaper import ReapReport, reap_orphans
6
+ from .runner import run_sandboxed
7
+
8
+ __all__ = [
9
+ "ControlStatus", "DEFAULT_IMAGE", "ReapReport", "SandboxError", "SandboxLimits", "SandboxResult",
10
+ "SensitivePolicy", "SkippedEntry", "WithheldEntry", "WorkspaceChangedError", "WorkspaceLimitError", "WorkspaceLimits", "reap_orphans", "run_sandboxed",
11
+ ]
@@ -0,0 +1,192 @@
1
+ """The analyzer image (Phase 5B): where Semgrep and OSV-Scanner run, instead of on the host.
2
+
3
+ The image is built locally by docker/analyzer/build.sh, which records its image ID in a lock file. Before every use
4
+ `verify_analyzer_image` checks that
5
+ * the lock file exists and the local image `saferun-analyzer:local` has exactly that ID (a retagged or rebuilt image is refused),
6
+ * the labels baked in at build time equal the pins in images.py (tool versions) and the SHA-256 of the rules file in this checkout.
7
+ The lock file is only trusted if it and its directory are owned by the current user and owner-only (0600 / 0700), and it is opened
8
+ without following symlinks. It returns the image ID, and callers run the sandbox *by that ID*, so the image cannot change between check and use.
9
+ Limits: the lock is a local file, not a registry digest or a signature. Whoever can write it AND re-tag or replace the image (the same user,
10
+ root, or any member of the `docker` group, which is root-equivalent) can substitute an image; the labels checked below are set by whoever
11
+ builds the image and prove nothing on their own.
12
+ There is no fallback: any problem raises, and the engine reports the check as unavailable/error, never as clean.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ import os
19
+ import re
20
+ import stat
21
+ import tempfile
22
+ from pathlib import Path
23
+
24
+ from .. import hostos
25
+ from . import docker as dk
26
+ from .config import SandboxError
27
+ from .images import OSV_SCANNER_VERSION, SEMGREP_VERSION
28
+
29
+ ANALYZER_REPO_TAG = "saferun-analyzer:local"
30
+ SEMGREP_EXE = "/usr/local/bin/semgrep"
31
+ OSV_EXE = "/usr/local/bin/osv-scanner"
32
+ RULES_PATH = "/opt/saferun/rules/python.yml"
33
+ _ID_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
34
+
35
+
36
+ class AnalyzerUnavailable(SandboxError):
37
+ """The analyzer image is not built or not usable (no host fallback exists)."""
38
+
39
+
40
+ class AnalyzerIntegrityError(SandboxError):
41
+ """The image present locally is not the one that was built and locked (digest, tool or rules mismatch)."""
42
+
43
+
44
+ def lock_path() -> Path:
45
+ env = os.environ.get("SAFERUN_ANALYZER_LOCK")
46
+ if env:
47
+ return Path(env)
48
+ return hostos.user_data_base() / "saferun" / "analyzer-image-id"
49
+
50
+
51
+ def rules_sha256() -> str:
52
+ from ..engine.semgrep import RULES_FILE # local import: the engine imports this module
53
+ return hashlib.sha256(RULES_FILE.read_bytes()).hexdigest()
54
+
55
+
56
+ def _lock_problem(p: Path) -> str | None:
57
+ """Why the lock file's directory or the file itself cannot be trusted, or None. Both must belong to the current user and be
58
+ owner-only (directory 0700, file 0600), and neither may be a symlink (only the immediate parent directory is examined)."""
59
+ if hostos.IS_WINDOWS: # owner + ACL instead of uid + mode bits (hostos.private_path_problem); reparse points are refused there
60
+ return hostos.private_path_problem(p.parent, directory=True)
61
+ me = os.geteuid()
62
+ try:
63
+ dst = os.lstat(p.parent)
64
+ except OSError as e:
65
+ return f"cannot examine the lock directory {p.parent}: {e}"
66
+ if not stat.S_ISDIR(dst.st_mode):
67
+ return f"the lock directory {p.parent} is not a real directory (a symlink?)"
68
+ if dst.st_uid != me:
69
+ return f"the lock directory {p.parent} is owned by uid {dst.st_uid}, not by the current user ({me})"
70
+ if dst.st_mode & 0o077:
71
+ return f"the lock directory {p.parent} is accessible to others (mode {stat.S_IMODE(dst.st_mode):04o}); run: chmod 700 {p.parent}"
72
+ return None
73
+
74
+
75
+ def _read_lock() -> str:
76
+ p = lock_path()
77
+ if hostos.IS_WINDOWS:
78
+ return _read_lock_windows(p)
79
+ try:
80
+ fd = os.open(p, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
81
+ except FileNotFoundError:
82
+ raise AnalyzerUnavailable(f"the analyzer image has not been installed (no lock file at {p}); run `saferun init --only analyzer`") from None
83
+ except OSError as e:
84
+ raise AnalyzerIntegrityError(f"cannot open the analyzer lock file {p} (a symlink?): {e}") from None
85
+ try:
86
+ st = os.fstat(fd)
87
+ problem = None
88
+ if not stat.S_ISREG(st.st_mode):
89
+ problem = "is not a regular file"
90
+ elif st.st_uid != os.geteuid():
91
+ problem = f"is owned by uid {st.st_uid}, not by the current user"
92
+ elif st.st_mode & 0o077:
93
+ problem = f"is accessible to others (mode {stat.S_IMODE(st.st_mode):04o}); run: chmod 600 {p}"
94
+ if problem is None:
95
+ dir_problem = _lock_problem(p)
96
+ if dir_problem:
97
+ raise AnalyzerIntegrityError(f"analyzer lock not trusted: {dir_problem}")
98
+ else:
99
+ raise AnalyzerIntegrityError(f"analyzer lock file {p} {problem}; it is not trusted")
100
+ text = os.read(fd, 4096).decode("ascii", "replace").strip()
101
+ finally:
102
+ os.close(fd)
103
+ if not _ID_RE.match(text):
104
+ raise AnalyzerIntegrityError(f"analyzer lock file {p} is malformed")
105
+ return text
106
+
107
+
108
+ def _read_lock_windows(p: Path) -> str:
109
+ if not p.exists():
110
+ raise AnalyzerUnavailable(f"the analyzer image has not been installed (no lock file at {p}); run `saferun init --only analyzer`")
111
+ problem = hostos.private_path_problem(p, directory=False) or hostos.private_path_problem(p.parent, directory=True)
112
+ if problem:
113
+ raise AnalyzerIntegrityError(f"analyzer lock not trusted: {problem}")
114
+ try:
115
+ text = p.read_bytes()[:4096].decode("ascii", "replace").strip()
116
+ except OSError as e:
117
+ raise AnalyzerIntegrityError(f"cannot read the analyzer lock file {p}: {e}") from None
118
+ if not _ID_RE.match(text):
119
+ raise AnalyzerIntegrityError(f"analyzer lock file {p} is malformed")
120
+ return text
121
+
122
+
123
+ def write_lock(image_id: str) -> Path:
124
+ """Record `image_id` as the trusted analyzer image (used by docker/analyzer/build.sh). Creates the lock directory (0700) if it
125
+ does not exist; an EXISTING directory is never chmod'ed, it must already be owner-only. The file is written 0600, atomically."""
126
+ if not isinstance(image_id, str) or not _ID_RE.match(image_id):
127
+ raise SandboxError(f"not an image ID: {image_id!r}")
128
+ p = lock_path()
129
+ if p.parent.is_symlink():
130
+ raise SandboxError(f"refusing to write the lock through a symlinked directory: {p.parent}")
131
+ if not p.parent.exists():
132
+ p.parent.parent.mkdir(parents=True, exist_ok=True)
133
+ p.parent.mkdir(mode=0o700)
134
+ if hostos.IS_WINDOWS:
135
+ hostos.restrict_to_owner(p.parent)
136
+ else:
137
+ os.chmod(p.parent, 0o700)
138
+ problem = _lock_problem(p)
139
+ if problem:
140
+ raise SandboxError(f"not writing the analyzer lock: {problem}")
141
+ fd, tmp = tempfile.mkstemp(dir=p.parent, prefix=".lock-")
142
+ try:
143
+ with os.fdopen(fd, "w") as f:
144
+ f.write(image_id + "\n")
145
+ if hostos.IS_WINDOWS:
146
+ hostos.restrict_to_owner(Path(tmp))
147
+ else:
148
+ os.chmod(tmp, 0o600)
149
+ os.replace(tmp, p)
150
+ except BaseException:
151
+ if os.path.exists(tmp):
152
+ os.unlink(tmp)
153
+ raise
154
+ return p
155
+
156
+
157
+ def inspect_analyzer_tag() -> tuple[str, dict]:
158
+ """(image ID, labels) of the local `saferun-analyzer:local` image. Raises AnalyzerUnavailable / AnalyzerIntegrityError."""
159
+ r = dk.docker("image", "inspect", "--format", "{{json .}}", ANALYZER_REPO_TAG)
160
+ if r.returncode != 0:
161
+ if "no such image" in r.stderr.lower():
162
+ raise AnalyzerUnavailable(f"image {ANALYZER_REPO_TAG} is not present locally; run `saferun init --only analyzer`")
163
+ raise AnalyzerUnavailable(f"cannot inspect {ANALYZER_REPO_TAG}: {r.stderr.strip()[:200]}")
164
+ try:
165
+ info = json.loads(r.stdout)
166
+ image_id = info["Id"]
167
+ labels = (info.get("Config") or {}).get("Labels") or {}
168
+ if not isinstance(image_id, str) or not isinstance(labels, dict):
169
+ raise TypeError("unexpected shape")
170
+ except (ValueError, KeyError, TypeError, AttributeError) as e:
171
+ raise AnalyzerIntegrityError(f"unexpected `docker image inspect` output for {ANALYZER_REPO_TAG}: {e}") from None
172
+ return image_id, labels
173
+
174
+
175
+ def check_labels(labels: dict) -> None:
176
+ """The labels baked in at build time must equal this checkout's pins (tool versions) and rules hash."""
177
+ expected = {"saferun.analyzer": "1", "saferun.semgrep.version": SEMGREP_VERSION,
178
+ "saferun.osv-scanner.version": OSV_SCANNER_VERSION, "saferun.rules.sha256": rules_sha256()}
179
+ wrong = [k for k, v in expected.items() if labels.get(k) != v]
180
+ if wrong:
181
+ raise AnalyzerIntegrityError(f"analyzer image labels do not match the pinned tools/rules: {', '.join(wrong)}; rebuild it")
182
+
183
+
184
+ def verify_analyzer_image() -> str:
185
+ """Return the verified image ID or raise AnalyzerUnavailable / AnalyzerIntegrityError."""
186
+ locked = _read_lock()
187
+ image_id, labels = inspect_analyzer_tag()
188
+ if image_id != locked:
189
+ raise AnalyzerIntegrityError(
190
+ f"analyzer image ID mismatch: {ANALYZER_REPO_TAG} is {image_id}, the build lock says {locked}; rebuild or investigate")
191
+ check_labels(labels)
192
+ return image_id