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
+ """Kernel-view attestation: run a tiny probe inside a sandbox built exactly like a scan sandbox
2
+ and report what the *kernel* says is in force (not what we asked Docker for).
3
+
4
+ Language-agnostic: the probe is POSIX sh reading /proc and /sys, so it works in any image that
5
+ meets the profile requirements. Controls that cannot be read are reported "unverified" or
6
+ "unavailable"; nothing is reported "verified" without evidence.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import tempfile
11
+ from dataclasses import dataclass, field
12
+
13
+ from . import docker as dk
14
+ from .config import DEFAULT_IMAGE, SANDBOX_UID, ControlStatus, SandboxError, SandboxLimits
15
+ from .controls import MIB
16
+ from .runner import run_sandboxed
17
+
18
+ PROBE = r"""
19
+ echo "uid=$(id -u)"
20
+ sed -n 's/^\(NoNewPrivs\|Seccomp\|CapEff\|CapBnd\|CapAmb\):[[:space:]]*/\1=/p' /proc/self/status
21
+ echo "apparmor=$(cat /proc/self/attr/current 2>/dev/null || echo none)"
22
+ for f in memory.max memory.swap.max pids.max cpu.max; do echo "$f=$(cat /sys/fs/cgroup/$f 2>/dev/null || echo none)"; done
23
+ echo "ifaces=$(tail -n +3 /proc/net/dev | cut -d: -f1 | tr -d ' ' | tr '\n' ',')"
24
+ for p in /workspace/.w /.w /tmp/.w; do if ( : > "$p" ) 2>/dev/null; then echo "write:$p=yes"; else echo "write:$p=no"; fi; done
25
+ """
26
+
27
+
28
+ @dataclass
29
+ class AttestationReport:
30
+ image: str
31
+ image_id: str
32
+ controls: list[ControlStatus] = field(default_factory=list)
33
+
34
+ def by_name(self) -> dict[str, ControlStatus]:
35
+ return {c.name: c for c in self.controls}
36
+
37
+ @property
38
+ def violated(self) -> list[ControlStatus]:
39
+ return [c for c in self.controls if c.state == "violated"]
40
+
41
+ @property
42
+ def ok(self) -> bool:
43
+ """No control observed to be violated (unverified/unavailable ones are listed, not hidden)."""
44
+ return not self.violated
45
+
46
+
47
+ def _parse(out: str) -> dict[str, str]:
48
+ kv = {}
49
+ for line in out.splitlines():
50
+ k, sep, v = line.partition("=")
51
+ if sep:
52
+ kv[k.strip()] = v.strip()
53
+ return kv
54
+
55
+
56
+ def evaluate(kv: dict[str, str], lim: SandboxLimits, daemon_has_apparmor: bool) -> list[ControlStatus]:
57
+ """Turn raw probe output into control statuses (pure; unit-tested)."""
58
+ def st(name, ok, detail, missing=False):
59
+ return ControlStatus(name, "unverified" if missing else ("verified" if ok else "violated"), detail)
60
+
61
+ def need(key):
62
+ return key not in kv or kv[key] == "none"
63
+
64
+ caps = (kv.get("CapEff"), kv.get("CapBnd"), kv.get("CapAmb"))
65
+ apparmor = kv.get("apparmor", "none")
66
+ out = [
67
+ st("kernel-non-root", kv.get("uid") == str(SANDBOX_UID), f"uid={kv.get('uid')}", missing="uid" not in kv),
68
+ st("kernel-no-new-privileges", kv.get("NoNewPrivs") == "1", f"NoNewPrivs={kv.get('NoNewPrivs')}",
69
+ missing="NoNewPrivs" not in kv),
70
+ st("kernel-seccomp-filter", kv.get("Seccomp") == "2", f"Seccomp={kv.get('Seccomp')} (2 = filter)",
71
+ missing="Seccomp" not in kv),
72
+ st("kernel-capabilities-empty", all(c == "0" * 16 for c in caps if c is not None) and None not in caps[:2],
73
+ f"CapEff={caps[0]} CapBnd={caps[1]} CapAmb={caps[2]}", missing=caps[0] is None or caps[1] is None),
74
+ st("kernel-network-loopback-only", kv.get("ifaces") == "lo,", f"interfaces={kv.get('ifaces')!r}",
75
+ missing="ifaces" not in kv),
76
+ st("kernel-memory-max", kv.get("memory.max") == str(lim.memory_mb * MIB), f"memory.max={kv.get('memory.max')}",
77
+ missing=need("memory.max")),
78
+ st("kernel-swap-disabled", kv.get("memory.swap.max") == "0", f"memory.swap.max={kv.get('memory.swap.max')}",
79
+ missing=need("memory.swap.max")),
80
+ st("kernel-pids-max", kv.get("pids.max") == str(lim.pids), f"pids.max={kv.get('pids.max')}",
81
+ missing=need("pids.max")),
82
+ st("kernel-cpu-quota", (kv.get("cpu.max") or "").split()[:1] == [str(int(lim.cpus * 100_000))],
83
+ f"cpu.max={kv.get('cpu.max')}", missing=need("cpu.max")),
84
+ st("fs-workspace-read-only", kv.get("write:/workspace/.w") == "no", f"write={kv.get('write:/workspace/.w')}",
85
+ missing="write:/workspace/.w" not in kv),
86
+ st("fs-root-read-only", kv.get("write:/.w") == "no", f"write={kv.get('write:/.w')}",
87
+ missing="write:/.w" not in kv),
88
+ st("fs-tmp-writable", kv.get("write:/tmp/.w") == "yes", f"write={kv.get('write:/tmp/.w')}",
89
+ missing="write:/tmp/.w" not in kv),
90
+ ]
91
+ if apparmor.startswith("docker-default") and "(enforce)" in apparmor:
92
+ out.append(ControlStatus("kernel-apparmor-profile", "verified", apparmor))
93
+ elif apparmor in ("none", "") or not daemon_has_apparmor:
94
+ out.append(ControlStatus("kernel-apparmor-profile", "unavailable",
95
+ "AppArmor not active for the container on this host (not required)"))
96
+ else:
97
+ out.append(ControlStatus("kernel-apparmor-profile", "violated", f"profile={apparmor!r}"))
98
+ return out
99
+
100
+
101
+ def attest(image: str = DEFAULT_IMAGE, limits: SandboxLimits | None = None) -> AttestationReport:
102
+ """Probe the sandbox for `image`. Costs one short container run."""
103
+ lim = limits or SandboxLimits(timeout_s=20)
104
+ with tempfile.TemporaryDirectory(prefix="saferun-attest-") as empty:
105
+ r = run_sandboxed(empty, ["sh", "-c", PROBE], lim, image=image)
106
+ if r.exit_code != 0:
107
+ raise SandboxError(f"attestation probe failed (exit {r.exit_code}): {r.stderr.strip()[:300]}")
108
+ daemon_apparmor = any(c.name == "daemon-apparmor" and c.state == "verified" for c in r.controls)
109
+ probe_controls = evaluate(_parse(r.stdout), lim, daemon_apparmor)
110
+ return AttestationReport(image, r.image_id, [*r.controls, *probe_controls])
@@ -0,0 +1,121 @@
1
+ """Sandbox policy and result types."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ from dataclasses import dataclass, field
6
+
7
+ from .images import PYTHON_IMAGE
8
+
9
+ DEFAULT_IMAGE = PYTHON_IMAGE.reference # pinned by digest (Phase 5A)
10
+ SANDBOX_UID = 65534 # "nobody"
11
+ CONTAINER_LABEL = "saferun.sandbox=1"
12
+ DEADLINE_LABEL = "saferun.deadline" # epoch seconds after which a container is an orphan
13
+ REAP_MARGIN_S = 60 # slack added to the deadline so live runs are never reaped
14
+
15
+
16
+ class SandboxError(RuntimeError):
17
+ """Sandbox could not be set up or torn down safely."""
18
+
19
+
20
+ class WorkspaceLimitError(SandboxError):
21
+ """The project exceeds the configured copy limits."""
22
+
23
+
24
+ class WorkspaceChangedError(SandboxError):
25
+ """The source tree changed while SafeRun was copying it; the copy is abandoned (fail closed)."""
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class SandboxLimits:
30
+ timeout_s: float = 30.0 # host-side wall clock, from `docker start`
31
+ container_grace_s: float = 5.0 # container-side `timeout` fires at timeout_s + grace
32
+ memory_mb: int = 256
33
+ cpus: float = 1.0
34
+ pids: int = 64
35
+ tmpfs_mb: int = 64
36
+ max_output_bytes: int = 256 * 1024
37
+ nofile: int = 256
38
+ fsize_mb: int = 64
39
+ shm_mb: int = 16
40
+
41
+ def __post_init__(self):
42
+ for k, v in vars(self).items():
43
+ if not (isinstance(v, (int, float)) and math.isfinite(v) and v > 0):
44
+ raise ValueError(f"SandboxLimits.{k} must be a positive finite number, got {v!r}")
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class WorkspaceLimits:
49
+ max_bytes: int = 400 * 1024 * 1024
50
+ max_files: int = 20_000 # counts files, directories and skipped entries
51
+ max_depth: int = 32 # directory levels below the project root
52
+
53
+ def __post_init__(self):
54
+ for k, v in vars(self).items():
55
+ if not (isinstance(v, int) and v > 0):
56
+ raise ValueError(f"WorkspaceLimits.{k} must be a positive integer, got {v!r}")
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class SensitivePolicy:
61
+ """How credential-like files are treated when building the workspace.
62
+
63
+ By default a source file that is not world-readable (directory: no other r/x) is withheld, since the
64
+ sandbox user is "other" and would not have been able to read it. Files with a credential signature
65
+ (see secrets.py) are withheld whatever their name. `allow_paths` (relative, posix) opts specific
66
+ paths back in; `honor_private_modes=False` is for umask-077 trees where everything is private.
67
+ """
68
+ honor_private_modes: bool = True
69
+ allow_paths: frozenset[str] = frozenset()
70
+
71
+ def __post_init__(self):
72
+ object.__setattr__(self, "allow_paths", frozenset(self.allow_paths))
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class WithheldEntry:
77
+ path: str # relative path (untrusted; sanitize before display)
78
+ reason: str # private-mode | private-key | token:<vendor> -- never the secret itself
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class ControlStatus:
83
+ """One security control and what is actually known about it.
84
+
85
+ state: verified - confirmed from Docker's own configuration or from inside the sandbox
86
+ violated - observed to differ from policy (a run is refused / flagged)
87
+ unavailable - the host or daemon cannot provide it
88
+ unverified - not checked, or the evidence needed is not obtainable
89
+ """
90
+ name: str
91
+ state: str
92
+ detail: str = ""
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class SkippedEntry:
97
+ path: str # relative to the project root; untrusted, sanitize before display
98
+ reason: str # symlink | hardlink-external | not-regular-file | unreadable
99
+
100
+
101
+ @dataclass
102
+ class SandboxResult:
103
+ exit_code: int | None
104
+ stdout: str
105
+ stderr: str
106
+ timed_out: bool = False
107
+ oom_killed: bool = False
108
+ output_truncated: bool = False
109
+ duration_s: float = 0.0
110
+ container_name: str = ""
111
+ hardening: dict = field(default_factory=dict) # HostConfig as Docker applied it
112
+ controls: list[ControlStatus] = field(default_factory=list) # evidence for this run, see ControlStatus
113
+ image_id: str = "" # the exact image (sha256:...) the container was created from
114
+ skipped: list[SkippedEntry] = field(default_factory=list)
115
+ withheld: list[WithheldEntry] = field(default_factory=list) # sensitive files NOT given to the sandbox
116
+ redactions: int = 0 # secrets masked in stdout/stderr
117
+ # Workspace accounting. entries_scanned == copied_files + copied_dirs + len(skipped) + len(withheld)
118
+ entries_scanned: int = 0 # every directory entry visited
119
+ copied_files: int = 0 # regular files actually written to the workspace
120
+ copied_dirs: int = 0
121
+ copied_bytes: int = 0 # bytes of those files (withheld content is not counted)
@@ -0,0 +1,104 @@
1
+ """Evidence about which security controls are really in force.
2
+
3
+ Pure functions over data Docker reports, so every rule is unit-testable without a daemon.
4
+ - daemon_controls(): what the *daemon* can enforce (seccomp, cgroup limits, AppArmor)
5
+ - verify_applied_config(): what Docker *applied* to the created, not yet started, container
6
+ A control is only ever reported "verified" when this evidence supports it.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from .config import CONTAINER_LABEL, SANDBOX_UID, ControlStatus, SandboxLimits
11
+
12
+ MIB = 1024 * 1024
13
+ # Controls without which a run is refused (unless the caller explicitly allows degradation).
14
+ REQUIRED_DAEMON = ("daemon-seccomp", "daemon-memory-limit", "daemon-swap-limit",
15
+ "daemon-pids-limit", "daemon-cpu-quota")
16
+
17
+
18
+ def _flag(info: dict, key: str, name: str, what: str) -> ControlStatus:
19
+ v = info.get(key)
20
+ if v is True:
21
+ return ControlStatus(name, "verified", f"daemon reports {what} support")
22
+ if v is False:
23
+ return ControlStatus(name, "unavailable", f"daemon reports no {what} support")
24
+ return ControlStatus(name, "unverified", f"daemon did not report {key}")
25
+
26
+
27
+ def daemon_controls(info: dict) -> list[ControlStatus]:
28
+ """Capabilities of the Docker daemon, from `docker info`."""
29
+ opts = [o for o in (info.get("SecurityOptions") or []) if isinstance(o, str)]
30
+ seccomp = next((o for o in opts if o.startswith("name=seccomp")), None)
31
+ apparmor = next((o for o in opts if o.startswith("name=apparmor")), None)
32
+ out = []
33
+ if seccomp is None:
34
+ out.append(ControlStatus("daemon-seccomp", "unavailable", "daemon reports no seccomp support"))
35
+ elif "profile=unconfined" in seccomp:
36
+ out.append(ControlStatus("daemon-seccomp", "violated", "daemon default seccomp profile is unconfined"))
37
+ else:
38
+ out.append(ControlStatus("daemon-seccomp", "verified", seccomp))
39
+ out.append(ControlStatus("daemon-apparmor", "verified", apparmor) if apparmor else
40
+ ControlStatus("daemon-apparmor", "unavailable", "AppArmor is not enabled on this host (not required)"))
41
+ out += [
42
+ _flag(info, "MemoryLimit", "daemon-memory-limit", "memory limit"),
43
+ _flag(info, "SwapLimit", "daemon-swap-limit", "swap limit"),
44
+ _flag(info, "PidsLimit", "daemon-pids-limit", "pids limit"),
45
+ _flag(info, "CpuCfsQuota", "daemon-cpu-quota", "CPU quota"),
46
+ ]
47
+ return out
48
+
49
+
50
+ def verify_applied_config(info: dict, lim: SandboxLimits, image_id: str,
51
+ extra_mount_targets: tuple[str, ...] = ()) -> list[ControlStatus]:
52
+ """Check the container Docker created (from `docker inspect`) against policy."""
53
+ hc = info.get("HostConfig") or {}
54
+ cfg = info.get("Config") or {}
55
+ ulimits = {u.get("Name"): (u.get("Soft"), u.get("Hard")) for u in (hc.get("Ulimits") or [])
56
+ if isinstance(u, dict)}
57
+ tmpfs = (hc.get("Tmpfs") or {}).get("/tmp", "")
58
+ mounts = hc.get("Mounts") or []
59
+ secopt = " ".join(hc.get("SecurityOpt") or [])
60
+ nofile, fsize = lim.nofile, lim.fsize_mb * MIB
61
+
62
+ checks = [
63
+ ("not-privileged", hc.get("Privileged") is False, f"Privileged={hc.get('Privileged')}"),
64
+ ("network-none", hc.get("NetworkMode") == "none", f"NetworkMode={hc.get('NetworkMode')}"),
65
+ ("rootfs-read-only", hc.get("ReadonlyRootfs") is True, f"ReadonlyRootfs={hc.get('ReadonlyRootfs')}"),
66
+ ("capabilities-dropped", hc.get("CapDrop") == ["ALL"] and not hc.get("CapAdd"),
67
+ f"CapDrop={hc.get('CapDrop')} CapAdd={hc.get('CapAdd')}"),
68
+ ("no-new-privileges", "no-new-privileges" in secopt, f"SecurityOpt={hc.get('SecurityOpt')}"),
69
+ ("no-unconfined-profile", "unconfined" not in secopt, f"SecurityOpt={hc.get('SecurityOpt')}"),
70
+ ("entrypoint-reset", cfg.get("Entrypoint") in (None, [], [""]), f"Entrypoint={cfg.get('Entrypoint')}"),
71
+ ("non-root-user", cfg.get("User") == f"{SANDBOX_UID}:{SANDBOX_UID}", f"User={cfg.get('User')}"),
72
+ ("memory-limit", hc.get("Memory") == lim.memory_mb * MIB, f"Memory={hc.get('Memory')}"),
73
+ ("swap-disabled", hc.get("MemorySwap") == hc.get("Memory") == lim.memory_mb * MIB,
74
+ f"Memory={hc.get('Memory')} MemorySwap={hc.get('MemorySwap')}"),
75
+ ("cpu-quota", hc.get("NanoCpus") == int(lim.cpus * 1_000_000_000), f"NanoCpus={hc.get('NanoCpus')}"),
76
+ ("pids-limit", hc.get("PidsLimit") == lim.pids, f"PidsLimit={hc.get('PidsLimit')}"),
77
+ ("fd-limit", ulimits.get("nofile") == (nofile, nofile), f"nofile={ulimits.get('nofile')}"),
78
+ ("file-size-limit", ulimits.get("fsize") == (fsize, fsize), f"fsize={ulimits.get('fsize')}"),
79
+ ("core-dumps-off", ulimits.get("core") == (0, 0), f"core={ulimits.get('core')}"),
80
+ ("tmp-tmpfs", all(f in tmpfs.split(",") for f in ("noexec", "nosuid", "nodev", f"size={lim.tmpfs_mb}m")),
81
+ f"/tmp={tmpfs!r}"),
82
+ ("shm-limit", hc.get("ShmSize") == lim.shm_mb * MIB, f"ShmSize={hc.get('ShmSize')}"),
83
+ ("workspace-mount-read-only",
84
+ len(mounts) == 1 + len(extra_mount_targets) and not hc.get("Binds")
85
+ and all(isinstance(m, dict) and m.get("Type") == "bind" and m.get("ReadOnly") is True for m in mounts)
86
+ and sorted(str(m.get("Target")) for m in mounts if isinstance(m, dict)) == sorted(("/workspace", *extra_mount_targets)),
87
+ f"Mounts={mounts} Binds={hc.get('Binds')} expected targets={['/workspace', *extra_mount_targets]}"),
88
+ ("no-image-volumes", not cfg.get("Volumes"), f"image declares VOLUME: {cfg.get('Volumes')}"),
89
+ ("no-devices", not hc.get("Devices"), f"Devices={hc.get('Devices')}"),
90
+ ("no-published-ports", not hc.get("PortBindings") and hc.get("PublishAllPorts") is False,
91
+ f"PortBindings={hc.get('PortBindings')}"),
92
+ ("private-namespaces", hc.get("PidMode") == "" and hc.get("IpcMode") in ("", "private")
93
+ and hc.get("UsernsMode") == "" and hc.get("UTSMode") == "",
94
+ f"Pid={hc.get('PidMode')!r} Ipc={hc.get('IpcMode')!r} Userns={hc.get('UsernsMode')!r} UTS={hc.get('UTSMode')!r}"),
95
+ ("init-process", hc.get("Init") is True, f"Init={hc.get('Init')}"),
96
+ ("logging-off", (hc.get("LogConfig") or {}).get("Type") == "none", f"LogConfig={hc.get('LogConfig')}"),
97
+ ("oom-kill-enabled", not hc.get("OomKillDisable"), f"OomKillDisable={hc.get('OomKillDisable')}"),
98
+ ("no-restart", (hc.get("RestartPolicy") or {}).get("Name") in ("", "no"),
99
+ f"RestartPolicy={hc.get('RestartPolicy')}"),
100
+ ("created-from-resolved-image", info.get("Image") == image_id, f"Image={info.get('Image')}"),
101
+ ("saferun-label", (cfg.get("Labels") or {}).get(CONTAINER_LABEL.split("=")[0]) == "1",
102
+ "label saferun.sandbox"),
103
+ ]
104
+ return [ControlStatus(n, "verified" if ok else "violated", d) for n, ok, d in checks]
@@ -0,0 +1,338 @@
1
+ """Race-safe, limit-enforcing, exposure-aware copy of an untrusted directory tree.
2
+
3
+ Two passes over the tree, both relative to already-open directory fds with O_NOFOLLOW:
4
+
5
+ 1. Pre-scan (stat only for files): records a *signature* (type/mode, dev, ino, size, mtime_ns,
6
+ ctime_ns) for every visited entry and the name set of every directory it lists.
7
+ 2. Copy: every entry's lstat, and the object actually opened, must match its recorded
8
+ signature before anything is read; every directory listing must equal the recorded name set;
9
+ after reading, the file must be unchanged.
10
+ 3. Final validation: the whole tree is walked once more and every directory (identity,
11
+ metadata, listing) and every entry must still match the pre-scan records, so changes made
12
+ after an entry was copied, or after its directory was listed, are caught too.
13
+ Any replaced, added, removed or modified entry found by any step aborts the whole copy with
14
+ WorkspaceChangedError (nothing is kept); the caller can simply retry.
15
+
16
+ This is *detection on a live filesystem, not an atomic snapshot*: a change made after the final
17
+ validation cannot affect the already-copied files (each was verified when read), but without
18
+ filesystem snapshot support SafeRun cannot make the tree stand still while it is read, and a
19
+ modification that leaves every recorded field identical (e.g. within one coarse timestamp tick on
20
+ kernels without fine-grained timestamps) is not detectable.
21
+
22
+ ctime cannot be set from user space and changes on rename, chmod, link/unlink and content
23
+ writes, so a restored mtime or a reused inode number does not hide a replacement.
24
+
25
+ Exposure rules (see SensitivePolicy / secrets.py):
26
+ - not world-readable in the source -> withheld, never opened or read
27
+ - credential signature in content -> withheld (written copy deleted)
28
+ - hard link whose inode has a name outside the scanned tree -> skipped
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import errno
33
+ import os
34
+ import stat
35
+ from collections import Counter
36
+ from dataclasses import dataclass
37
+
38
+ from .config import (
39
+ SensitivePolicy, SkippedEntry, WithheldEntry, WorkspaceChangedError, WorkspaceLimitError,
40
+ WorkspaceLimits,
41
+ )
42
+ from .secrets import LEARN_MAX, Redactor, SignatureScanner
43
+
44
+ _RD = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) # POSIX copy engine; Windows uses copy_windows.py (same value on Linux/macOS)
45
+ CHUNK = 1024 * 1024
46
+ _GONE = (errno.ELOOP, errno.ENOTDIR, errno.ENOENT) # opening failed because the entry was replaced/removed
47
+ _SIZE = 3 # index of st_size inside a signature
48
+
49
+
50
+ def _sig(st: os.stat_result) -> tuple:
51
+ return (st.st_mode, st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns, st.st_ctime_ns)
52
+
53
+
54
+ def _changed(path: str, what: str) -> WorkspaceChangedError:
55
+ return WorkspaceChangedError(
56
+ f"{path or '.'}: {what} while SafeRun was copying; the copy was abandoned (retry on a quiescent tree)")
57
+
58
+
59
+ @dataclass
60
+ class CopyResult:
61
+ skipped: list[SkippedEntry]
62
+ withheld: list[WithheldEntry]
63
+ entries_scanned: int # every directory entry visited; == copied_files + copied_dirs + len(skipped) + len(withheld)
64
+ copied_files: int # regular files written and kept
65
+ copied_dirs: int
66
+ copied_bytes: int # bytes of the kept files only (discarded/withheld content is not counted)
67
+ redactor: Redactor
68
+
69
+
70
+ class _Budget:
71
+ def __init__(self, lim: WorkspaceLimits):
72
+ self.lim, self.files, self.bytes = lim, 0, 0
73
+
74
+ def entry(self) -> None:
75
+ self.files += 1
76
+ if self.files > self.lim.max_files:
77
+ raise WorkspaceLimitError(f"project has more than {self.lim.max_files} entries")
78
+
79
+ def add_bytes(self, n: int) -> None:
80
+ self.bytes += n
81
+ if self.bytes > self.lim.max_bytes:
82
+ raise WorkspaceLimitError(f"project is larger than {self.lim.max_bytes} bytes")
83
+
84
+ def depth(self, d: int) -> None:
85
+ if d > self.lim.max_depth:
86
+ raise WorkspaceLimitError(f"project is nested deeper than {self.lim.max_depth} directories")
87
+
88
+
89
+ class _Sink:
90
+ """Writes the copy under `dest` (a directory we own and created; mode 0700 until the copy is done)."""
91
+
92
+ def __init__(self, dest: str):
93
+ self.dest = dest
94
+
95
+ def mkdir(self, rel: str) -> None:
96
+ p = os.path.join(self.dest, rel)
97
+ os.mkdir(p, 0o755)
98
+ os.chmod(p, 0o755)
99
+
100
+ def write(self, rel: str, src_fd: int, want: tuple, budget: _Budget, redactor: Redactor,
101
+ withhold_on_match: bool) -> tuple[str | None, int]:
102
+ """Copy one file. Returns (signature kind, bytes kept); kind is set iff the copy was removed."""
103
+ dest, scanner, kind = os.path.join(self.dest, rel), SignatureScanner(), None
104
+ head: bytearray | None = bytearray()
105
+ written = read = 0
106
+ fd = os.open(dest, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC, 0o644)
107
+ try:
108
+ os.fchmod(fd, 0o644) # executable bits intentionally dropped
109
+ while chunk := os.read(src_fd, CHUNK):
110
+ read += len(chunk)
111
+ budget.add_bytes(len(chunk)) # real bytes: files may grow after the pre-scan
112
+ kind = kind or scanner.feed(chunk)
113
+ if kind and withhold_on_match:
114
+ break
115
+ if head is not None:
116
+ head += chunk
117
+ head = head if len(head) <= LEARN_MAX else None
118
+ view = memoryview(chunk)
119
+ while view:
120
+ n = os.write(fd, view)
121
+ written += n
122
+ view = view[n:]
123
+ finally:
124
+ os.close(fd)
125
+ if kind and withhold_on_match:
126
+ os.unlink(dest)
127
+ return kind, 0
128
+ # Fully read: the file must be the very object and state the pre-scan recorded.
129
+ if read != want[_SIZE] or _sig(os.fstat(src_fd)) != want:
130
+ raise _changed(rel, "file was modified while being read")
131
+ if head is not None:
132
+ redactor.learn(bytes(head))
133
+ return None, written
134
+
135
+
136
+ class _Walker:
137
+ """One traversal. sink=None is the pre-scan: stat only for files, nothing is read or written.
138
+
139
+ Pre-scan fills `records` / `listings`; the copy pass is given them as `expected*` and verifies.
140
+ """
141
+
142
+ def __init__(self, lim: WorkspaceLimits, policy: SensitivePolicy, sink: _Sink | None,
143
+ seen_links: Counter | None = None, expected: dict | None = None,
144
+ expected_listings: dict | None = None):
145
+ self.budget, self.policy, self.sink = _Budget(lim), policy, sink
146
+ self.skipped: list[SkippedEntry] = []
147
+ self.withheld: list[WithheldEntry] = []
148
+ self.redactor = Redactor()
149
+ self.copied_files = self.copied_dirs = self.copied_bytes = 0
150
+ self.links: Counter = Counter() # pre-scan: names seen per multi-link inode
151
+ self.seen_links = seen_links or Counter() # copy pass: result of the pre-scan
152
+ self.records: dict[str, tuple] = {} # pre-scan: path -> signature
153
+ self.listings: dict[str, frozenset] = {} # pre-scan: dir path -> names
154
+ self.expected, self.expected_listings = expected, expected_listings
155
+
156
+ def _private(self, path: str, mode: int, other_bits: int) -> bool:
157
+ if (self.policy.honor_private_modes and path not in self.policy.allow_paths
158
+ and not mode & other_bits):
159
+ self.withheld.append(WithheldEntry(path, "private-mode"))
160
+ return True
161
+ return False
162
+
163
+ def _verify(self, path: str, want: tuple) -> None:
164
+ """Pre-scan: record. Copy pass: the entry must be exactly what was recorded."""
165
+ if self.expected is None:
166
+ self.records[path] = want
167
+ elif self.expected.get(path) != want:
168
+ raise _changed(path, "entry was replaced, added or modified")
169
+
170
+ def _list(self, dir_fd: int, rel: str) -> list[str]:
171
+ """List a directory, counting entries against the budget *as they are read*, so a huge
172
+ directory is rejected without being read in full. The copy pass must see the recorded names."""
173
+ exp = None
174
+ if self.expected_listings is not None:
175
+ exp = self.expected_listings.get(rel)
176
+ if exp is None:
177
+ raise _changed(rel, "directory was not part of the pre-scan")
178
+ names: list[str] = []
179
+ with os.scandir(dir_fd) as it:
180
+ for e in it:
181
+ self.budget.entry()
182
+ if exp is not None and e.name not in exp:
183
+ raise _changed(f"{rel}/{e.name}" if rel else e.name, "entry was added")
184
+ names.append(e.name)
185
+ if exp is not None:
186
+ if len(names) != len(exp):
187
+ raise _changed(rel, "an entry was removed")
188
+ else:
189
+ self.listings[rel] = frozenset(names)
190
+ return sorted(names)
191
+
192
+ def walk(self, dir_fd: int, rel: str, depth: int) -> None:
193
+ for name in self._list(dir_fd, rel):
194
+ path = f"{rel}/{name}" if rel else name
195
+ try:
196
+ st = os.stat(name, dir_fd=dir_fd, follow_symlinks=False)
197
+ except FileNotFoundError:
198
+ raise _changed(path, "entry was removed") from None
199
+ except OSError:
200
+ self.skipped.append(SkippedEntry(path, "unreadable"))
201
+ continue
202
+ want = _sig(st)
203
+ self._verify(path, want)
204
+ if stat.S_ISLNK(st.st_mode):
205
+ self.skipped.append(SkippedEntry(path, "symlink"))
206
+ elif stat.S_ISDIR(st.st_mode):
207
+ self._dir(dir_fd, name, path, st, want, depth)
208
+ elif stat.S_ISREG(st.st_mode):
209
+ self._file(dir_fd, name, path, st, want)
210
+ else:
211
+ self.skipped.append(SkippedEntry(path, "not-regular-file"))
212
+
213
+ def _open(self, name, dir_fd, extra, path, want):
214
+ """Open without following symlinks; the opened object must match `want`. -> (fd, fstat) | None"""
215
+ try:
216
+ fd = os.open(name, _RD | extra, dir_fd=dir_fd)
217
+ except OSError as e:
218
+ if e.errno in _GONE:
219
+ raise _changed(path, "entry was replaced or removed") from None
220
+ self.skipped.append(SkippedEntry(path, "unreadable"))
221
+ return None
222
+ fst = os.fstat(fd)
223
+ if _sig(fst) != want:
224
+ os.close(fd)
225
+ raise _changed(path, "entry was replaced or modified")
226
+ return fd, fst
227
+
228
+ def _dir(self, dir_fd, name, path, st, want, depth):
229
+ self.budget.depth(depth + 1)
230
+ if self._private(path, st.st_mode, 0o005):
231
+ return
232
+ opened = self._open(name, dir_fd, os.O_DIRECTORY, path, want)
233
+ if opened is None:
234
+ return
235
+ fd, _ = opened
236
+ try:
237
+ if self.sink:
238
+ self.sink.mkdir(path)
239
+ self.copied_dirs += 1
240
+ self.walk(fd, path, depth + 1)
241
+ finally:
242
+ os.close(fd)
243
+
244
+ def _file(self, dir_fd, name, path, st, want):
245
+ if self.sink is None: # pre-scan
246
+ if st.st_nlink > 1:
247
+ self.links[(st.st_dev, st.st_ino)] += 1
248
+ if not self._private(path, st.st_mode, 0o004):
249
+ self.budget.add_bytes(st.st_size)
250
+ return
251
+ if self._private(path, st.st_mode, 0o004): # never opened, never read
252
+ return
253
+ opened = self._open(name, dir_fd, os.O_NONBLOCK | os.O_NOCTTY, path, want)
254
+ if opened is None:
255
+ return
256
+ fd, fst = opened
257
+ try:
258
+ # Hard link: only safe if EVERY name of this inode was seen inside the tree.
259
+ if fst.st_nlink > 1 and self.seen_links[(fst.st_dev, fst.st_ino)] != fst.st_nlink:
260
+ self.skipped.append(SkippedEntry(path, "hardlink-external"))
261
+ return
262
+ kind, kept = self.sink.write(path, fd, want, self.budget, self.redactor,
263
+ withhold_on_match=path not in self.policy.allow_paths)
264
+ if kind:
265
+ self.withheld.append(WithheldEntry(path, kind))
266
+ else:
267
+ self.copied_files += 1
268
+ self.copied_bytes += kept
269
+ finally:
270
+ os.close(fd)
271
+
272
+
273
+ def _validate_tree(dir_fd: int, rel: str, records: dict, listings: dict, budget: _Budget) -> None:
274
+ """Final pass: `dir_fd` and everything below it must still be exactly what the pre-scan recorded.
275
+
276
+ Recurses only into directories the pre-scan descended into (those with a recorded listing);
277
+ other entries (symlinks, withheld or unreadable directories, special files) are checked by lstat.
278
+ """
279
+ expected = listings[rel]
280
+ names: list[str] = []
281
+ with os.scandir(dir_fd) as it:
282
+ for e in it:
283
+ budget.entry()
284
+ if e.name not in expected:
285
+ raise _changed(f"{rel}/{e.name}" if rel else e.name, "entry was added (final check)")
286
+ names.append(e.name)
287
+ if len(names) != len(expected):
288
+ raise _changed(rel, "an entry was removed (final check)")
289
+ for name in sorted(names):
290
+ path = f"{rel}/{name}" if rel else name
291
+ try:
292
+ st = os.stat(name, dir_fd=dir_fd, follow_symlinks=False)
293
+ except FileNotFoundError:
294
+ raise _changed(path, "entry was removed (final check)") from None
295
+ except OSError:
296
+ if path in records:
297
+ raise _changed(path, "entry became unreadable (final check)") from None
298
+ continue # was already unreadable at pre-scan time
299
+ if records.get(path) != _sig(st):
300
+ raise _changed(path, "entry was replaced or modified (final check)")
301
+ if stat.S_ISDIR(st.st_mode) and path in listings:
302
+ try:
303
+ fd = os.open(name, _RD | os.O_DIRECTORY, dir_fd=dir_fd)
304
+ except OSError:
305
+ raise _changed(path, "directory was replaced or removed (final check)") from None
306
+ try:
307
+ if _sig(os.fstat(fd)) != records[path]:
308
+ raise _changed(path, "directory was replaced or modified (final check)")
309
+ _validate_tree(fd, path, records, listings, budget)
310
+ finally:
311
+ os.close(fd)
312
+
313
+
314
+ def copy_tree(src: str, dest: str, lim: WorkspaceLimits,
315
+ policy: SensitivePolicy | None = None) -> CopyResult:
316
+ """Pre-scan (stat only), verified copy, then a final validation pass.
317
+
318
+ Raises WorkspaceLimitError before anything is copied if the pre-scan already exceeds a
319
+ limit (limits are enforced again during the copy) and WorkspaceChangedError if the tree
320
+ is found modified between the passes, during a read, or by the final validation.
321
+ """
322
+ policy = policy or SensitivePolicy()
323
+ root_fd = os.open(src, _RD | os.O_DIRECTORY)
324
+ try:
325
+ pre = _Walker(lim, policy, None)
326
+ pre.records[""] = _sig(os.fstat(root_fd))
327
+ pre.walk(root_fd, "", 0)
328
+ w = _Walker(lim, policy, _Sink(dest), pre.links, pre.records, pre.listings)
329
+ if _sig(os.fstat(root_fd)) != pre.records[""]:
330
+ raise _changed("", "the project root was modified")
331
+ w.walk(root_fd, "", 0)
332
+ if _sig(os.fstat(root_fd)) != pre.records[""]:
333
+ raise _changed("", "the project root was modified (final check)")
334
+ _validate_tree(root_fd, "", pre.records, pre.listings, _Budget(lim))
335
+ return CopyResult(w.skipped, w.withheld, w.budget.files, w.copied_files, w.copied_dirs,
336
+ w.copied_bytes, w.redactor)
337
+ finally:
338
+ os.close(root_fd)