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,110 @@
1
+ """Pinned container images (Phase 5A): data and pure helpers only (no Docker calls, no imports from the rest of the package).
2
+
3
+ Every image SafeRun runs is referenced as `repository:tag@sha256:<digest>`. The digest is the manifest (index)
4
+ digest, which is content-addressed: Docker resolves the reference by digest and the tag is only a label for humans.
5
+ `saferun verify-images` and `docker.resolve_image` refuse to continue when the local image does not carry the pinned digest.
6
+
7
+ How a digest becomes a pin: (1) read it from the local image (`docker image inspect`) that the test suite ran against,
8
+ (2) confirm the registry serves that exact digest for the repository (`docker buildx imagetools inspect <repo>@<digest>`),
9
+ (3) record it here together with the date. To update a pin, repeat these steps deliberately; nothing updates itself.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ from dataclasses import dataclass
16
+
17
+ DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
18
+ _REF_RE = re.compile(r"^(?P<name>[^@]+)@(?P<digest>sha256:[0-9a-f]{64})$")
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class PinnedImage:
23
+ name: str # profile name
24
+ repository: str # as Docker lists it in RepoDigests, e.g. "python"
25
+ tag: str # human-readable label; NOT trusted, the digest decides
26
+ digest: str # sha256:<64 hex>, manifest-index digest
27
+ pinned_on: str # ISO date the digest was verified against the registry
28
+
29
+ @property
30
+ def reference(self) -> str:
31
+ return f"{self.repository}:{self.tag}@{self.digest}"
32
+
33
+ @property
34
+ def pull_reference(self) -> str:
35
+ return f"{self.repository}@{self.digest}"
36
+
37
+
38
+ PYTHON_IMAGE = PinnedImage("python", "python", "3.12-slim",
39
+ "sha256:78387bc3881b8273120a12ebe6c1ab22b018ccc2c9adf565ae1ac9b536e184ea", "2026-10-29")
40
+ NODE_IMAGE = PinnedImage("node", "node", "22-slim",
41
+ "sha256:4b150b48b4116e2a22f2c39cc27025fbf4f153e2078fd3b14d159fdb5c7279da", "2026-10-29")
42
+ PINNED_IMAGES: tuple[PinnedImage, ...] = (PYTHON_IMAGE, NODE_IMAGE)
43
+
44
+ # Where the prebuilt analyzer image is published, and WHO may have signed it (Phase 5E).
45
+ # The image is built and pushed by .github/workflows/release-analyzer.yml and signed with cosign "keyless": the signature is bound to
46
+ # the workflow's GitHub OIDC identity, not to a key anyone holds. SafeRun accepts a signature only if the certificate identity matches
47
+ # this expression: that exact repository, that exact workflow file, and a TAG ref `analyzer-v<number>(.<number>)*` (never a branch or
48
+ # a pull request). tests/test_release_signature.py checks the expression against the workflow file.
49
+ ANALYZER_REGISTRY_REPOSITORY = "ghcr.io/vinay-repalle/saferun-analyzer"
50
+ ANALYZER_SIGNER_IDENTITY_REGEXP = (r"^https://github\.com/vinay-repalle/saferun/\.github/workflows/release-analyzer\.yml"
51
+ r"@refs/tags/analyzer-v[0-9]+(\.[0-9]+)*$")
52
+ GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com"
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class AnalyzerRelease:
57
+ """An officially PUBLISHED prebuilt analyzer image: where it lives, the manifest digest it must have, and who must have signed it.
58
+ `saferun init` first verifies the cosign signature of `<repository>@<digest>` (identity + issuer below), then pulls that reference
59
+ (content-addressed: Docker rejects anything else), then applies the same label/tool/rules verification as for a locally built
60
+ image, and only then locks it. A release without a signer identity is refused: there is no unsigned install."""
61
+ repository: str # e.g. ANALYZER_REGISTRY_REPOSITORY
62
+ digest: str # sha256:<64 hex> of the published manifest
63
+ published_on: str
64
+ signer_identity_regexp: str = ANALYZER_SIGNER_IDENTITY_REGEXP
65
+ oidc_issuer: str = GITHUB_OIDC_ISSUER
66
+
67
+ @property
68
+ def reference(self) -> str:
69
+ return f"{self.repository}@{self.digest}"
70
+
71
+
72
+ # The current release: tag analyzer-v1.0.1 (commit a275dce, rules unchanged since), signed by this repository's release workflow. The digest was read from
73
+ # the registry and its cosign signature was verified with ANALYZER_SIGNER_IDENTITY_REGEXP before it was recorded (docs/release-images.md). A new analyzer
74
+ # (new tool pins or rules) is a new tag, a new digest and a new commit here. Set to None to make `saferun init` build locally with docker/analyzer/build.sh.
75
+ ANALYZER_RELEASE: AnalyzerRelease | None = AnalyzerRelease(
76
+ ANALYZER_REGISTRY_REPOSITORY,
77
+ "sha256:6f26c3e3fed1a8b2a981bdcb920c4da3b2829bce2679709ba8880789ce6f6fa4",
78
+ "2026-10-30",
79
+ )
80
+
81
+ # Tools baked into the analyzer image build recipe (docker/analyzer/). Not used at run time yet (Phase 5B).
82
+ # The Dockerfile ARG defaults must equal these values; tests enforce it.
83
+ SEMGREP_VERSION = "1.182.1"
84
+ OSV_SCANNER_VERSION = "2.7.0"
85
+ OSV_SCANNER_LINUX_AMD64_SHA256 = "160c6253e2922e9e0a6a9771c5c4c40a4b3c6090b34a3cdd5e80c96cc2e4218b"
86
+
87
+
88
+ def digest_of(reference: str) -> str | None:
89
+ """The sha256 digest named by an image reference, or None for a tag-only reference."""
90
+ m = _REF_RE.match(reference)
91
+ return m.group("digest") if m else None
92
+
93
+
94
+ def repo_digests(raw: str) -> list[str]:
95
+ """Digests (`sha256:...`) from the JSON of `docker image inspect --format {{json .RepoDigests}}`.
96
+
97
+ Raises ValueError for anything but a JSON list of `name@sha256:<64 hex>` strings (or null/empty).
98
+ """
99
+ data = json.loads(raw)
100
+ if data is None:
101
+ return []
102
+ if not isinstance(data, list):
103
+ raise ValueError("RepoDigests is not a list")
104
+ out = []
105
+ for item in data:
106
+ m = _REF_RE.match(item) if isinstance(item, str) else None
107
+ if not m:
108
+ raise ValueError(f"unexpected RepoDigests entry: {item!r}")
109
+ out.append(m.group("digest"))
110
+ return out
@@ -0,0 +1,40 @@
1
+ """Runtime profiles: which image runs which kind of project.
2
+
3
+ A profile is only a name for an image. The sandbox itself is image-agnostic: every profile gets
4
+ exactly the same restrictions, verified the same way. A profile does NOT imply anything about
5
+ dependency installation, test runners or analyzers (those belong to Phase 2+).
6
+
7
+ Requirements for any image: Linux; `timeout` (coreutils or busybox) and `sh` on PATH; no VOLUME
8
+ declarations; a `nobody` user is not required (the uid is set numerically).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+ from .config import SandboxError
15
+ from .images import NODE_IMAGE, PYTHON_IMAGE
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class RuntimeProfile:
20
+ name: str
21
+ image: str # pinned reference `repo:tag@sha256:...` (see images.py); the digest is verified before every run
22
+ interpreter: str # binary used to run a project file / self-checks
23
+ description: str = ""
24
+
25
+ @property
26
+ def label(self) -> str:
27
+ """Human-readable `repo:tag` without the digest; used in report text so `tool` strings stay as before pinning."""
28
+ return self.image.split("@", 1)[0]
29
+
30
+
31
+ PYTHON = RuntimeProfile("python", PYTHON_IMAGE.reference, "python", "CPython 3.12 (Debian slim)")
32
+ NODE = RuntimeProfile("node", NODE_IMAGE.reference, "node", "Node.js 22 LTS (Debian slim)")
33
+ PROFILES: dict[str, RuntimeProfile] = {p.name: p for p in (PYTHON, NODE)}
34
+
35
+
36
+ def get_profile(name: str) -> RuntimeProfile:
37
+ try:
38
+ return PROFILES[name]
39
+ except KeyError:
40
+ raise SandboxError(f"unknown runtime profile {name!r}; known: {sorted(PROFILES)}") from None
@@ -0,0 +1,127 @@
1
+ """Recovery of containers and workspaces abandoned by a crashed SafeRun process.
2
+
3
+ Safety rules: only objects SafeRun itself created are considered (label +
4
+ name prefix for containers; name prefix, ownership, real directory for
5
+ workspaces), and live runs are never touched: containers carry a deadline
6
+ label (timeout + grace + margin) and workspaces are protected by a flock.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import errno
11
+ import os
12
+ import shutil
13
+ import tempfile
14
+ import time
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+
18
+ from .. import hostos
19
+ from . import docker as dk
20
+ from .config import CONTAINER_LABEL, DEADLINE_LABEL
21
+ from .workspace import LOCK_NAME, WS_PREFIX
22
+
23
+ if not hostos.IS_WINDOWS:
24
+ import fcntl
25
+
26
+ NO_LOCK_MIN_AGE_S = 600 # a workspace with no lock file is only reaped once this old
27
+
28
+
29
+ @dataclass
30
+ class ReapReport:
31
+ containers: list[str] = field(default_factory=list)
32
+ workspaces: list[str] = field(default_factory=list)
33
+ errors: list[str] = field(default_factory=list)
34
+
35
+
36
+ def _reap_containers(now: float, report: ReapReport) -> None:
37
+ try:
38
+ r = dk.docker("ps", "-a", "--filter", f"label={CONTAINER_LABEL}",
39
+ "--format", '{{.Names}}\t{{.Label "' + DEADLINE_LABEL + '"}}')
40
+ except dk.SandboxError as e: # daemon hung or the CLI missing: report it, but still reap workspaces
41
+ report.errors.append(f"could not list containers: {e}")
42
+ return
43
+ if r.returncode != 0: # daemon down: say so, do not pretend there was nothing to reap
44
+ report.errors.append(f"could not list containers: {r.stderr.strip()}")
45
+ return
46
+ for line in r.stdout.splitlines():
47
+ name, _, raw = line.partition("\t")
48
+ if not name.startswith("saferun-"):
49
+ continue
50
+ try:
51
+ expired = float(raw) < now
52
+ except ValueError:
53
+ expired = True # our label but no usable deadline: treat as abandoned
54
+ if not expired:
55
+ continue
56
+ try:
57
+ if dk.force_remove(name):
58
+ report.containers.append(name)
59
+ else:
60
+ report.errors.append(f"container {name} still present after removal")
61
+ except Exception as e:
62
+ report.errors.append(f"container {name}: {e}")
63
+
64
+
65
+ def _reap_workspaces_windows(now: float, tmp_root: Path, report: ReapReport) -> None:
66
+ """Same rules as the POSIX version: our name prefix, a real directory (no reparse point) owned by this user, and a lock file that
67
+ nobody holds (the owner keeps it open with share mode 0, so it cannot be opened while the run is alive)."""
68
+ for entry in os.scandir(tmp_root):
69
+ if not entry.name.startswith(WS_PREFIX):
70
+ continue
71
+ try:
72
+ st = entry.stat(follow_symlinks=False)
73
+ if entry.is_symlink() or not entry.is_dir(follow_symlinks=False) or st.st_file_attributes & 0x400:
74
+ continue
75
+ if not hostos.owned_by_current_user(Path(entry.path)):
76
+ continue
77
+ state = hostos.workspace_lock_state(os.path.join(entry.path, LOCK_NAME))
78
+ if state == "busy" or (state == "absent" and now - st.st_mtime < NO_LOCK_MIN_AGE_S):
79
+ continue
80
+ shutil.rmtree(hostos.long_path(entry.path), onexc=hostos.rmtree_onexc)
81
+ report.workspaces.append(entry.name)
82
+ except OSError as e:
83
+ report.errors.append(f"workspace {entry.name}: {e}")
84
+
85
+
86
+ def _reap_workspaces(now: float, tmp_root: Path, report: ReapReport) -> None:
87
+ if hostos.IS_WINDOWS:
88
+ return _reap_workspaces_windows(now, tmp_root, report)
89
+ for entry in os.scandir(tmp_root):
90
+ if not entry.name.startswith(WS_PREFIX) or entry.is_symlink() or not entry.is_dir(follow_symlinks=False):
91
+ continue
92
+ st = entry.stat(follow_symlinks=False)
93
+ if st.st_uid != os.getuid():
94
+ continue
95
+ try:
96
+ fd = os.open(os.path.join(entry.path, LOCK_NAME), os.O_RDWR | os.O_NOFOLLOW | os.O_CLOEXEC)
97
+ except FileNotFoundError:
98
+ if now - st.st_mtime < NO_LOCK_MIN_AGE_S:
99
+ continue # may be mid-creation
100
+ fd = None
101
+ except OSError as e:
102
+ report.errors.append(f"workspace {entry.name}: {e}")
103
+ continue
104
+ try:
105
+ if fd is not None:
106
+ try:
107
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
108
+ except OSError as e:
109
+ if e.errno in (errno.EWOULDBLOCK, errno.EAGAIN):
110
+ continue # owner is alive
111
+ raise
112
+ shutil.rmtree(entry.path)
113
+ report.workspaces.append(entry.name)
114
+ except OSError as e:
115
+ report.errors.append(f"workspace {entry.name}: {e}")
116
+ finally:
117
+ if fd is not None:
118
+ os.close(fd)
119
+
120
+
121
+ def reap_orphans(now: float | None = None, tmp_root: Path | None = None) -> ReapReport:
122
+ """Remove abandoned SafeRun containers and workspaces. `now`/`tmp_root` are for tests."""
123
+ now = time.time() if now is None else now
124
+ report = ReapReport()
125
+ _reap_containers(now, report)
126
+ _reap_workspaces(now, Path(tmp_root or tempfile.gettempdir()), report)
127
+ return report
@@ -0,0 +1,268 @@
1
+ """Run a command against an untrusted project inside a locked-down container."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ import re
6
+ import subprocess
7
+ import threading
8
+ import time
9
+ import uuid
10
+ from pathlib import Path
11
+
12
+ from .. import hostos
13
+ from . import docker as dk
14
+ from .config import (
15
+ CONTAINER_LABEL, DEADLINE_LABEL, DEFAULT_IMAGE, REAP_MARGIN_S, SANDBOX_UID,
16
+ ControlStatus, SandboxError, SandboxLimits, SandboxResult, SensitivePolicy, WorkspaceLimits,
17
+ )
18
+ from .controls import verify_applied_config
19
+ from .secrets import drop_partial_tail
20
+ from .workspace import Workspace, create_workspace
21
+
22
+ MAX_ARGS, MAX_ARG_LEN = 4096, 64 * 1024
23
+
24
+
25
+ def _check_mount_path(project: Path) -> None:
26
+ """--mount values are comma-separated key=value CSV; refuse paths that could inject options."""
27
+ s = str(project)
28
+ if not project.is_absolute() or any(c in s for c in ',"') or not s.isprintable():
29
+ raise SandboxError(f"workspace path contains unsupported characters: {s!r}")
30
+ problem = hostos.mount_source_problem(s)
31
+ if problem:
32
+ raise SandboxError(f"cannot mount {s!r}: {problem}")
33
+
34
+
35
+ def validate_command(command) -> list[str]:
36
+ """argv for the container: a non-empty list of strings, argv[0] non-empty, no NUL bytes.
37
+ (Never passed through a shell.)"""
38
+ if (not isinstance(command, (list, tuple)) or not command or len(command) > MAX_ARGS
39
+ or not all(isinstance(a, str) and len(a) <= MAX_ARG_LEN and "\0" not in a for a in command)
40
+ or not command[0]):
41
+ raise SandboxError("command must be a non-empty list of strings with a non-empty first element")
42
+ return list(command)
43
+
44
+
45
+ _RESERVED_TARGETS = frozenset({"/", "/workspace", "/tmp", "/proc", "/sys", "/dev", "/etc", "/bin", "/usr", "/lib", "/sbin", "/var", "/run"})
46
+ _TARGET_RE = re.compile(r"^/[a-z][a-z0-9_-]{0,31}$")
47
+
48
+
49
+ def validate_readonly_mounts(mounts) -> tuple[tuple[Path, str], ...]:
50
+ """Extra read-only bind mounts (host directory, container path), e.g. a vulnerability database.
51
+
52
+ Only existing, absolute host *directories* are accepted (never the filesystem root or a symlink), and the container
53
+ path must be a single fresh top-level name that is not a system location. They are always mounted read-only and
54
+ `verify_applied_config` re-checks the exact set Docker applied.
55
+ """
56
+ out, seen = [], set()
57
+ for item in mounts or ():
58
+ try:
59
+ src, dst = item
60
+ src = Path(src)
61
+ except (TypeError, ValueError):
62
+ raise SandboxError(f"invalid read-only mount specification: {item!r}") from None
63
+ if dst in _RESERVED_TARGETS or not isinstance(dst, str) or not _TARGET_RE.match(dst) or dst in seen:
64
+ raise SandboxError(f"invalid or duplicate read-only mount target: {dst!r}")
65
+ _check_mount_path(src)
66
+ if src == Path(src.anchor) or src.is_symlink() or not src.is_dir():
67
+ raise SandboxError(f"read-only mount source must be an existing real directory (not the root): {str(src)!r}")
68
+ seen.add(dst)
69
+ out.append((src, dst))
70
+ return tuple(out)
71
+
72
+
73
+ def build_create_args(
74
+ name: str, project: Path, command: list[str], image: str, lim: SandboxLimits,
75
+ readonly_mounts: tuple[tuple[Path, str], ...] = (),
76
+ ) -> list[str]:
77
+ """docker-create arguments. Every security control lives here."""
78
+ _check_mount_path(project)
79
+ extra = []
80
+ for src, dst in readonly_mounts:
81
+ _check_mount_path(src)
82
+ extra += ["--mount", f"type=bind,src={src},dst={dst},readonly"]
83
+ mem = f"{lim.memory_mb}m"
84
+ container_timeout = math.ceil(lim.timeout_s + lim.container_grace_s)
85
+ deadline = int(time.time() + container_timeout + REAP_MARGIN_S)
86
+ return [
87
+ "create", "--name", name, "--label", CONTAINER_LABEL,
88
+ "--label", f"{DEADLINE_LABEL}={deadline}",
89
+ "--pull", "never", # never fetch an image (no host egress, no tag swap)
90
+ "--network", "none", # no network at all
91
+ "--read-only", # immutable root fs
92
+ "--tmpfs", f"/tmp:rw,noexec,nosuid,nodev,size={lim.tmpfs_mb}m",
93
+ "--shm-size", f"{lim.shm_mb}m",
94
+ "--mount", f"type=bind,src={project},dst=/workspace,readonly",
95
+ *extra,
96
+ "--workdir", "/workspace",
97
+ "--entrypoint", "", # the image's ENTRYPOINT must not reinterpret our argv
98
+ "--user", f"{SANDBOX_UID}:{SANDBOX_UID}",
99
+ "--cap-drop", "ALL",
100
+ "--security-opt", "no-new-privileges", # daemon-default seccomp + AppArmor kept, checked in preflight
101
+ "--memory", mem, "--memory-swap", mem, # no swap
102
+ "--cpus", str(lim.cpus),
103
+ "--pids-limit", str(lim.pids),
104
+ "--ulimit", f"nofile={lim.nofile}:{lim.nofile}",
105
+ "--ulimit", f"fsize={lim.fsize_mb * 1024 * 1024}",
106
+ "--ulimit", "core=0",
107
+ "--log-driver", "none", # no host-disk log growth
108
+ "--init",
109
+ "--env", "HOME=/tmp", "--env", "PYTHONDONTWRITEBYTECODE=1",
110
+ image,
111
+ # Container-side deadline: still fires if the SafeRun process dies mid-run.
112
+ "timeout", "-s", "KILL", str(container_timeout), *command,
113
+ ]
114
+
115
+
116
+ def _drain(stream, cap: int, buf: bytearray, flag: list[bool]) -> None:
117
+ """Keep up to `cap` bytes; keep reading (and discarding) so the child never blocks."""
118
+ while chunk := stream.read(65536):
119
+ room = cap - len(buf)
120
+ if room > 0:
121
+ buf += chunk[:room]
122
+ if len(chunk) > room:
123
+ flag[0] = True
124
+
125
+
126
+ def _execute(name: str, ws: Workspace, command: list[str], image: str, image_id: str,
127
+ daemon_statuses: list[ControlStatus], lim: SandboxLimits,
128
+ readonly_mounts: tuple[tuple[Path, str], ...] = ()) -> SandboxResult:
129
+ r = dk.docker(*build_create_args(name, ws.project, command, image_id, lim, readonly_mounts))
130
+ if r.returncode != 0:
131
+ raise SandboxError(f"docker create failed: {r.stderr.strip()}")
132
+
133
+ # Fail closed: verify what Docker actually created BEFORE any untrusted code can start.
134
+ info = dk.inspect(name)
135
+ if not info:
136
+ raise SandboxError("docker create succeeded but the container cannot be inspected")
137
+ checks = verify_applied_config(info, lim, image_id, tuple(dst for _s, dst in readonly_mounts))
138
+ bad = [c for c in checks if c.state != "verified"]
139
+ if bad:
140
+ raise SandboxError("container was not started: applied configuration violates the sandbox policy: "
141
+ + "; ".join(f"{c.name} ({c.detail})" for c in bad))
142
+
143
+ out, err, trunc = bytearray(), bytearray(), [False]
144
+ t0 = time.monotonic()
145
+ try:
146
+ proc = subprocess.Popen(
147
+ [dk.docker_bin(), "start", "-a", name],
148
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.DEVNULL,
149
+ )
150
+ except OSError as e:
151
+ raise SandboxError(f"could not start the docker client: {e}") from e
152
+ threads = [
153
+ threading.Thread(target=_drain, args=(s, lim.max_output_bytes, b, trunc), daemon=True)
154
+ for s, b in ((proc.stdout, out), (proc.stderr, err))
155
+ ]
156
+ for t in threads:
157
+ t.start()
158
+ timed_out = False
159
+ try:
160
+ try:
161
+ proc.wait(timeout=lim.timeout_s)
162
+ except subprocess.TimeoutExpired:
163
+ timed_out = True
164
+ try:
165
+ dk.docker("kill", name)
166
+ except SandboxError:
167
+ pass # daemon hung or unreachable: the client is stopped below and the cleanup step reports what is left
168
+ try:
169
+ proc.wait(timeout=30)
170
+ except subprocess.TimeoutExpired:
171
+ raise SandboxError(f"container {name} did not stop within 30 s of being killed") from None
172
+ finally:
173
+ if proc.poll() is None: # never leave our `docker start -a` client behind (hung daemon, error, interrupt)
174
+ proc.kill()
175
+ try:
176
+ proc.wait(timeout=10)
177
+ except subprocess.TimeoutExpired:
178
+ pass
179
+ for t in threads:
180
+ t.join(timeout=5)
181
+ duration = time.monotonic() - t0
182
+
183
+ info = dk.inspect(name)
184
+ state = info.get("State") if info else None
185
+ if not isinstance(state, dict) or not isinstance(state.get("ExitCode"), int):
186
+ raise SandboxError(f"could not read the final state of container {name}; outcome unknown")
187
+ texts, redactions = [], 0
188
+ for buf in (out, err):
189
+ text = buf.decode("utf-8", "replace")
190
+ if trunc[0]:
191
+ text = drop_partial_tail(text) # a cut-off secret prefix would evade redaction
192
+ text, n = ws.redactor.redact(text)
193
+ texts.append(text)
194
+ redactions += n
195
+ return SandboxResult(
196
+ exit_code=state["ExitCode"],
197
+ stdout=texts[0],
198
+ stderr=texts[1],
199
+ redactions=redactions,
200
+ withheld=ws.withheld,
201
+ timed_out=timed_out,
202
+ oom_killed=bool(state.get("OOMKilled")),
203
+ output_truncated=trunc[0],
204
+ duration_s=duration,
205
+ container_name=name,
206
+ hardening=info.get("HostConfig", {}),
207
+ controls=[*daemon_statuses, *checks],
208
+ image_id=image_id,
209
+ skipped=ws.skipped,
210
+ entries_scanned=ws.entries_scanned,
211
+ copied_files=ws.copied_files,
212
+ copied_dirs=ws.copied_dirs,
213
+ copied_bytes=ws.copied_bytes,
214
+ )
215
+
216
+
217
+ def _cleanup(name: str, ws: Workspace) -> list[str]:
218
+ """Run BOTH cleanup steps whatever happens to the first; return problems."""
219
+ problems = []
220
+ try:
221
+ if not dk.force_remove(name):
222
+ problems.append(f"container {name} still present")
223
+ except Exception as e:
224
+ problems.append(f"container {name}: {e}")
225
+ try:
226
+ ws.cleanup()
227
+ except Exception as e:
228
+ problems.append(f"workspace {ws.root}: {e}")
229
+ return problems
230
+
231
+
232
+ def run_sandboxed(
233
+ project_dir: Path,
234
+ command: list[str],
235
+ limits: SandboxLimits | None = None,
236
+ image: str = DEFAULT_IMAGE,
237
+ workspace_limits: WorkspaceLimits | None = None,
238
+ sensitive_policy: SensitivePolicy | None = None,
239
+ allow_degraded: frozenset[str] = frozenset(),
240
+ readonly_mounts: tuple[tuple[Path, str], ...] = (),
241
+ ) -> SandboxResult:
242
+ """Run `command` (argv, no shell) against a sanitized copy of `project_dir`.
243
+
244
+ `allow_degraded` names daemon controls (see controls.REQUIRED_DAEMON) the caller accepts
245
+ as unavailable; they are still reported in `SandboxResult.controls`.
246
+ `readonly_mounts` adds (host directory, container path) bind mounts, always read-only (see validate_readonly_mounts);
247
+ the network stays disabled whatever is mounted.
248
+ """
249
+ lim = limits or SandboxLimits()
250
+ command = validate_command(command)
251
+ readonly_mounts = validate_readonly_mounts(readonly_mounts)
252
+ image_id, daemon_statuses = dk.preflight(image, frozenset(allow_degraded))
253
+ ws = create_workspace(project_dir, workspace_limits, sensitive_policy)
254
+ name = f"saferun-{uuid.uuid4().hex[:12]}"
255
+ primary: BaseException | None = None
256
+ try:
257
+ return _execute(name, ws, command, image, image_id, daemon_statuses, lim, readonly_mounts)
258
+ except BaseException as e:
259
+ primary = e
260
+ raise
261
+ finally:
262
+ problems = _cleanup(name, ws)
263
+ if problems:
264
+ msg = "; ".join(problems)
265
+ if primary is not None:
266
+ primary.add_note(f"SafeRun cleanup also failed: {msg}") # keep the original error
267
+ else:
268
+ raise SandboxError(f"cleanup failed: {msg}")
@@ -0,0 +1,91 @@
1
+ """Content-based detection of credential material, and output redaction.
2
+
3
+ Detection is by *content signature*, never by filename. Signatures are high
4
+ precision on purpose (a PEM private key must have a base64 body, tokens must
5
+ match a vendor format); the list is not exhaustive. Redaction is best-effort
6
+ defence in depth: sandboxed code can encode or split a secret before printing.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ MASK = "[REDACTED]"
13
+ OVERLAP = 1024 # bytes carried between chunks so a signature can span a boundary
14
+ LEARN_MAX = 256 * 1024 # only files up to this size are mined for secret-looking assignments
15
+ MAX_LEARNED = 500
16
+
17
+ _PEM_HDR = r"-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----"
18
+ _PEM_BODY = r"(?:[ \t]*\r?\n|\\n)(?:[A-Za-z0-9-]+:[^\n]*\n)*\s*[A-Za-z0-9+/=]{40,}"
19
+ _PEM_END = r"-----END (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----"
20
+
21
+ # (kind, byte anchors that must appear before the regex is even tried, pattern)
22
+ _SIGNATURES: list[tuple[str, tuple[bytes, ...], str]] = [
23
+ ("private-key", (b"-----BEGIN",), _PEM_HDR + _PEM_BODY),
24
+ ("token:aws-access-key", (b"AKIA", b"ASIA"), r"(?<![A-Za-z0-9])(?:AKIA|ASIA)[0-9A-Z]{16}(?![A-Za-z0-9])"),
25
+ ("token:github", (b"ghp_", b"gho_", b"ghu_", b"ghs_", b"ghr_", b"github_pat_"),
26
+ r"(?<![A-Za-z0-9])(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255})"),
27
+ ("token:slack", (b"xox",), r"(?<![A-Za-z0-9])xox[abprs]-[0-9A-Za-z-]{10,}"),
28
+ ("token:google-api-key", (b"AIza",), r"(?<![A-Za-z0-9])AIza[0-9A-Za-z_\-]{35}"),
29
+ ("token:stripe-live", (b"_live_",), r"(?<![A-Za-z0-9])[sr]k_live_[0-9A-Za-z]{16,}"),
30
+ ("token:pypi", (b"pypi-AgEI",), r"(?<![A-Za-z0-9])pypi-AgEIcHlwaS5vcmc[A-Za-z0-9_\-]{50,}"),
31
+ ("token:anthropic", (b"sk-ant-",), r"(?<![A-Za-z0-9])sk-ant-[A-Za-z0-9_\-]{40,}"),
32
+ ]
33
+ _BYTES = [(k, a, re.compile(p.encode())) for k, a, p in _SIGNATURES]
34
+
35
+ # Full-block redaction for output: header + body (lookahead) through END marker or end of text.
36
+ _REDACT = [re.compile(_PEM_HDR + "(?=" + _PEM_BODY + ").*?(?:" + _PEM_END + r"|\Z)", re.S)]
37
+ _REDACT += [re.compile(p) for k, _, p in _SIGNATURES if k != "private-key"]
38
+
39
+ _KEY = (r"^[ \t]*(?:export[ \t]+)?['\"]?[A-Za-z0-9_.\-]*"
40
+ r"(?:secret|token|passw(?:or)?d|api[_\-]?key|private[_\-]?key|access[_\-]?key|credentials?)"
41
+ r"[A-Za-z0-9_.\-]*['\"]?[ \t]*[=:][ \t]*")
42
+ _VALUE = r"(?P<q>['\"]?)(?P<v>[A-Za-z0-9_\-+/=.:@!%^*~]{8,})(?P=q)(?=[\s#,;]|$)"
43
+ _ASSIGN = re.compile((_KEY + _VALUE).encode(), re.I | re.M)
44
+
45
+
46
+ class SignatureScanner:
47
+ """Feed a file's chunks in order; returns the first matching kind (never the value)."""
48
+
49
+ def __init__(self) -> None:
50
+ self._tail = b""
51
+
52
+ def feed(self, chunk: bytes) -> str | None:
53
+ data = self._tail + chunk
54
+ self._tail = data[-OVERLAP:]
55
+ for kind, anchors, rx in _BYTES:
56
+ if any(a in data for a in anchors) and rx.search(data):
57
+ return kind
58
+ return None
59
+
60
+
61
+ class Redactor:
62
+ """Masks secrets in sandbox output. Holds secret values in memory only."""
63
+
64
+ def __init__(self) -> None:
65
+ self._values: set[str] = set()
66
+
67
+ def learn(self, data: bytes) -> None:
68
+ """Remember values of secret-looking `KEY=value` lines in a file the sandbox can read."""
69
+ if len(data) > LEARN_MAX or b"\0" in data:
70
+ return
71
+ for m in _ASSIGN.finditer(data):
72
+ if len(self._values) >= MAX_LEARNED:
73
+ return
74
+ self._values.add(m.group("v").decode("utf-8", "ignore"))
75
+
76
+ def redact(self, text: str) -> tuple[str, int]:
77
+ n = 0
78
+ for v in sorted(self._values, key=len, reverse=True):
79
+ c = text.count(v)
80
+ if c:
81
+ text, n = text.replace(v, MASK), n + c
82
+ for rx in _REDACT:
83
+ text, c = rx.subn(MASK, text)
84
+ n += c
85
+ return text, n
86
+
87
+
88
+ def drop_partial_tail(text: str) -> str:
89
+ """After truncation the last token may be a cut-off secret prefix: drop back to whitespace."""
90
+ cut = max(text.rfind(c) for c in " \t\r\n")
91
+ return text[: cut + 1] if cut >= 0 else ""