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
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Windows copy engine: the same algorithm and guarantees-by-detection as copy.py, on Win32 handles (see hostos/winfs.py).
|
|
2
|
+
|
|
3
|
+
Three passes over the tree (pre-scan, verified copy, final validation) compare a signature per entry (type, attributes, 128-bit file id,
|
|
4
|
+
size, LastWrite and Change time, link count) and the name set of every directory; any replaced, added, removed or modified entry abandons
|
|
5
|
+
the whole copy (WorkspaceChangedError). On top of that, Windows lets us hold directories and files open with a share mode that forbids
|
|
6
|
+
rename / delete / write, which prevents what the POSIX engine can only detect (see winfs.py for exactly what is and is not prevented).
|
|
7
|
+
|
|
8
|
+
Not copied, and reported as skipped: symlinks and every other reparse point (junctions, mount points, cloud placeholders) and entries
|
|
9
|
+
with an alternate data stream other than Zone.Identifier. Withheld: files whose access list is narrower than the project root's
|
|
10
|
+
(or cannot be read), and files with a credential signature. Hard links with names outside the tree are skipped.
|
|
11
|
+
|
|
12
|
+
This module has no Windows-only imports: it runs against any `FsOps`, which is how its logic is tested on every platform.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import ntpath
|
|
17
|
+
import os
|
|
18
|
+
from collections import Counter
|
|
19
|
+
|
|
20
|
+
from ..hostos.fsops import EntryReplaced, FsOps, Info
|
|
21
|
+
from .config import (
|
|
22
|
+
SandboxError, SensitivePolicy, SkippedEntry, WithheldEntry, WorkspaceChangedError, WorkspaceLimits,
|
|
23
|
+
)
|
|
24
|
+
from .copy import CHUNK, CopyResult, _Budget
|
|
25
|
+
from .secrets import LEARN_MAX, Redactor, SignatureScanner
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _changed(path: str, what: str) -> WorkspaceChangedError:
|
|
29
|
+
return WorkspaceChangedError(
|
|
30
|
+
f"{path or '.'}: {what} while SafeRun was copying; the copy was abandoned (retry on a quiescent tree)")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _Sink:
|
|
34
|
+
"""Writes the copy under `dest` (a directory we own and created)."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, dest: str, ops: FsOps):
|
|
37
|
+
self.dest, self.ops = dest, ops
|
|
38
|
+
|
|
39
|
+
def _p(self, rel: str) -> str:
|
|
40
|
+
p = os.path.join(self.dest, *rel.split("/"))
|
|
41
|
+
# Extended-length form. Not os.path.abspath(): Win32 normalisation turns a final component such as CON into the device path \\.\CON.
|
|
42
|
+
# `dest` is absolute and `rel` comes from a directory listing, so cleaning up the string is all that is needed.
|
|
43
|
+
return "\\\\?\\" + ntpath.normpath(p) if os.name == "nt" else p
|
|
44
|
+
|
|
45
|
+
def mkdir(self, rel: str) -> None:
|
|
46
|
+
os.mkdir(self._p(rel))
|
|
47
|
+
|
|
48
|
+
def write(self, rel, node, want: Info, budget: _Budget, redactor: Redactor, withhold_on_match: bool):
|
|
49
|
+
dest, scanner, kind = self._p(rel), SignatureScanner(), None
|
|
50
|
+
head: bytearray | None = bytearray()
|
|
51
|
+
written = read = 0
|
|
52
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOINHERIT", 0)
|
|
53
|
+
try:
|
|
54
|
+
fd = os.open(dest, flags, 0o644)
|
|
55
|
+
except FileExistsError:
|
|
56
|
+
raise SandboxError(f"{rel}: another entry with the same name (differing only in case) was already copied") from None
|
|
57
|
+
try:
|
|
58
|
+
while chunk := self.ops.read(node, CHUNK):
|
|
59
|
+
read += len(chunk)
|
|
60
|
+
budget.add_bytes(len(chunk))
|
|
61
|
+
kind = kind or scanner.feed(chunk)
|
|
62
|
+
if kind and withhold_on_match:
|
|
63
|
+
break
|
|
64
|
+
if head is not None:
|
|
65
|
+
head += chunk
|
|
66
|
+
head = head if len(head) <= LEARN_MAX else None
|
|
67
|
+
view = memoryview(chunk)
|
|
68
|
+
while view:
|
|
69
|
+
n = os.write(fd, view)
|
|
70
|
+
written += n
|
|
71
|
+
view = view[n:]
|
|
72
|
+
finally:
|
|
73
|
+
os.close(fd)
|
|
74
|
+
if kind and withhold_on_match:
|
|
75
|
+
os.unlink(dest)
|
|
76
|
+
return kind, 0
|
|
77
|
+
after = self.ops.refresh(node)
|
|
78
|
+
if read != want.size or after.sig != want.sig:
|
|
79
|
+
raise _changed(rel, "file was modified while being read")
|
|
80
|
+
if head is not None:
|
|
81
|
+
redactor.learn(bytes(head))
|
|
82
|
+
return None, written
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class _Walker:
|
|
86
|
+
"""One traversal. sink=None is the pre-scan (nothing is read or written)."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, ops: FsOps, lim: WorkspaceLimits, policy: SensitivePolicy, sink: _Sink | None,
|
|
89
|
+
seen_links: Counter | None = None, expected: dict | None = None, expected_listings: dict | None = None):
|
|
90
|
+
self.ops, self.budget, self.policy, self.sink = ops, _Budget(lim), policy, sink
|
|
91
|
+
self.skipped: list[SkippedEntry] = []
|
|
92
|
+
self.withheld: list[WithheldEntry] = []
|
|
93
|
+
self.redactor = Redactor()
|
|
94
|
+
self.copied_files = self.copied_dirs = self.copied_bytes = 0
|
|
95
|
+
self.links: Counter = Counter()
|
|
96
|
+
self.seen_links = seen_links or Counter()
|
|
97
|
+
self.records: dict[str, tuple] = {}
|
|
98
|
+
self.listings: dict[str, frozenset] = {}
|
|
99
|
+
self.expected, self.expected_listings = expected, expected_listings
|
|
100
|
+
|
|
101
|
+
def _private(self, path: str, info: Info) -> bool:
|
|
102
|
+
if self.policy.honor_private_modes and path not in self.policy.allow_paths and info.private:
|
|
103
|
+
self.withheld.append(WithheldEntry(path, "private-mode"))
|
|
104
|
+
return True
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
def _verify(self, path: str, want: tuple) -> None:
|
|
108
|
+
if self.expected is None:
|
|
109
|
+
self.records[path] = want
|
|
110
|
+
elif self.expected.get(path) != want:
|
|
111
|
+
raise _changed(path, "entry was replaced, added or modified")
|
|
112
|
+
|
|
113
|
+
def _list(self, d, rel: str) -> list[str]:
|
|
114
|
+
exp = None
|
|
115
|
+
if self.expected_listings is not None:
|
|
116
|
+
exp = self.expected_listings.get(rel)
|
|
117
|
+
if exp is None:
|
|
118
|
+
raise _changed(rel, "directory was not part of the pre-scan")
|
|
119
|
+
names = self.ops.listdir(d)
|
|
120
|
+
for name in names:
|
|
121
|
+
self.budget.entry()
|
|
122
|
+
if exp is not None and name not in exp:
|
|
123
|
+
raise _changed(f"{rel}/{name}" if rel else name, "entry was added")
|
|
124
|
+
if exp is not None:
|
|
125
|
+
if len(names) != len(exp):
|
|
126
|
+
raise _changed(rel, "an entry was removed")
|
|
127
|
+
else:
|
|
128
|
+
self.listings[rel] = frozenset(names)
|
|
129
|
+
return sorted(names)
|
|
130
|
+
|
|
131
|
+
def walk(self, d, rel: str, depth: int) -> None:
|
|
132
|
+
for name in self._list(d, rel):
|
|
133
|
+
path = f"{rel}/{name}" if rel else name
|
|
134
|
+
try:
|
|
135
|
+
info = self.ops.probe(d, name, True)
|
|
136
|
+
except FileNotFoundError:
|
|
137
|
+
raise _changed(path, "entry was removed") from None
|
|
138
|
+
except OSError:
|
|
139
|
+
self.skipped.append(SkippedEntry(path, "unreadable"))
|
|
140
|
+
continue
|
|
141
|
+
self._verify(path, info.sig)
|
|
142
|
+
if info.kind == "symlink":
|
|
143
|
+
self.skipped.append(SkippedEntry(path, "symlink"))
|
|
144
|
+
elif info.kind == "reparse":
|
|
145
|
+
self.skipped.append(SkippedEntry(path, "reparse-point"))
|
|
146
|
+
elif info.kind == "dir":
|
|
147
|
+
self._dir(d, name, path, info, depth)
|
|
148
|
+
elif info.kind == "file":
|
|
149
|
+
self._file(d, name, path, info)
|
|
150
|
+
else:
|
|
151
|
+
self.skipped.append(SkippedEntry(path, "not-regular-file"))
|
|
152
|
+
|
|
153
|
+
def _streams_skip(self, d, name: str, path: str) -> bool:
|
|
154
|
+
try:
|
|
155
|
+
extra = self.ops.streams(d, name)
|
|
156
|
+
except OSError:
|
|
157
|
+
self.skipped.append(SkippedEntry(path, "unreadable"))
|
|
158
|
+
return True
|
|
159
|
+
if extra:
|
|
160
|
+
self.skipped.append(SkippedEntry(path, "alternate-data-stream"))
|
|
161
|
+
return True
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
def _open(self, opener, d, name: str, path: str, want: Info):
|
|
165
|
+
try:
|
|
166
|
+
node = opener(d, name)
|
|
167
|
+
except EntryReplaced:
|
|
168
|
+
raise _changed(path, "entry was replaced or moved") from None
|
|
169
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
170
|
+
raise _changed(path, "entry was replaced or removed") from None
|
|
171
|
+
except OSError:
|
|
172
|
+
self.skipped.append(SkippedEntry(path, "unreadable"))
|
|
173
|
+
return None
|
|
174
|
+
if node.info.sig != want.sig:
|
|
175
|
+
self.ops.close(node)
|
|
176
|
+
raise _changed(path, "entry was replaced or modified")
|
|
177
|
+
return node
|
|
178
|
+
|
|
179
|
+
def _dir(self, d, name, path, info: Info, depth):
|
|
180
|
+
self.budget.depth(depth + 1)
|
|
181
|
+
if self._private(path, info) or self._streams_skip(d, name, path): # same in both passes: such a directory is never entered
|
|
182
|
+
return
|
|
183
|
+
node = self._open(self.ops.open_dir, d, name, path, info)
|
|
184
|
+
if node is None:
|
|
185
|
+
return
|
|
186
|
+
try:
|
|
187
|
+
if self.sink:
|
|
188
|
+
self.sink.mkdir(path)
|
|
189
|
+
self.copied_dirs += 1
|
|
190
|
+
self.walk(node, path, depth + 1)
|
|
191
|
+
finally:
|
|
192
|
+
self.ops.close(node)
|
|
193
|
+
|
|
194
|
+
def _file(self, d, name, path, info: Info):
|
|
195
|
+
if self.sink is None: # pre-scan
|
|
196
|
+
if info.nlink > 1:
|
|
197
|
+
self.links[info.ident] += 1
|
|
198
|
+
if not (self.policy.honor_private_modes and path not in self.policy.allow_paths and info.private):
|
|
199
|
+
self.budget.add_bytes(info.size)
|
|
200
|
+
return
|
|
201
|
+
if self._private(path, info): # never opened, never read
|
|
202
|
+
return
|
|
203
|
+
if self._streams_skip(d, name, path):
|
|
204
|
+
return
|
|
205
|
+
node = self._open(self.ops.open_file, d, name, path, info)
|
|
206
|
+
if node is None:
|
|
207
|
+
return
|
|
208
|
+
try:
|
|
209
|
+
if node.info.nlink > 1 and self.seen_links[node.info.ident] != node.info.nlink:
|
|
210
|
+
self.skipped.append(SkippedEntry(path, "hardlink-external"))
|
|
211
|
+
return
|
|
212
|
+
kind, kept = self.sink.write(path, node, info, self.budget, self.redactor,
|
|
213
|
+
withhold_on_match=path not in self.policy.allow_paths)
|
|
214
|
+
if kind:
|
|
215
|
+
self.withheld.append(WithheldEntry(path, kind))
|
|
216
|
+
else:
|
|
217
|
+
self.copied_files += 1
|
|
218
|
+
self.copied_bytes += kept
|
|
219
|
+
finally:
|
|
220
|
+
self.ops.close(node)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _validate_tree(ops: FsOps, d, rel: str, records: dict, listings: dict, budget: _Budget) -> None:
|
|
224
|
+
"""Final pass: everything below `d` must still be exactly what the pre-scan recorded."""
|
|
225
|
+
expected = listings[rel]
|
|
226
|
+
names = ops.listdir(d)
|
|
227
|
+
for name in names:
|
|
228
|
+
budget.entry()
|
|
229
|
+
if name not in expected:
|
|
230
|
+
raise _changed(f"{rel}/{name}" if rel else name, "entry was added (final check)")
|
|
231
|
+
if len(names) != len(expected):
|
|
232
|
+
raise _changed(rel, "an entry was removed (final check)")
|
|
233
|
+
for name in sorted(names):
|
|
234
|
+
path = f"{rel}/{name}" if rel else name
|
|
235
|
+
try:
|
|
236
|
+
info = ops.probe(d, name, False)
|
|
237
|
+
except FileNotFoundError:
|
|
238
|
+
raise _changed(path, "entry was removed (final check)") from None
|
|
239
|
+
except OSError:
|
|
240
|
+
if path in records:
|
|
241
|
+
raise _changed(path, "entry became unreadable (final check)") from None
|
|
242
|
+
continue
|
|
243
|
+
if records.get(path) != info.sig:
|
|
244
|
+
raise _changed(path, "entry was replaced or modified (final check)")
|
|
245
|
+
if info.kind == "dir" and path in listings:
|
|
246
|
+
try:
|
|
247
|
+
node = ops.open_dir(d, name)
|
|
248
|
+
except OSError:
|
|
249
|
+
raise _changed(path, "directory was replaced or removed (final check)") from None
|
|
250
|
+
try:
|
|
251
|
+
if node.info.sig != records[path]:
|
|
252
|
+
raise _changed(path, "directory was replaced or modified (final check)")
|
|
253
|
+
_validate_tree(ops, node, path, records, listings, budget)
|
|
254
|
+
finally:
|
|
255
|
+
ops.close(node)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def copy_tree(src: str, dest: str, lim: WorkspaceLimits, policy: SensitivePolicy | None = None, ops: FsOps | None = None) -> CopyResult:
|
|
259
|
+
"""Same contract as copy.copy_tree."""
|
|
260
|
+
policy = policy or SensitivePolicy()
|
|
261
|
+
if ops is None:
|
|
262
|
+
from ..hostos.winfs import WinOps
|
|
263
|
+
ops = WinOps()
|
|
264
|
+
try:
|
|
265
|
+
root = ops.open_root(src)
|
|
266
|
+
except EntryReplaced as e:
|
|
267
|
+
raise _changed("", str(e)) from None
|
|
268
|
+
try:
|
|
269
|
+
pre = _Walker(ops, lim, policy, None)
|
|
270
|
+
pre.records[""] = root.info.sig
|
|
271
|
+
pre.walk(root, "", 0)
|
|
272
|
+
w = _Walker(ops, lim, policy, _Sink(dest, ops), pre.links, pre.records, pre.listings)
|
|
273
|
+
if ops.refresh(root).sig != pre.records[""]:
|
|
274
|
+
raise _changed("", "the project root was modified")
|
|
275
|
+
w.walk(root, "", 0)
|
|
276
|
+
if ops.refresh(root).sig != pre.records[""]:
|
|
277
|
+
raise _changed("", "the project root was modified (final check)")
|
|
278
|
+
_validate_tree(ops, root, "", pre.records, pre.listings, _Budget(lim))
|
|
279
|
+
return CopyResult(w.skipped, w.withheld, w.budget.files, w.copied_files, w.copied_dirs, w.copied_bytes, w.redactor)
|
|
280
|
+
finally:
|
|
281
|
+
ops.close(root)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""A pinned, hash-verified copy of cosign that SafeRun fetches itself, so users do not have to install it.
|
|
2
|
+
|
|
3
|
+
Trust model: the expected SHA-256 of every supported cosign binary is written in THIS file (taken from the upstream release's
|
|
4
|
+
`cosign_checksums.txt`, v2.4.3, the version the release workflow uses). A download is written to a temporary file in the private
|
|
5
|
+
tool directory, hashed, and only a file whose SHA-256 equals the pin is made executable and moved into place. The file is hashed
|
|
6
|
+
again every time it is looked up, so a binary that was modified later is ignored (and re-fetched by `saferun init`). Nothing else is
|
|
7
|
+
trusted: not the redirect target, not the file name, not the HTTP headers. HTTPS only, size-capped.
|
|
8
|
+
What this does NOT give you: it trusts the hashes in this reviewed source file, and the GitHub release that they were copied from.
|
|
9
|
+
The cosign binary then verifies the analyzer image's signature exactly as before (signature.py); the identity check is unchanged.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import os
|
|
15
|
+
import platform
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
import urllib.request
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from .. import hostos
|
|
22
|
+
from .config import SandboxError
|
|
23
|
+
|
|
24
|
+
COSIGN_VERSION = "2.4.3"
|
|
25
|
+
BASE_URL = f"https://github.com/sigstore/cosign/releases/download/v{COSIGN_VERSION}"
|
|
26
|
+
MAX_BYTES = 200 * 1024 * 1024
|
|
27
|
+
# (system, machine) -> (release asset, sha256). Source: cosign_checksums.txt of release v2.4.3.
|
|
28
|
+
ASSETS = {
|
|
29
|
+
("linux", "x86_64"): ("cosign-linux-amd64", "caaad125acef1cb81d58dcdc454a1e429d09a750d1e9e2b3ed1aed8964454708"),
|
|
30
|
+
("linux", "aarch64"): ("cosign-linux-arm64", "bd0f9763bca54de88699c3656ade2f39c9a1c7a2916ff35601caf23a79be0629"),
|
|
31
|
+
("darwin", "x86_64"): ("cosign-darwin-amd64", "98a3bfd691f42c6a5b721880116f89210d8fdff61cc0224cd3ef2f8e55a466fb"),
|
|
32
|
+
("darwin", "arm64"): ("cosign-darwin-arm64", "edfc761b27ced77f0f9ca288ff4fac7caa898e1e9db38f4dfdf72160cdf8e638"),
|
|
33
|
+
("windows", "x86_64"): ("cosign-windows-amd64.exe", "a2ac24e197111c9430cb2a98f10a641164381afb83df036504868e4ea5720800"),
|
|
34
|
+
}
|
|
35
|
+
_MACHINE_ALIASES = {"amd64": "x86_64", "x64": "x86_64", "arm64": "aarch64"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CosignFetchError(SandboxError):
|
|
39
|
+
"""The pinned cosign could not be obtained or did not match its pinned hash. Nothing was installed."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def platform_key(system: str | None = None, machine: str | None = None) -> tuple[str, str]:
|
|
43
|
+
s = (system or platform.system()).lower()
|
|
44
|
+
m = (machine or platform.machine()).lower()
|
|
45
|
+
if s == "darwin":
|
|
46
|
+
m = {"amd64": "x86_64", "x64": "x86_64"}.get(m, m) # Apple silicon reports arm64
|
|
47
|
+
else:
|
|
48
|
+
m = _MACHINE_ALIASES.get(m, m)
|
|
49
|
+
return s, m
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def asset_for(system: str | None = None, machine: str | None = None) -> tuple[str, str]:
|
|
53
|
+
key = platform_key(system, machine)
|
|
54
|
+
if key not in ASSETS:
|
|
55
|
+
raise CosignFetchError(f"no pinned cosign build for {key[0]}/{key[1]}; install cosign {COSIGN_VERSION}+ yourself "
|
|
56
|
+
"(https://docs.sigstore.dev/cosign/system_config/installation/) and put it on PATH")
|
|
57
|
+
return ASSETS[key]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def tool_dir() -> Path:
|
|
61
|
+
return hostos.user_data_base() / "saferun" / "bin"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def managed_path(system: str | None = None) -> Path:
|
|
65
|
+
exe = ".exe" if (system or sys.platform).lower().startswith(("win", "windows")) else ""
|
|
66
|
+
return tool_dir() / f"cosign-{COSIGN_VERSION}{exe}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _sha256(path: Path) -> str:
|
|
70
|
+
h = hashlib.sha256()
|
|
71
|
+
with open(path, "rb") as f:
|
|
72
|
+
while chunk := f.read(1 << 20):
|
|
73
|
+
h.update(chunk)
|
|
74
|
+
return h.hexdigest()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def managed_cosign() -> str | None:
|
|
78
|
+
"""Absolute path of the managed cosign if it exists, is a regular file (no symlink) and still has its pinned hash; else None. Never raises."""
|
|
79
|
+
try:
|
|
80
|
+
want = asset_for()[1]
|
|
81
|
+
p = managed_path()
|
|
82
|
+
if p.is_symlink() or not p.is_file() or _sha256(p) != want:
|
|
83
|
+
return None
|
|
84
|
+
return str(p.resolve())
|
|
85
|
+
except (OSError, SandboxError):
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def ensure_cosign(*, opener=None, progress=None) -> str:
|
|
90
|
+
"""Return the managed cosign, downloading and verifying it first if needed. Raises CosignFetchError (nothing is left behind)."""
|
|
91
|
+
have = managed_cosign()
|
|
92
|
+
if have:
|
|
93
|
+
return have
|
|
94
|
+
name, want = asset_for()
|
|
95
|
+
url = f"{BASE_URL}/{name}"
|
|
96
|
+
if not url.startswith("https://"):
|
|
97
|
+
raise CosignFetchError("cosign must be downloaded over https")
|
|
98
|
+
opener = opener or urllib.request.urlopen
|
|
99
|
+
d = tool_dir()
|
|
100
|
+
if d.is_symlink():
|
|
101
|
+
raise CosignFetchError(f"refusing to use a symlink as the tool directory: {d}")
|
|
102
|
+
try:
|
|
103
|
+
hostos.ensure_private_dir(d.parent) # <data>/saferun also holds the analyzer lock, which requires it to be owner-only
|
|
104
|
+
hostos.ensure_private_dir(d)
|
|
105
|
+
fd, tmp = tempfile.mkstemp(dir=d, prefix=".cosign-")
|
|
106
|
+
except OSError as e:
|
|
107
|
+
raise CosignFetchError(f"cannot prepare {d}: {e}") from None
|
|
108
|
+
try:
|
|
109
|
+
h, size = hashlib.sha256(), 0
|
|
110
|
+
with os.fdopen(fd, "wb") as out, opener(url, timeout=120) as resp: # noqa: S310 - https enforced above
|
|
111
|
+
while chunk := resp.read(1 << 20):
|
|
112
|
+
size += len(chunk)
|
|
113
|
+
if size > MAX_BYTES:
|
|
114
|
+
raise CosignFetchError(f"download exceeds {MAX_BYTES} bytes; refused")
|
|
115
|
+
h.update(chunk)
|
|
116
|
+
out.write(chunk)
|
|
117
|
+
if progress:
|
|
118
|
+
progress("cosign", size, None)
|
|
119
|
+
if h.hexdigest() != want:
|
|
120
|
+
raise CosignFetchError("the downloaded cosign does not match its pinned SHA-256; it was discarded and NOT installed")
|
|
121
|
+
if not hostos.IS_WINDOWS:
|
|
122
|
+
os.chmod(tmp, 0o700)
|
|
123
|
+
os.replace(tmp, managed_path())
|
|
124
|
+
except CosignFetchError:
|
|
125
|
+
raise
|
|
126
|
+
except OSError as e: # URLError, timeouts and disk errors are all OSError subclasses
|
|
127
|
+
raise CosignFetchError(f"could not download cosign from {url}: {e}") from None
|
|
128
|
+
finally:
|
|
129
|
+
if os.path.exists(tmp):
|
|
130
|
+
os.unlink(tmp)
|
|
131
|
+
got = managed_cosign()
|
|
132
|
+
if not got:
|
|
133
|
+
raise CosignFetchError("cosign was installed but could not be verified afterwards")
|
|
134
|
+
return got
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Thin, defensive wrappers over the docker CLI. Never uses a shell; args are lists.
|
|
2
|
+
|
|
3
|
+
Everything Docker returns is treated as untrusted input: unexpected output becomes a
|
|
4
|
+
SandboxError, never a raw exception and never a silent "success".
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
|
|
14
|
+
from .. import hostos
|
|
15
|
+
from .config import CONTAINER_LABEL, ControlStatus, SandboxError
|
|
16
|
+
from .controls import REQUIRED_DAEMON, daemon_controls
|
|
17
|
+
from .images import digest_of, repo_digests
|
|
18
|
+
|
|
19
|
+
DEFAULT_TIMEOUT = 30 # seconds per docker CLI call (module attribute so tests can shorten it)
|
|
20
|
+
# Plain image reference only: must not start with "-" (would be parsed as a docker option).
|
|
21
|
+
_IMAGE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/:@+-]{0,254}$")
|
|
22
|
+
_IMAGE_ID_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def docker_bin() -> str:
|
|
26
|
+
"""Absolute path of the docker executable (PATH is trusted; see PROJECT.md)."""
|
|
27
|
+
path = shutil.which("docker")
|
|
28
|
+
if not path:
|
|
29
|
+
raise SandboxError("docker executable not found on PATH")
|
|
30
|
+
return os.path.abspath(path)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def docker(*args: str, timeout: float | None = None) -> subprocess.CompletedProcess:
|
|
34
|
+
try:
|
|
35
|
+
return subprocess.run(
|
|
36
|
+
[docker_bin(), *args], capture_output=True, text=True, errors="replace",
|
|
37
|
+
timeout=DEFAULT_TIMEOUT if timeout is None else timeout, stdin=subprocess.DEVNULL,
|
|
38
|
+
)
|
|
39
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
40
|
+
raise SandboxError(f"docker {args[0] if args else ''} failed: {e}") from e
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate_image(image: str) -> str:
|
|
44
|
+
if not isinstance(image, str) or not _IMAGE_RE.match(image):
|
|
45
|
+
raise SandboxError(f"invalid image reference: {image!r}")
|
|
46
|
+
return image
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def check_local_daemon() -> None:
|
|
50
|
+
"""Refuse a remote daemon: the workspace is bind-mounted by host path, so a remote
|
|
51
|
+
DOCKER_HOST / context would resolve that path on another machine."""
|
|
52
|
+
host = os.environ.get("DOCKER_HOST")
|
|
53
|
+
if host is None:
|
|
54
|
+
r = docker("context", "inspect", "--format", "{{.Endpoints.docker.Host}}")
|
|
55
|
+
if r.returncode != 0:
|
|
56
|
+
raise SandboxError(f"cannot determine the docker endpoint: {r.stderr.strip()}")
|
|
57
|
+
host = r.stdout.strip()
|
|
58
|
+
if not hostos.docker_endpoint_is_local(host):
|
|
59
|
+
only = "local named pipes (npipe:////./pipe/...)" if hostos.IS_WINDOWS else "unix:// sockets"
|
|
60
|
+
raise SandboxError(f"refusing non-local docker endpoint {host!r} (only {only} are supported)")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def daemon_info() -> dict:
|
|
64
|
+
r = docker("info", "--format", "{{json .}}")
|
|
65
|
+
if r.returncode != 0:
|
|
66
|
+
raise SandboxError(f"docker daemon unavailable: {r.stderr.strip()}")
|
|
67
|
+
try:
|
|
68
|
+
info = json.loads(r.stdout)
|
|
69
|
+
except ValueError as e:
|
|
70
|
+
raise SandboxError(f"unexpected `docker info` output: {e}") from e
|
|
71
|
+
if not isinstance(info, dict):
|
|
72
|
+
raise SandboxError("unexpected `docker info` output: not an object")
|
|
73
|
+
return info
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def resolve_image(image: str) -> str:
|
|
77
|
+
"""Image ID (sha256:...) of a *local* image. SafeRun never pulls implicitly."""
|
|
78
|
+
r = docker("image", "inspect", "--format", "{{.Id}}", validate_image(image))
|
|
79
|
+
if r.returncode != 0:
|
|
80
|
+
raise SandboxError(f"image {image!r} not present locally (SafeRun never pulls implicitly)")
|
|
81
|
+
image_id = r.stdout.strip()
|
|
82
|
+
if not _IMAGE_ID_RE.match(image_id):
|
|
83
|
+
raise SandboxError(f"unexpected image id from docker: {image_id!r}")
|
|
84
|
+
pinned = digest_of(image)
|
|
85
|
+
if pinned is not None:
|
|
86
|
+
verify_pinned_digest(image, pinned)
|
|
87
|
+
return image_id
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def verify_pinned_digest(image: str, digest: str) -> None:
|
|
91
|
+
"""Fail closed unless the local image behind `image` really carries the pinned `digest`.
|
|
92
|
+
|
|
93
|
+
Docker resolves `repo:tag@sha256:...` by digest, so a retagged image should not match at all; this second,
|
|
94
|
+
independent check on what Docker reports (RepoDigests) guards against a daemon that resolves differently.
|
|
95
|
+
"""
|
|
96
|
+
r = docker("image", "inspect", "--format", "{{json .RepoDigests}}", validate_image(image))
|
|
97
|
+
if r.returncode != 0:
|
|
98
|
+
raise SandboxError(f"image {image!r} not present locally (SafeRun never pulls implicitly)")
|
|
99
|
+
try:
|
|
100
|
+
have = repo_digests(r.stdout.strip())
|
|
101
|
+
except ValueError as e:
|
|
102
|
+
raise SandboxError(f"cannot verify the digest of image {image!r}: {e}") from e
|
|
103
|
+
if digest not in have:
|
|
104
|
+
raise SandboxError(f"image digest mismatch for {image!r}: pinned {digest}, local image has {have or 'no digest'}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def preflight(image: str, allow_degraded: frozenset[str] = frozenset()) -> tuple[str, list[ControlStatus]]:
|
|
108
|
+
"""Fail closed before anything is created. Returns (image_id, daemon control statuses).
|
|
109
|
+
|
|
110
|
+
A required daemon control that is missing raises, unless the caller explicitly lists it in
|
|
111
|
+
`allow_degraded`; allowed degradations stay visible in the returned statuses.
|
|
112
|
+
"""
|
|
113
|
+
validate_image(image)
|
|
114
|
+
check_local_daemon()
|
|
115
|
+
info = daemon_info()
|
|
116
|
+
# The sandbox images are Linux images: a daemon in Windows-containers mode (or any other OS type) cannot run them as specified.
|
|
117
|
+
if info.get("OSType") != "linux":
|
|
118
|
+
raise SandboxError(f"the docker daemon runs {info.get('OSType')!r} containers; SafeRun needs Linux containers"
|
|
119
|
+
+ (" (Docker Desktop: right-click the tray icon and choose 'Switch to Linux containers')" if hostos.IS_WINDOWS else ""))
|
|
120
|
+
statuses = daemon_controls(info)
|
|
121
|
+
for s in statuses:
|
|
122
|
+
if s.name in REQUIRED_DAEMON and s.state != "verified" and s.name not in allow_degraded:
|
|
123
|
+
raise SandboxError(f"required control {s.name} is {s.state}: {s.detail}")
|
|
124
|
+
return resolve_image(image), statuses
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def check_available(image: str) -> None:
|
|
128
|
+
"""Raise SandboxError unless Docker is usable for SafeRun with `image` (used by the test setup)."""
|
|
129
|
+
preflight(image)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def container_exists(name: str) -> bool:
|
|
133
|
+
"""True/False for a definite answer; SandboxError if Docker could not tell us."""
|
|
134
|
+
r = docker("inspect", "--type", "container", name)
|
|
135
|
+
if r.returncode == 0:
|
|
136
|
+
return True
|
|
137
|
+
if "no such" in r.stderr.lower():
|
|
138
|
+
return False
|
|
139
|
+
raise SandboxError(f"cannot determine whether container {name} exists: {r.stderr.strip()}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def inspect(name: str) -> dict:
|
|
143
|
+
"""`docker inspect` of a container; {} if it does not exist; SandboxError on odd output."""
|
|
144
|
+
r = docker("inspect", "--type", "container", name)
|
|
145
|
+
if r.returncode != 0:
|
|
146
|
+
if "no such" in r.stderr.lower():
|
|
147
|
+
return {}
|
|
148
|
+
raise SandboxError(f"docker inspect {name} failed: {r.stderr.strip()}")
|
|
149
|
+
try:
|
|
150
|
+
data = json.loads(r.stdout)
|
|
151
|
+
info = data[0]
|
|
152
|
+
except (ValueError, IndexError, KeyError, TypeError) as e:
|
|
153
|
+
raise SandboxError(f"unexpected `docker inspect` output for {name}: {e}") from e
|
|
154
|
+
if not isinstance(info, dict):
|
|
155
|
+
raise SandboxError(f"unexpected `docker inspect` output for {name}: not an object")
|
|
156
|
+
return info
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def force_remove(name: str) -> bool:
|
|
160
|
+
"""Kill and remove a container; True only if Docker confirms it is gone."""
|
|
161
|
+
docker("rm", "-f", "-v", name, timeout=60)
|
|
162
|
+
return not container_exists(name)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def leftover_containers() -> list[str]:
|
|
166
|
+
r = docker("ps", "-a", "-q", "--filter", f"label={CONTAINER_LABEL}")
|
|
167
|
+
return r.stdout.split()
|