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
|
+
"""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]
|
saferun/sandbox/copy.py
ADDED
|
@@ -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)
|