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,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 ""
|