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/hostos/fsops.py
ADDED
|
@@ -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)
|
saferun/hostos/winfs.py
ADDED
|
@@ -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
|