diff --git a/tools/conformance/multiidentity/f10_filesystem_boundary.py b/tools/conformance/multiidentity/f10_filesystem_boundary.py new file mode 100644 index 00000000..d248dcba --- /dev/null +++ b/tools/conformance/multiidentity/f10_filesystem_boundary.py @@ -0,0 +1,463 @@ +#!/usr/bin/env python3 +"""Disposable F1.0 filesystem mechanisms, not public mapped qualification. + +Run as the non-root owner of a Linux Podman engine. All resources use a new +private store. The supplied binary is built from f10_filesystem_payload.go.txt; +its expected digest must come from the retained build receipt. No host files, +default engine store, host services or account configuration are changed. +""" + +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import os +from pathlib import Path +import secrets +import select +import shutil +import signal +import stat +import subprocess +import tarfile +import tempfile +import time + +import operation_audit as audit + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +class SourceBindingRefused(RuntimeError): + """The retained source object no longer matches the protected binding.""" + + +def source_snapshot(path): + """Read a source through a non-following descriptor and retain its identity.""" + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC) + try: + st = os.fstat(fd) + data = os.read(fd, 16 << 20) + if os.read(fd, 1): + raise RuntimeError("source fixture exceeds observation limit") + finally: + os.close(fd) + return {"device": st.st_dev, "inode": st.st_ino, "size": st.st_size, + "sha256": hashlib.sha256(data).hexdigest()} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--binary", type=Path, required=True) + parser.add_argument("--expected-sha256", required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--podman", default="/usr/bin/podman") + parser.add_argument("--runtime", default="/usr/bin/crun") + args = parser.parse_args() + args.binary = args.binary.absolute() + if os.geteuid() == 0: + parser.error("run as a non-root engine owner") + args.output_dir.mkdir(mode=0o700) + expected = args.expected_sha256 + if len(expected) != 64 or any(c not in "0123456789abcdef" for c in expected): + parser.error("expected digest must be a lowercase SHA-256") + if args.binary.is_symlink() or sha(args.binary) != expected: + parser.error("fixture bytes do not match the supplied build digest") + work = Path(tempfile.mkdtemp(prefix="reploy-f10-")) + token = secrets.token_hex(12) + env = dict(os.environ) + for name, key in [("runtime", "XDG_RUNTIME_DIR"), ("config", "XDG_CONFIG_HOME"), + ("data", "XDG_DATA_HOME")]: + (work / name).mkdir(mode=0o700) + env[key] = str(work / name) + prefix = [args.podman, "--remote=false", "--runtime", args.runtime, + "--root", str(work / "graph"), "--runroot", str(work / "run"), + "--storage-driver", "vfs", "--cgroup-manager", "cgroupfs", + "--events-backend", "file"] + socket_path = work / "api.sock" + label = "reploy.f10.attempt" + result = {"scope": "F1.0 disposable filesystem mechanisms; no public launch or F provider qualification", + "attempt": token, "work": str(work), "cases": [], "cleanup": [], + "binary_sha256": expected, "passed": False, + "limits": ["No application admission, generation binding or production provider is claimed", + "Engine identity and actual kernel maps are recorded; this does not qualify another engine build", + "Host root or engine-owner interference after admission is outside the integrity guarantee"]} + commands, containers = [], [] + server, image, log = None, None, None + started = time.monotonic() + + def run(argv, check=True, timeout=60): + p = subprocess.run(argv, cwd=work, env=env, capture_output=True, text=True, timeout=timeout) + commands.append({"argv": argv, "exit_code": p.returncode, + "stdout": p.stdout, "stderr": p.stderr}) + if check and p.returncode: + raise RuntimeError(f"command failed: {argv[:3]}: {p.stderr[-1500:]}") + return p + + def api(method, route, body=None): + return audit.api(socket_path, method, route, body) + + def inventory(): + return {kind: sorted(run(prefix + command).stdout.splitlines()) for kind, command in { + "containers": ["ps", "-aq"], "images": ["images", "-aq"], + "volumes": ["volume", "ls", "-q"], "networks": ["network", "ls", "-q"], + }.items()} + + def logs(identity): + # Native logs are an independent fixture observation, not API approval. + p = run(prefix + ["logs", identity]) + return [json.loads(line) for line in p.stdout.splitlines() if line.startswith("{")] + + def wait_phase(identity, phase): + deadline = time.monotonic() + 20 + while time.monotonic() < deadline: + rows = logs(identity) + if any(x.get("phase") == phase for x in rows): + return rows + time.sleep(0.05) + raise TimeoutError("fixture phase " + phase) + + def stop(identity): + obj = api("GET", f"/containers/{identity}/json") + if obj["Config"]["Labels"].get(label) != token or obj["Id"] != identity: + raise RuntimeError("refusing foreign container cleanup") + api("DELETE", f"/containers/{identity}?force=true") + result["cleanup"].append({"container": identity, "removed": True}) + + def checked_effect(stage, source_path, expected_source, effect, bound=None, + check_source=True): + """Check the retained source and protected bind before invoking an effect.""" + try: + current = source_snapshot(source_path) + except OSError as exc: + raise SourceBindingRefused(f"{stage}: source observation failed") from exc + if check_source: + source_keys = ("device", "inode", "size", "sha256") + if any(current[key] != expected_source[key] for key in source_keys): + raise SourceBindingRefused(f"{stage}: source object/hash changed") + if bound is not None and any(bound[key] != expected_source[key] + for key in ("device", "inode", "sha256")): + raise SourceBindingRefused(f"{stage}: protected bind changed") + return {"source": current, "bound": bound, "effect": effect()} + + def expect_refusal(action): + """Negative oracle: stale source must refuse before the supplied effect.""" + try: + action() + except SourceBindingRefused as exc: + return {"refused": True, "error": str(exc)} + raise AssertionError("negative oracle accepted a stale source") + + try: + info = json.loads(run(prefix + ["info", "--format", "json"]).stdout) + assert info["host"]["security"]["rootless"] is True + result["initial_inventory"] = inventory() + result["engine"] = {"version": info["version"], "kernel": info["host"]["kernel"], + "runtime": info["host"]["ociRuntime"], "rootless": True, + "binary_sha256": sha(args.podman), "runtime_sha256": sha(args.runtime)} + # Descriptor/mount crossing proof runs in a disposable private mount ns. + host = run(prefix + ["unshare", "unshare", "--mount", "--propagation", "private", + str(args.binary), "host"]) + result["descriptor_checks"] = json.loads(host.stdout) + assert all(result["descriptor_checks"]["checks"].values()) + retained = work / "retained" + retained.mkdir(mode=0o700) + original = work / "original" + shutil.copyfile(args.binary, original) + artifact = retained / "probe" + shutil.copyfile(original, artifact) + artifact.chmod(0o555) + assert sha(artifact) == expected + expected_source = source_snapshot(artifact) + # Changes to an independent original do not change retained bytes. + original.write_bytes(b"changed original") + assert sha(artifact) == expected + result["protected_capture_independent"] = True + archive = work / "rootfs.tar" + with tarfile.open(archive, "w") as tar: + for directory in [".", "data", "empty", "only", "only/child", "proc", "tmp"]: + entry = tarfile.TarInfo(directory) + entry.type, entry.mode = tarfile.DIRTYPE, 0o755 + tar.addfile(entry) + payload = artifact.read_bytes() + entry = tarfile.TarInfo("fixture") + entry.mode, entry.size = 0o555, len(payload) + tar.addfile(entry, io.BytesIO(payload)) + image = run(prefix + ["import", "--change", f"LABEL {label}={token}", + str(archive), "localhost/reploy-f10:" + token]).stdout.strip() + log = (args.output_dir / "api.log").open("wb") + server = subprocess.Popen(prefix + ["system", "service", "--time=0", "unix://" + str(socket_path)], + cwd=work, env=env, stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT) + deadline = time.monotonic() + 15 + while not socket_path.exists(): + if server.poll() is not None or time.monotonic() > deadline: + raise RuntimeError("private compatible API unavailable") + time.sleep(0.05) + assert stat.S_ISSOCK(socket_path.stat().st_mode) + result["compatible_version"] = api("GET", "/version") + # Positive root policies and all accepted empty/directory/absent targets. + for profile in ["exact", "bounded"]: + span = 1 if profile == "exact" else 4096 + namespace = f"auto:size=1,uidmapping=0:1:{span},gidmapping=0:1:{span}" + if profile == "exact": + namespace += ",gidmapping=5:2:1" # devpts runtime group, as in the C fixture. + for mode, target in [("readonly", "/empty"), ("readonly", "/only"), + ("readonly", "/absent"), ("writable", "/data")]: + case = f"{profile}-{mode}-{target[1:]}" + data = work / case + data.mkdir(mode=0o777) + data.chmod(0o777) + body = {"Image": image, "Entrypoint": ["/reploy-probe"], + "Cmd": ["container", mode, target], "User": "0:0", "Labels": {label: token}, + "HostConfig": {"UsernsMode": namespace, "NetworkMode": "none", + "ReadonlyRootfs": mode == "readonly", "CapDrop": ["ALL"], + "SecurityOpt": ["no-new-privileges"], "RestartPolicy": {"Name": "no"}, + "Binds": [f"{artifact}:/reploy-probe:ro", f"{data}:/data:rw"]}} + # A second bind proves each target on the actual engine. + if target != "/data": + body["HostConfig"]["Binds"].append(f"{data}:{target}:rw") + name = "reploy-f10-" + token + "-" + case + + def prepare(): + created = checked_effect( + "pre-create", artifact, expected_source, + lambda: api("POST", "/containers/create?name=" + name, body)) + identity = created["effect"]["Id"] + containers.append(identity) + api("POST", f"/containers/{identity}/start") + return identity + + # Exercise the same checked preparation path with a replaced source. + if "source_substitution_refused_before_create" not in result: + before_inventory = inventory() + pinned = retained / "original-probe" + artifact.rename(pinned) + try: + retained_source = source_snapshot(pinned) + assert retained_source == expected_source + artifact.write_bytes(b"substitute") + stale_source = source_snapshot(artifact) + assert (stale_source["device"], stale_source["inode"]) != \ + (expected_source["device"], expected_source["inode"]) + assert stale_source["sha256"] != expected + try: + prepare() + except SourceBindingRefused as exc: + after_inventory = inventory() + assert after_inventory == before_inventory + assert not containers + precreate = { + "refused": True, + "effect": False, + "error": str(exc), + "retained_source": retained_source, + "stale_source": stale_source, + "inventory_unchanged": True, + } + else: + raise AssertionError("pre-create source substitution was accepted") + + # Deliberately omit the source check. The oracle must fail + # closed rather than record a refusal for this mutation. + control_effects = [] + try: + expect_refusal(lambda: checked_effect( + "pre-create-skipped-check", artifact, expected_source, + lambda: control_effects.append("invoked"), + check_source=False)) + except AssertionError as exc: + assert control_effects == ["invoked"] + precreate["skipped_source_check_control"] = { + "negative_oracle_failed": True, + "effect_invoked": True, + "error": str(exc), + } + else: + raise AssertionError("skipped source check did not fail negative oracle") + finally: + artifact.unlink(missing_ok=True) + pinned.rename(artifact) + assert source_snapshot(artifact) == expected_source + result["source_substitution_refused_before_create"] = precreate + + identity = prepare() + rows = wait_phase(identity, "inhibited") + prepared = rows[-1] + assert prepared["sha256"] == expected + # Kernel evidence, independent of requested engine flags. + observed = api("GET", f"/containers/{identity}/json") + pid = observed["State"]["Pid"] + assert isinstance(pid, int) and pid > 0 and observed["State"]["Running"] + # Read from the guest's initial user namespace. The container's + # own uid_map describes its parent namespace, not host IDs. + proc = Path("/proc") / str(pid) + host_uid_map = (proc / "uid_map").read_text() + host_gid_map = (proc / "gid_map").read_text() + maps = [list(map(int, line.split())) for line in host_uid_map.splitlines()] + assert len(maps) == 1 and maps[0][0] == 0 and maps[0][2] == span + assert maps[0][1] not in [0, os.getuid()] + gids = [list(map(int, line.split())) for line in host_gid_map.splitlines()] + gid_base = gids[0][1] + assert gid_base not in [0, os.getgid()] + assert gids == ([[0, gid_base, 1], [5, gid_base + 1, 1]] + if profile == "exact" else [[0, gid_base, span]]) + mounts = [line.split() for line in prepared["mountinfo"].splitlines()] + protected = [m for m in mounts if m[4] == "/reploy-probe"] + assert len(protected) == 1 and "ro" in protected[0][5].split(",") + roots = [m for m in mounts if m[4] == "/"] + assert len(roots) == 1 and ("ro" if mode == "readonly" else "rw") in roots[0][5].split(",") + assert any(m[4] == target and "rw" in m[5].split(",") for m in mounts) + assert "CapEff:\t0000000000000000" in prepared["status"] + assert "NoNewPrivs:\t1" in prepared["status"] + def observe(): + return json.loads(run(prefix + ["unshare", str(args.binary), "observe", str(pid)]).stdout) + observer = observe() + source = artifact.stat() + assert observer["sha256"] == expected and observer["pinned_process_alive"] + assert (observer["device"], observer["inode"]) == (source.st_dev, source.st_ino) + host_mounts = [line.split() for line in observer["mountinfo"].splitlines()] + assert any(m[4] == "/reploy-probe" and "ro" in m[5].split(",") for m in host_mounts) + substitution = None + if mode == "writable": + pinned = retained / "pinned-probe" + artifact.rename(pinned) + try: + retained_source = source_snapshot(pinned) + assert retained_source == expected_source + artifact.write_bytes(b"SUBSTITUTE") + stale_source = source_snapshot(artifact) + still_bound = observe() + # The bind retains the original inode; name substitution + # cannot authorize release from a stale host observation. + assert still_bound["sha256"] == expected + assert still_bound["inode"] == source.st_ino + assert stale_source["inode"] != source.st_ino and stale_source["sha256"] != expected + markers_before = {name: (data / name).exists() + for name in ["release", "payload", "target-payload"]} + assert not any(markers_before.values()) + try: + checked_effect( + "pre-release", artifact, expected_source, + lambda: (data / "release").write_bytes(b"fixture release"), + bound=still_bound) + except SourceBindingRefused as exc: + after_bind = observe() + refusal_rows = logs(identity) + markers_after = {name: (data / name).exists() + for name in ["release", "payload", "target-payload"]} + assert markers_after == markers_before + assert after_bind["pinned_process_alive"] + assert after_bind["sha256"] == expected + assert any(x.get("phase") == "inhibited" for x in refusal_rows) + assert not any(x.get("phase") == "completed" for x in refusal_rows) + substitution = { + "refused_before_release": True, + "effect": False, + "error": str(exc), + "retained_source": retained_source, + "stale_source": stale_source, + "bound_original": still_bound, + "post_refusal_bind": after_bind, + "markers_before": markers_before, + "markers_after": markers_after, + "process_remained_inhibited": True, + } + else: + raise AssertionError("post-bind source substitution was accepted") + finally: + artifact.unlink(missing_ok=True) + pinned.rename(artifact) + assert source_snapshot(artifact) == expected_source + + release_observation = checked_effect( + "pre-release", artifact, expected_source, + lambda: (data / "release").write_bytes(b"fixture release"), + bound=observe()) + rows = wait_phase(identity, "completed") + assert all(rows[-1]["checks"].values()) and rows[-1]["sha256"] == expected + waited = api("POST", f"/containers/{identity}/wait?condition=not-running") + assert waited["StatusCode"] == 0 + assert run(prefix + ["unshare", "cat", str(data / "payload")]).stdout == "managed" + assert run(prefix + ["unshare", "cat", str(data / "target-payload")]).stdout == "target" + result["cases"].append({"case": case, "container": identity, "observations": rows, + "host_pid": pid, "host_uid_map": host_uid_map, + "host_gid_map": host_gid_map, + "host_observer": observer, "source_substitution": substitution, + "release_observation": release_observation, + "exit_status": waited["StatusCode"], "passed": True}) + stop(identity) + containers.remove(identity) + result["passed"] = len(result["cases"]) == 8 + except Exception as exc: + result["error"] = repr(exc) + finally: + for identity in containers: + try: + stop(identity) + except Exception as exc: + result["cleanup"].append({"container": identity, "error": repr(exc)}) + result["passed"] = False + if server is not None: + server.terminate() + try: + server.wait(timeout=8) + except subprocess.TimeoutExpired: + server.kill() + server.wait(timeout=5) + result["api_stopped"] = server.poll() is not None + if log is not None: + log.close() + if image: + removed = run(prefix + ["rmi", image], check=False) + result["cleanup"].append({"image": image, "removed": removed.returncode == 0}) + result["passed"] = result["passed"] and removed.returncode == 0 + try: + result["final_inventory"] = inventory() + assert result["final_inventory"] == result["initial_inventory"] + result["inventory_restored"] = True + assert work.resolve() == work and work.parent == Path(tempfile.gettempdir()).resolve() + run(prefix + ["unshare", "rm", "-rf", "--", str(work)]) + helpers = [] + for proc in Path("/proc").iterdir(): + if not proc.name.isdecimal(): + continue + try: + values = (proc / "environ").read_bytes().split(b"\0") + if ("XDG_RUNTIME_DIR=" + str(work / "runtime")).encode() not in values: + continue + argv = (proc / "cmdline").read_bytes().split(b"\0") + assert argv[0].endswith(b"/catatonit") and argv[1:2] == [b"-P"] + assert proc.stat().st_uid == os.getuid() + start = (proc / "stat").read_text().rsplit(") ", 1)[1].split()[19] + pinned = os.pidfd_open(int(proc.name)) + try: + assert (proc / "stat").read_text().rsplit(") ", 1)[1].split()[19] == start + signal.pidfd_send_signal(pinned, signal.SIGTERM) + assert select.select([pinned], [], [], 5)[0] + finally: + os.close(pinned) + helpers.append({"pid": int(proc.name), "start": start, "exit_observed": True}) + except (FileNotFoundError, ProcessLookupError, PermissionError): + continue + result["private_helpers_retired"] = helpers + if work.exists(): + shutil.rmtree(work) + result["private_root_removed"] = not work.exists() + except Exception as exc: + result["cleanup_error"] = repr(exc) + result["passed"] = False + result["elapsed_seconds"] = time.monotonic() - started + result["sources"] = {str(p): sha(p) for p in [Path(__file__), Path(audit.__file__)]} + (args.output_dir / "commands.json").write_text(json.dumps(commands, indent=2) + "\n") + (args.output_dir / "report.json").write_text(json.dumps(result, indent=2) + "\n") + print(json.dumps({"passed": result["passed"], "cases": len(result["cases"]), + "error": result.get("error"), "work": str(work)})) + return 0 if result["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/conformance/multiidentity/f10_filesystem_payload.go.txt b/tools/conformance/multiidentity/f10_filesystem_payload.go.txt new file mode 100644 index 00000000..a31bc54e --- /dev/null +++ b/tools/conformance/multiidentity/f10_filesystem_payload.go.txt @@ -0,0 +1,236 @@ +// Disposable F1.0 fixture. Build outside the package; this is not shipped code. +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "golang.org/x/sys/unix" +) + +func must(err error) { + if err != nil { + panic(err) + } +} + +func require(ok bool, message string) { + if !ok { + panic(message) + } +} + +func emit(value any) { must(json.NewEncoder(os.Stdout).Encode(value)) } + +func identity(fd int) unix.Stat_t { + var st unix.Stat_t + must(unix.Fstat(fd, &st)) + return st +} + +func confined(root int, name string, flags int) (int, error) { + return unix.Openat2(root, name, &unix.OpenHow{ + Flags: uint64(flags | unix.O_CLOEXEC | unix.O_NOFOLLOW), + Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS | + unix.RESOLVE_NO_MAGICLINKS | unix.RESOLVE_NO_XDEV, + }) +} + +func host() { + root, err := os.MkdirTemp("", "f10-descriptors-") + must(err) + defer os.RemoveAll(root) + selected, foreign := filepath.Join(root, "selected"), filepath.Join(root, "foreign") + must(os.Mkdir(selected, 0700)) + must(os.Mkdir(foreign, 0700)) + canary := filepath.Join(foreign, "canary") + must(os.WriteFile(canary, []byte("UNCHANGED"), 0600)) + fd, err := unix.Open(selected, unix.O_PATH|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + must(err) + defer unix.Close(fd) + checks := map[string]bool{} + for _, name := range []string{"../foreign/canary", canary, "/proc/self/fd/0"} { + x, err := confined(fd, name, unix.O_WRONLY) + if err == nil { + unix.Close(x) + panic("escape accepted: " + name) + } + } + checks["absolute_parent_and_magic_escape_refused"] = true + must(os.Symlink(foreign, filepath.Join(selected, "alias"))) + _, err = confined(fd, "alias/canary", unix.O_WRONLY) + require(errors.Is(err, unix.ELOOP), "symlink ancestor did not refuse") + checks["symlink_ancestor_refused"] = true + must(os.Link(canary, filepath.Join(selected, "linked"))) + x, err := confined(fd, "linked", unix.O_PATH) + must(err) + st := identity(x) + must(unix.Close(x)) + require(st.Nlink == 2, "hardlink control missing") + checks["hardlink_rejected_by_held_metadata"] = st.Nlink != 1 + // Replacement after opening never redirects an operation on the held object. + must(os.WriteFile(filepath.Join(selected, "leaf"), []byte("selected"), 0600)) + x, err = confined(fd, "leaf", unix.O_RDWR) + must(err) + before := identity(x) + must(os.Rename(filepath.Join(selected, "leaf"), filepath.Join(selected, "retained"))) + must(os.Symlink(canary, filepath.Join(selected, "leaf"))) + _, err = unix.Write(x, []byte("PINNED")) + must(err) + after := identity(x) + must(unix.Close(x)) + require(before.Ino == after.Ino && before.Dev == after.Dev, "descriptor identity changed") + data, err := os.ReadFile(canary) + must(err) + require(string(data) == "UNCHANGED", "foreign canary changed") + checks["leaf_substitution_cannot_redirect_held_descriptor"] = true + // Root-name replacement also cannot redirect the held directory descriptor. + must(os.Rename(selected, selected+"-held")) + defer os.RemoveAll(selected + "-held") + must(os.Symlink(foreign, selected)) + x, err = confined(fd, "retained", unix.O_RDONLY) + must(err) + must(unix.Close(x)) + _, err = confined(fd, "canary", unix.O_WRONLY) + require(errors.Is(err, unix.ENOENT), "root replacement redirected descriptor") + checks["root_substitution_cannot_redirect_held_descriptor"] = true + mountpoint := filepath.Join(selected+"-held", "nested") + must(os.Mkdir(mountpoint, 0700)) + must(unix.Mount(foreign, mountpoint, "", unix.MS_BIND, "")) + defer unix.Unmount(mountpoint, 0) + _, err = confined(fd, "nested/canary", unix.O_WRONLY) + require(errors.Is(err, unix.EXDEV), "nested mount crossed") + checks["nested_mount_refused"] = true + data, err = os.ReadFile(canary) + must(err) + require(string(data) == "UNCHANGED", "foreign canary changed") + emit(map[string]any{"checks": checks, "canary": string(data), "uid": os.Getuid()}) +} + +func digest(path string) string { + b, err := os.ReadFile(path) + must(err) + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]) +} + +func observe() { + pid, err := strconv.Atoi(os.Args[2]) + must(err) + require(pid > 0, "invalid observed PID") + pidfd, err := unix.PidfdOpen(pid, 0) + must(err) + defer unix.Close(pidfd) + proc := filepath.Join("/proc", strconv.Itoa(pid)) + before, err := os.ReadFile(filepath.Join(proc, "stat")) + must(err) + root, err := unix.Open(filepath.Join(proc, "root"), unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) + must(err) + defer unix.Close(root) + fd, err := unix.Openat2(root, "reploy-probe", &unix.OpenHow{ + Flags: unix.O_RDONLY | unix.O_CLOEXEC | unix.O_NOFOLLOW, + Resolve: unix.RESOLVE_IN_ROOT | unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_MAGICLINKS, + }) + must(err) + f := os.NewFile(uintptr(fd), "observed-protected-artifact") + defer f.Close() + st := identity(fd) + require(st.Mode&unix.S_IFMT == unix.S_IFREG && st.Size > 0 && st.Size < 16<<20, "invalid observed artifact") + h := sha256.New() + n, err := io.Copy(h, io.LimitReader(f, 16<<20)) + must(err) + require(n == st.Size, "observed artifact size changed") + mounts, err := os.ReadFile(filepath.Join(proc, "mountinfo")) + must(err) + after, err := os.ReadFile(filepath.Join(proc, "stat")) + must(err) + start := func(data []byte) string { + // Fields after the final comm delimiter start with field 3 (state). + return strings.Fields(string(data)[strings.LastIndex(string(data), ") ")+2:])[19] + } + require(start(before) == start(after), "observed process changed") + poll := []unix.PollFd{{Fd: int32(pidfd), Events: unix.POLLIN}} + nready, err := unix.Poll(poll, 0) + must(err) + require(nready == 0, "observed process exited") + emit(map[string]any{"pid": pid, "start_time": start(before), "device": st.Dev, + "inode": st.Ino, "size": st.Size, "sha256": hex.EncodeToString(h.Sum(nil)), + "mountinfo": string(mounts), "pinned_process_alive": true}) +} + +func container() { + mode := os.Args[2] + info, err := os.ReadFile("/proc/self/mountinfo") + must(err) + status, err := os.ReadFile("/proc/self/status") + must(err) + uidmap, err := os.ReadFile("/proc/self/uid_map") + must(err) + gidmap, err := os.ReadFile("/proc/self/gid_map") + must(err) + var root unix.Stat_t + must(unix.Stat("/", &root)) + emit(map[string]any{"phase": "inhibited", "sha256": digest("/reploy-probe"), + "mountinfo": string(info), "status": string(status), "uid_map": string(uidmap), "gid_map": string(gidmap), + "root_uid": root.Uid, "root_gid": root.Gid, "root_mode": root.Mode}) + deadline := time.Now().Add(30 * time.Second) + for { + if _, err := os.Stat("/data/release"); err == nil { + break + } + require(time.Now().Before(deadline), "release deadline expired") + time.Sleep(10 * time.Millisecond) + } + checks := map[string]bool{} + // A read-only file bind must resist direct writes and all name replacement. + f, err := os.OpenFile("/reploy-probe", os.O_WRONLY, 0) + if err == nil { + f.Close() + panic("trusted file writable") + } + checks["trusted_write_refused"] = true + checks["trusted_unlink_refused"] = os.Remove("/reploy-probe") != nil + checks["trusted_rename_refused"] = os.Rename("/reploy-probe", "/replaced") != nil + must(os.WriteFile("/data/payload", []byte("managed"), 0600)) + checks["managed_destination_writable"] = true + must(os.WriteFile(filepath.Join(os.Args[3], "target-payload"), []byte("target"), 0600)) + checks["selected_destination_writable"] = true + err = os.WriteFile("/application-change", []byte("explicit"), 0600) + if mode == "writable" && err != nil { + panic(fmt.Sprintf("writable root uid=%d gid=%d mode=%o: %v", root.Uid, root.Gid, root.Mode, err)) + } + checks["root_policy"] = (mode == "writable" && err == nil) || (mode == "readonly" && err != nil) + for name, ok := range checks { + require(ok, "failed: "+name) + } + require(strings.Contains(string(status), "CapEff:\t0000000000000000"), "fixture has capabilities") + emit(map[string]any{"phase": "completed", "checks": checks, "sha256": digest("/reploy-probe")}) +} + +func main() { + defer func() { + if problem := recover(); problem != nil { + fmt.Fprintln(os.Stderr, problem) + os.Exit(1) + } + }() + switch os.Args[1] { + case "host": + host() + case "container": + container() + case "observe": + observe() + default: + panic("unknown fixture mode") + } +}