diff --git a/docs/MULTI_IDENTITY_SECURITY_CONTRACT.md b/docs/MULTI_IDENTITY_SECURITY_CONTRACT.md index dec42763..7c9bae92 100644 --- a/docs/MULTI_IDENTITY_SECURITY_CONTRACT.md +++ b/docs/MULTI_IDENTITY_SECURITY_CONTRACT.md @@ -403,9 +403,11 @@ case instead of reusing a successful live container: - under the bounded-range profile, a raw mechanism probe kept separate from the production supervisor confirms that representative other in-range transitions succeed and every out-of-range transition fails; -- a child probe at its first untrusted instruction sees exactly its declared - descriptor allowlist, while adversarial non-`CLOEXEC` supervisor control-file, - socket, pidfd, and private-data canaries are absent; +- immediately before untrusted exec, the trusted launcher observes exactly the + declared descriptor allowlist after closing adversarial non-`CLOEXEC` + supervisor control-file, socket, pidfd, and private-data canaries; the first + application report confirms the launcher's evidence and the canaries remain + absent; - capability-dropped application children cannot change identity, and neither set-ID nor file-capability execution can restore identity-changing authority; - all four fields in the capability-bearing supervisor's @@ -424,7 +426,8 @@ case instead of reusing a successful live container: - the approved seccomp profile is explicitly selected and identifiable in the resolved and effective runtime policy, application processes report `NoNewPrivs: 1` and seccomp filter mode (`Seccomp: 2`), negative probes for - syscalls the approved profile must block fail, and the container root + syscalls the approved profile must block fail while the same valid operation + succeeds in an otherwise equivalent unfiltered control, and the container root filesystem is read-only except for declared writable storage; - two workloads using the same container IDs receive different host authority, run in separate PID, IPC, network, and mount namespaces with private mount @@ -462,20 +465,20 @@ without an explicit security decision. | ID | Required result | Owning milestone | Minimum evidence | Current state | | --- | --- | --- | --- | --- | -| ID-01 | Every mapped identity, including the dedicated supervisor identity, has exclusively delegated subordinate host authority only; it does not alias host root, a local or NSS-provided host principal, or a subordinate range delegated to another host principal. | 1 | Resolved-state and host-side mapping inspection for exactly one dedicated supervisor UID/GID plus authoritative collision checks against the host identity inventory and every other subordinate UID/GID delegation, explicit collision-failure cases, and an in-container identity probe for each profile. | Unproven; repeated conformance probe pending | -| ID-02 | The trusted supervisor can perform every required declared transition and rejects mapped-but-undeclared UIDs, GIDs, and supplementary groups; every application child's real, effective, saved-set, and filesystem UIDs and GIDs match its declared application identities, and its final supplementary-group vector contains exactly its declared groups; a separate raw bounded-range mechanism probe permits representative other in-range transitions. | 1 | Positive declared-identity and negative mapped-but-undeclared tests through the production supervisor policy, all four `/proc//status` `Uid:` and `Gid:` fields matching the declared application IDs, final `Groups:` inspection that excludes inherited privileged, runtime-required, and otherwise undeclared groups, plus an independently identified raw range-mechanism probe. | Unproven; repeated conformance probe pending | -| ID-03 | Exact mappings reject every unmapped transition; bounded ranges reject every out-of-range transition; capability-dropped children cannot change identity, inherit supervisor or undeclared descriptors, regain authority through set-ID or file-capability execution, or signal, inspect, or control the capability-bearing supervisor. | 1 | Profile-specific boundary and post-drop tests for `setuid`, `setreuid`, `setresuid`, `setfsuid`, `setgid`, `setregid`, `setresgid`, `setfsgid`, `setgroups`, set-user/group-ID execution, and file-capability execution; first-instruction child descriptor enumeration against an exact allowlist plus adversarial non-`CLOEXEC` supervisor control-file, socket, pidfd, and private-data canaries; all four supervisor `Uid:` and `Gid:` fields using its dedicated child-disjoint UID and GID; a final supervisor `Groups:` vector containing no GID other than its dedicated GID; a supervisor `PR_GET_DUMPABLE` result of zero; supervisor/child POSIX session-ID inspection proving separation; and negative same-workload child-to-supervisor `kill` and `pidfd_send_signal` probes that explicitly include `SIGCONT`, plus negative `ptrace`, `/proc//mem`, `process_vm_readv`, and `process_vm_writev` probes. | Unproven; repeated conformance probe pending | -| ID-04 | Two installations using the same container IDs have distinct host mappings, PID, IPC, network, and mount namespaces, private mount propagation, and private `/dev/shm` and `/dev/mqueue` mounts; they cannot observe, signal, or ptrace each other's processes, see each other's mounts, use each other's System V or named POSIX shared-memory, semaphore, or message-queue objects, connect to each other's Linux abstract Unix sockets, or read each other's private state. | 1 | Concurrent two-workload probe with host mapping, PID-namespace, IPC-namespace, network-namespace, mount-namespace, mount-propagation, `/dev/shm`, and `/dev/mqueue` inspection plus negative cross-workload mount visibility, process visibility, signaling, ptrace, System V IPC, POSIX `shm_open`/`sem_open`, POSIX `mq_open`/send/receive, abstract Unix-socket connection, and private-state access tests. | Unproven; repeated conformance probe pending | -| BE-01 | Both mapping profiles run only on a positively identified Podman backend; Docker Engine rejects them before workload mutation. | 1 | Backend detection plus positive Podman and negative Docker Engine integration tests. | Unproven; product integration not implemented | -| CAP-01 | Capability sets are empty by default; an opted-in workload control plane receives only its exact reviewed allowlist, and ordinary children receive none. | 1 and 2 | Inspect bounding, effective, permitted, inheritable, and ambient sets for the default, supervisor, and child processes; verify omitted capabilities fail. | Unproven; capability contract not implemented | +| ID-01 | Every mapped identity, including the dedicated supervisor identity, has exclusively delegated subordinate host authority only; it does not alias host root, a local or NSS-provided host principal, or a subordinate range delegated to another host principal. | 1 | Resolved-state and host-side mapping inspection for exactly one dedicated supervisor UID/GID plus per-ID NSS resolution, fail-closed subordinate-backend enumeration, active-libsubid agreement, checks against every other subordinate UID/GID delegation, injected UID and GID collision cases that must fail for both NSS principals and another delegation, and an in-container identity probe for each profile. | Private 1B conformance passed 3 exact and 3 bounded trials; product integration pending | +| ID-02 | The trusted supervisor can perform every required declared transition and rejects mapped-but-undeclared UIDs, GIDs, and supplementary groups; every application child's real, effective, saved-set, and filesystem UIDs and GIDs match its declared application identities, and its final supplementary-group vector contains exactly its declared groups; a separate raw bounded-range mechanism probe permits representative other in-range transitions. | 1 | Positive declared-identity and negative mapped-but-undeclared tests through the production supervisor policy, all four `/proc//status` `Uid:` and `Gid:` fields matching the declared application IDs, final `Groups:` inspection that excludes inherited privileged, runtime-required, and otherwise undeclared groups, plus an independently identified raw range-mechanism probe. | Private 1B conformance passed 3 exact and 3 bounded trials; product integration pending | +| ID-03 | Exact mappings reject every unmapped transition; bounded ranges reject every out-of-range transition; capability-dropped children cannot change identity, inherit supervisor or undeclared descriptors, regain authority through set-ID or file-capability execution, or signal, inspect, or control the capability-bearing supervisor. | 1 | Profile-specific boundary and post-drop tests for `setuid`, `setreuid`, `setresuid`, `setfsuid`, `setgid`, `setregid`, `setresgid`, `setfsgid`, `setgroups`, execution of a mapped supervisor-owned set-user/group-ID fixture, and file-capability execution; pre-exec trusted-launcher enumeration against an exact descriptor allowlist plus adversarial non-`CLOEXEC` supervisor control-file, socket, pidfd, and private-data canaries, followed by application confirmation; all four supervisor `Uid:` and `Gid:` fields using its dedicated child-disjoint UID and GID; a final supervisor `Groups:` vector containing no GID other than its dedicated GID; a supervisor `PR_GET_DUMPABLE` result of zero; supervisor/child POSIX session-ID inspection proving separation; and negative same-workload child-to-supervisor `kill` and `pidfd_send_signal` probes that explicitly include `SIGCONT`, plus negative `ptrace`, `/proc//mem`, `process_vm_readv`, and `process_vm_writev` probes. | Private 1B conformance passed 3 exact and 3 bounded trials; product integration pending | +| ID-04 | Two installations using the same container IDs have distinct host mappings, PID, IPC, network, and mount namespaces, private mount propagation, and private `/dev/shm` and `/dev/mqueue` mounts; they cannot observe, signal, or ptrace each other's processes, see each other's mounts, use each other's System V or named POSIX shared-memory, semaphore, or message-queue objects, connect to each other's Linux abstract Unix sockets, or read each other's private state. | 1 | Concurrent two-workload probe with host mapping, PID-namespace, IPC-namespace, network-namespace, mount-namespace, mount-propagation, `/dev/shm`, and `/dev/mqueue` inspection plus negative cross-workload mount visibility, process visibility, signaling, ptrace, System V IPC, POSIX `shm_open`/`sem_open`, POSIX `mq_open`/send/receive, abstract Unix-socket connection, and private-state access tests. | Private 1B conformance passed 3 exact and 3 bounded trials; product integration pending | +| BE-01 | Both mapping profiles run only on a positively identified Podman backend; Docker Engine rejects them before workload mutation. | 1 | Backend detection plus positive Podman and negative Docker Engine integration tests. | Private Podman-positive and Docker-negative gate passed; product integration pending | +| CAP-01 | Capability sets are empty by default; an opted-in workload control plane receives only its exact reviewed allowlist, and ordinary children receive none. | 1 and 2 | Inspect bounding, effective, permitted, inheritable, and ambient sets for the default, supervisor, and child processes; verify omitted capabilities fail. | Private default, supervisor, and child conformance passed; public capability contract pending | | LC-01 | Files created by declared identities have expected subordinate ownership; ordinary restart and recreation preserve both the stored allocation and host ownership. | 2 | Stored-allocation and host-ownership inspection before and after ordinary stop/start and recreation through Reploy. | Unproven; deferred to lifecycle work | | LC-02 | Generation updates may add, remove, or renumber application identities without rewriting persistent ownership. Exact mappings contain only the current accepted set while removed assignments remain reserved and reactivate on rollback; bounded mappings retain their fixed geometry while the supervisor admits only the current set. Mapping-profile, supervisor-identity, and bounded-geometry changes fail before mutation. | 2 | Lifecycle tests across identity addition, removal, renumbering, and rollback for both profiles; host ownership inspection proving no automatic rewrite; exact-map and supervisor-policy inspection for each generation; dormant-assignment reservation and reactivation checks; and pre-mutation rejection tests for mechanism changes. | Unproven; deferred to lifecycle work | | LC-03 | Backup and restore preserve ownership or perform an explicit checked translation. | 2 | Checksummed offline export/import with no workload attached. | Unproven; deferred to storage work | | AL-01 | Concurrent allocations cannot overlap, mappings remain reserved while live or retained managed storage uses them, and all external `bind` mounts are rejected before mutation. | 1 | Contended allocation, crash recovery, retention, removal, safe-reuse, and pre-mutation mount-rejection tests. | Unproven; allocator not designed | | RV-01 | Revocation stops and removes live workload resources, prevents service-manager or other autonomous restart, and makes further operations fail closed without mutating retained storage or releasing its mapping. | 1 | Active-workload revocation with runtime-resource absence and restart-prevention inspection, followed by recovery tests with retained owned data and its mapping still reserved. | Unproven; revocation not designed | | HB-01 | Host bootstrap is explicit, auditable, idempotent, validates that its delegated IDs are exclusive and non-overlapping, and enables later user-level operation without host root. | 1 | Repeated bootstrap, successful user operation, and failures for absent or inconsistent preparation, collisions with local or NSS-provided host principals, overlap with another principal's subordinate delegation, and an identity inventory whose completeness cannot be established. | Unproven; bootstrap not designed | -| SB-01 | No workload uses privileged mode or gains host-root authority. | 1 | Runtime inspection plus negative host-authority tests. | Unproven; repeated conformance probe pending | -| SB-02 | The approved seccomp profile is explicitly selected and identifiable in the resolved and effective runtime policy; application processes have `NoNewPrivs: 1` and seccomp filter mode (`Seccomp: 2`); syscalls the approved profile must block fail; the container root filesystem is read-only except for declared writable storage; and namespaces, devices, mounts, and network attachment match declared policy, with distinct network namespaces and no host control socket, undeclared path, or peer-reachable network. | 1 and 2 | Resolved-policy and host/runtime inspection through the supported Podman path plus negative approved-policy syscall, path, cross-workload TCP, cross-workload UDP, and cross-workload Linux abstract Unix-socket tests through supported Reploy interfaces. | Unproven; baseline checked by one-off probe; repeated conformance pending | +| SB-01 | No workload uses privileged mode or gains host-root authority. | 1 | Runtime inspection plus negative host-authority tests. | Private 1B conformance passed 3 exact and 3 bounded trials; product integration pending | +| SB-02 | The approved seccomp profile is explicitly selected and identifiable in the resolved and effective runtime policy; application processes have `NoNewPrivs: 1` and seccomp filter mode (`Seccomp: 2`); syscalls the approved profile must block fail while the same valid operation succeeds in an unfiltered control; the container root filesystem is read-only except for declared writable storage; and namespaces, devices, mounts, and network attachment match declared policy, with distinct network namespaces and no host control socket, undeclared path, or peer-reachable network. | 1 and 2 | Resolved-policy and host/runtime inspection through the supported Podman path plus paired filtered-denial and unfiltered-success syscall evidence, negative path checks, and cross-workload TCP, UDP, and Linux abstract Unix-socket tests through supported Reploy interfaces. | Private 1B sandbox conformance passed; supported Reploy integration pending | | FS-01 | Trusted filesystem preparation cannot follow symlinks, accept unexpected object types, escape the declared root, recursively rewrite application data, or execute application-provided code. | 2 | Adversarial descriptor-safe preparation tests plus executable and hook canaries proving that trusted preparation never invokes application-provided code; process tracing verifies that any later application-provided execution begins only after entering its final child credentials and capability set. | Unproven; deferred to filesystem design | | CF-01 | Disagreement among declarations, locked image, delegation, allocation, and persistent ownership fails before start without state mutation. | 1 and 2 | Deliberate mismatch matrix with before/after state evidence. | Unproven; initial cases required in milestone 1 | | SI-01 | Only declared producers and consumers can use a shared Unix socket across otherwise private mappings. | 3 | Positive participant test and negative undeclared-workload test. | Unproven; shared interface not designed | diff --git a/tools/conformance/multiidentity/Containerfile b/tools/conformance/multiidentity/Containerfile new file mode 100644 index 00000000..a6375eda --- /dev/null +++ b/tools/conformance/multiidentity/Containerfile @@ -0,0 +1,10 @@ +FROM scratch + +COPY --chmod=0755 probe /probe +COPY --chmod=0755 posix-ipc-probe /posix-ipc-probe +COPY --chown=100:100 --chmod=6755 probe /probe-setid +COPY --chmod=0755 probe /probe-filecap +RUN ["/probe", "stamp-filecap", "/probe-filecap"] + +ENTRYPOINT ["/probe"] +CMD ["supervisor"] diff --git a/tools/conformance/multiidentity/EVIDENCE.md b/tools/conformance/multiidentity/EVIDENCE.md new file mode 100644 index 00000000..e38ba699 --- /dev/null +++ b/tools/conformance/multiidentity/EVIDENCE.md @@ -0,0 +1,91 @@ +# Multi-identity Podman conformance evidence + +Status: passed on 2026-08-14 from 23:08:57 through 23:12:05 UTC. + +This is private mechanism evidence for slice 1B. It confirms the selected +Podman primitives on the recorded host; it does not claim that Reploy exposes +or supports multi-identity workloads yet. + +## Reproduction identity + +- Command: `tools/conformance/multiidentity/conformance.py --profiles exact,bounded --iterations 3` +- Reploy parent head: `7dfc218c07ca247e1914de59cf07e4e8c3891b09` +- Probe source SHA-256: `99512c98466438e2c089a83a8bfbefbf0ae79a09c6549a53ef9dd89fb78fe0cd` +- Host: Ubuntu 24.04 on Linux 6.6.114.1 WSL2, x86_64 +- Rootless Podman: 4.9.3, local socket +- OCI runtime: crun 1.14.1 +- Delegation: UID and GID range `100000:65536`; the active `files` libsubid + backend agreed with `getsubids`, and the harness checked every mapped ID + through NSS lookup plus every other enumerable subordinate delegation + present on the host. All trials recognized both the caller's login name + (`omry`) and numeric UID (`1000`) as equivalent delegation owners +- Seccomp profile: `/usr/share/containers/seccomp.json`, SHA-256 + `cc374cf23846ce1f62f4dc807a8e2b8673c783c6f56cb475467621035d281e6c` +- Supervisor allowlist: `CAP_SETGID`, `CAP_SETUID`, and `CAP_SETPCAP`; the last + capability is used by the trusted launcher to empty the child bounding set + +The source hash covers `conformance.py`, `Containerfile`, `posix_ipc.c`, and +`probe/main.go`. The static image was built offline from those sources with no +base image or registry access. + +## Repeated results + +| Profile | Iterations | Workload A mapping | Workload B mapping | Result | +| --- | ---: | --- | --- | --- | +| Exact | 3 | sparse host IDs beginning at 100000 | disjoint sparse host IDs beginning at 100020 | 3/3 passed | +| Bounded | 3 | container range 0–4095 to host range 100000–104095 | container range 0–4095 to host range 104096–108191 | 3/3 passed | + +Every iteration created two fresh workloads using the same container IDs. The +exact profile mapped only UID 0, 100, and 1001 and GID 0, 5, 100, 2001, 3001, +and 3002. GID 5 was explicitly justified as runtime-only `devpts` authority. +The bounded profile mapped exactly 4096 private UIDs and GIDs. Host mappings +were subordinate, non-root, non-overlapping, and free of known identity or +delegation aliases. Each trial also injected UID and GID aliases for an NSS +principal and another principal's delegation; all four cases failed closed for +the intended reason. + +All six trials passed the following groups of checks: + +- declared supervisor transitions, undeclared-policy rejection including + mapped runtime and supervisor GIDs as supplementary groups, exact unmapped + denial, bounded in-range success, and bounded out-of-range denial across the + complete required UID/GID syscall matrix; +- final application UID 1001, GID 2001, supplementary groups 3001 and 3002, + empty capability sets, `NoNewPrivs: 1`, and seccomp filter mode 2; +- an exact `0/1/2` descriptor allowlist after closing six deliberately + inherited non-`CLOEXEC` supervisor canaries; +- blocked post-drop identity changes, privilege regain through a mapped + supervisor-owned `100:100` set-user/group-ID executable, file-capability + privilege regain, supervisor signaling, pidfd signaling, ptrace, proc-memory, + and process-VM access; +- an opted-in supervisor capability mask of exactly `0x1c0`, a separate + default-capability run with every capability set empty, and a + profile-denied `vmsplice` round trip paired with a successful unconfined + control; +- separate PID, IPC, network, mount, and user namespaces, private mount + propagation, private `/dev/shm` and `/dev/mqueue`, and denial of cross-workload + System V IPC, named POSIX IPC, TCP, UDP, abstract Unix sockets, process + access, and private-state access, with positive `shm_open` and `sem_open` + round trips plus a POSIX message-queue send/receive control in every producer + workload; +- read-only roots, one managed writable volume, no privileged mode, host + namespace, host device, external bind, or runtime socket; and +- managed files owned by the exact expected subordinate host UID and GID. + +## Docker and cleanup + +The negative backend check positively identified Docker Desktop Engine 29.6.2, +rejected it in the private harness before sending mapping syntax or a mutation +command, and observed identical Docker inventories before and after the check. +This proves the probe's pre-mutation backend gate, not the later Reploy product +integration. + +Each trial reported no remaining prefixed container or volume. The complete +rootless Podman inventory was empty before the campaign and empty afterward, +including after final image removal. The harness exited zero only after that +inventory comparison passed. + +The full machine-readable evidence remains reproducible from the recorded +command and source hash. It is not committed because it includes verbose host +and runtime inventories; this curated record contains the review-relevant +results without turning the repository into an environment snapshot. diff --git a/tools/conformance/multiidentity/README.md b/tools/conformance/multiidentity/README.md new file mode 100644 index 00000000..8f15691b --- /dev/null +++ b/tools/conformance/multiidentity/README.md @@ -0,0 +1,90 @@ +# Multi-identity Podman conformance + +This private harness proves the candidate Linux identity boundary selected by +the multi-identity security contract. It does not add Reploy configuration, +runtime behavior, or a supported workload interface. + +The harness builds an offline `scratch` image containing a static probe, runs +two concurrent workloads through both Podman mapping profiles, records +host-side and in-container evidence, and removes every resource it creates. +Docker Engine is inspected only so the harness can demonstrate rejection at +its own backend gate before sending mapping syntax or a mutation request. The +product-level Docker rejection remains later integration work. + +## Prerequisites + +- Linux with rootless Podman and a compatible OCI runtime; +- delegated subordinate UID and GID ranges for the current user, keyed by + either the login name or its numeric UID; +- the enumerable `files` libsubid backend and the `getsubids` command; +- an explicit Podman seccomp profile reported by `podman info`; +- Go, Python 3, and a C compiler with the static POSIX C library; and +- a Docker CLI connected to the Docker Engine used for the negative backend + check. + +No registry access or image pull is required. The test must run without +`sudo`; needing host elevation or persistent host mutation invalidates this +slice's prepared execution path. + +## Run + +From the repository root: + +```shell +tools/conformance/multiidentity/conformance.py \ + --profiles exact,bounded \ + --iterations 3 \ + --output-dir /tmp/reploy-multiidentity-conformance +``` + +The output directory must be absent or empty. The default is a fresh directory +under `/tmp`. + +Each iteration creates new containers and managed volumes. It proves: + +- exact sparse and bounded private UID/GID mapping geometry; +- distinct subordinate host authority for two workloads using the same + container IDs; +- declared supervisor transitions and rejection of undeclared identities; +- raw in-range and out-of-range transition behavior; +- exact child credentials, groups, descriptor allowlist, capabilities, + `no-new-privileges`, and seccomp state; +- authoritative per-ID NSS collision checks, plus agreement between the + enumerable subordinate-ID files and the active libsubid result, recognizing + both login-name and numeric-UID ownership entries; +- injected UID and GID collision cases that must fail closed for an NSS + principal and for another principal's subordinate delegation; +- blocked privilege regain through set-ID and file-capability execution; +- a seccomp-denied `vmsplice` round trip paired with a successful unconfined + control that proves the denial is attributable to the selected profile; +- private PID, IPC, network, mount, `/dev/shm`, and `/dev/mqueue` boundaries, + including positive `shm_open` and `sem_open` round trips; +- process, state, System V IPC, named POSIX IPC, TCP, UDP, and abstract Unix + socket isolation; +- expected subordinate ownership on managed persistent files; +- absence of privileged mode, host namespaces, host devices, external binds, + and runtime sockets; and +- Docker Engine rejection by the private probe before mutation. + +Container GID 5 is mapped explicitly because the rootless OCI `devpts` mount +requires it. It is runtime-only authority and the supervisor rejects it as an +application identity. + +## Evidence and cleanup + +`host.json` records the Reploy parent head, probe source hash, host delegation, +Podman and OCI runtime versions, seccomp path and hash, runtime-required IDs, +and supervisor capability rationale. Each trial writes `evidence.json` and +`cleanup.json`; `summary.json` contains the compact result and engine inventory +comparison. `commands.txt` contains every executed command. + +Resources use a `reploy-mi--` prefix whose token includes 128 bits of +cryptographic randomness. Trial cleanup targets only those exact containers +and volumes, and final cleanup targets only the unique probe image. A +successful process exit additionally requires the complete Podman container, +image, and volume inventory to match its starting state. + +Passing the harness confirms that the selected Podman mechanisms can satisfy +the frozen boundary on the recorded host. It authorizes later design work; it +does not implement host bootstrap, durable allocation, lifecycle handling, +public identity or capability declarations, or production support. diff --git a/tools/conformance/multiidentity/conformance.py b/tools/conformance/multiidentity/conformance.py new file mode 100755 index 00000000..02c9bdb1 --- /dev/null +++ b/tools/conformance/multiidentity/conformance.py @@ -0,0 +1,1085 @@ +#!/usr/bin/env python3 +"""Run the private multi-identity Podman conformance probe. + +This harness deliberately operates below Reploy's public blueprint surface. It +creates only uniquely named disposable Podman resources, captures evidence, +and removes those exact resources in a finally block. +""" + +from __future__ import annotations + +import argparse +import dataclasses +import datetime as dt +import getpass +import grp +import hashlib +import json +import os +import pathlib +import pwd +import secrets +import shlex +import shutil +import subprocess +import sys +import tempfile +import time +from typing import Any, Callable, Iterable + + +ROOT = pathlib.Path(__file__).resolve().parents[3] +HERE = pathlib.Path(__file__).resolve().parent +PROBE_PACKAGE = "./tools/conformance/multiidentity/probe" +SUPERVISOR_UID = 100 +SUPERVISOR_GID = 100 +DECLARED_UID = 1001 +DECLARED_GID = 2001 +DECLARED_GROUPS = (3001, 3002) +RUNTIME_GID = 5 +EXPECTED_SUPERVISOR_CAPS = 0x1C0 # SETGID, SETUID, SETPCAP + + +class ConformanceError(RuntimeError): + pass + + +@dataclasses.dataclass +class Runner: + output: pathlib.Path + commands: list[str] = dataclasses.field(default_factory=list) + + def run( + self, + argv: Iterable[str | os.PathLike[str]], + *, + check: bool = True, + env: dict[str, str] | None = None, + timeout: float = 120, + ) -> subprocess.CompletedProcess[str]: + args = [os.fspath(value) for value in argv] + self.commands.append(shlex.join(args)) + result = subprocess.run( + args, + cwd=ROOT, + env={**os.environ, **(env or {})}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout, + check=False, + ) + if check and result.returncode != 0: + raise ConformanceError( + f"command failed ({result.returncode}): {shlex.join(args)}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + return result + + def json(self, argv: Iterable[str | os.PathLike[str]], **kwargs: Any) -> Any: + result = self.run(argv, **kwargs) + try: + return json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise ConformanceError( + f"invalid JSON from {self.commands[-1]}: {result.stdout!r}" + ) from exc + + def save_commands(self) -> None: + (self.output / "commands.txt").write_text( + "\n".join(self.commands) + "\n", encoding="utf-8" + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument( + "--output-dir", + type=pathlib.Path, + default=None, + help="evidence directory (default: a new directory under /tmp)", + ) + parser.add_argument("--iterations", type=int, default=3) + parser.add_argument( + "--profiles", + default="exact,bounded", + help="comma-separated subset of exact,bounded", + ) + return parser.parse_args() + + +def sha256_file(path: pathlib.Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1 << 20), b""): + digest.update(block) + return digest.hexdigest() + + +def source_hash() -> str: + digest = hashlib.sha256() + paths = [ + HERE / "conformance.py", + HERE / "Containerfile", + HERE / "posix_ipc.c", + HERE / "probe" / "main.go", + ] + for path in paths: + digest.update(path.relative_to(ROOT).as_posix().encode()) + digest.update(b"\0") + digest.update(path.read_bytes()) + digest.update(b"\0") + return digest.hexdigest() + + +def write_json(path: pathlib.Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def inventory(runner: Runner, runtime: str) -> dict[str, list[str]]: + if runtime == "podman": + commands = { + "containers": ["podman", "ps", "-aq"], + "images": ["podman", "images", "-aq"], + "volumes": ["podman", "volume", "ls", "-q"], + } + else: + commands = { + "containers": ["docker", "ps", "-aq"], + "images": ["docker", "images", "-q", "--no-trunc"], + "volumes": ["docker", "volume", "ls", "-q"], + } + return { + name: sorted(filter(None, runner.run(command).stdout.splitlines())) + for name, command in commands.items() + } + + +def docker_negative(runner: Runner) -> dict[str, Any]: + before = inventory(runner, "docker") + info = runner.json(["docker", "info", "--format", "{{json .}}"]) + version = runner.json(["docker", "version", "--format", "{{json .Server}}"]) + components = json.dumps(version.get("Components", [])) + is_podman = "Podman" in components or "podman" in str(info.get("Name", "")).lower() + if is_podman: + raise ConformanceError("default Docker endpoint unexpectedly identifies as Podman") + # The backend gate rejects here. No mapping request or mutation command is sent. + after = inventory(runner, "docker") + if before != after: + raise ConformanceError("Docker inventory changed during pre-mutation rejection") + return { + "pass": True, + "decision": "rejected_before_mutation", + "reason": "server is Docker Engine, not Podman", + "server": { + "name": info.get("Name"), + "operating_system": info.get("OperatingSystem"), + "server_version": info.get("ServerVersion"), + "components": version.get("Components"), + }, + "before": before, + "after": after, + "mapping_request_sent": False, + } + + +def current_head(runner: Runner) -> str: + return runner.run( + ["sl", "log", "-r", ".", "-T", "{node}"], env={"CHGDISABLE": "1"} + ).stdout.strip() + + +def require_enumerable_subid_backend() -> list[str]: + nsswitch = pathlib.Path("/etc/nsswitch.conf") + if not nsswitch.is_file(): + raise ConformanceError("cannot establish the subordinate-ID backend without /etc/nsswitch.conf") + for raw_line in nsswitch.read_text(encoding="utf-8").splitlines(): + line = raw_line.split("#", 1)[0].strip() + if not line: + continue + database, separator, value = line.partition(":") + if separator and database.strip() == "subid": + sources = value.split() + if sources != ["files"]: + raise ConformanceError( + "cannot enumerate every subordinate-ID delegation from " + f"the configured subid sources: {sources}" + ) + return sources + # libsubid defaults to the files backend when nsswitch.conf has no subid entry. + return ["files"] + + +def host_context(runner: Runner) -> dict[str, Any]: + podman_version = runner.json(["podman", "version", "--format", "json"]) + podman_info = runner.json(["podman", "info", "--format", "json"]) + security = podman_info.get("host", {}).get("security", {}) + if not security.get("rootless"): + raise ConformanceError("the selected Podman engine is not rootless") + if not security.get("seccompEnabled"): + raise ConformanceError("the selected Podman engine has no seccomp support") + seccomp_path = pathlib.Path(str(security.get("seccompProfilePath", ""))) + if not seccomp_path.is_file(): + raise ConformanceError(f"Podman seccomp profile is missing: {seccomp_path}") + subid_sources = require_enumerable_subid_backend() + username = getpass.getuser() + subuid = pathlib.Path("/etc/subuid").read_text(encoding="utf-8") + subgid = pathlib.Path("/etc/subgid").read_text(encoding="utf-8") + uname = os.uname() + return { + "reploy_head_before_evidence": current_head(runner), + "probe_source_sha256": source_hash(), + "host": { + "uname": dict( + zip( + ("sysname", "nodename", "release", "version", "machine"), + uname, + strict=True, + ) + ), + "uid": os.getuid(), + "gid": os.getgid(), + "username": username, + "subuid": subuid.splitlines(), + "subgid": subgid.splitlines(), + "subid_sources": subid_sources, + }, + "podman_version": podman_version, + "podman_host": podman_info.get("host"), + "podman_store": podman_info.get("store"), + "seccomp_profile": { + "path": str(seccomp_path), + "sha256": sha256_file(seccomp_path), + }, + "runtime_required_identities": [ + { + "kind": "gid", + "container_id": RUNTIME_GID, + "reason": "the rootless OCI devpts mount requires container GID 5", + } + ], + "supervisor_allowlist": ["CAP_SETGID", "CAP_SETUID", "CAP_SETPCAP"], + "supervisor_setpcap_reason": ( + "the trusted launcher needs CAP_SETPCAP to empty each child bounding set; " + "the container bounding set contains no other capability" + ), + } + + +def build_image(runner: Runner, output: pathlib.Path, tag: str) -> dict[str, Any]: + staging = output / "build-context" + staging.mkdir() + probe = staging / "probe" + posix_ipc_probe = staging / "posix-ipc-probe" + env = {"GOCACHE": str(output / "go-cache"), "CGO_ENABLED": "0"} + runner.run( + [ + "go", "build", "-trimpath", "-buildvcs=false", + "-o", probe, PROBE_PACKAGE, + ], + env=env, + ) + runner.run( + [ + "cc", "-O2", "-static", "-pthread", + "-o", posix_ipc_probe, HERE / "posix_ipc.c", + ] + ) + shutil.copy2(HERE / "Containerfile", staging / "Containerfile") + build = runner.run( + [ + "podman", "build", "--network=none", "--pull=never", + "--file", staging / "Containerfile", "--tag", tag, staging, + ], + timeout=300, + ) + inspect = runner.json(["podman", "image", "inspect", tag]) + write_json(output / "image-inspect.json", inspect) + (output / "image-build.stdout").write_text(build.stdout, encoding="utf-8") + (output / "image-build.stderr").write_text(build.stderr, encoding="utf-8") + return {"tag": tag, "probe_sha256": sha256_file(probe), "inspect": inspect} + + +def exact_map_args(slot: int) -> list[str]: + base = 1 + slot * 20 + args: list[str] = [] + for container_id, intermediate_id, count in ( + (0, base, 1), + (SUPERVISOR_UID, base + 1, 1), + (DECLARED_UID, base + 2, 1), + ): + args.extend(["--uidmap", f"{container_id}:{intermediate_id}:{count}"]) + for container_id, intermediate_id, count in ( + (0, base, 1), + (RUNTIME_GID, base + 1, 1), + (SUPERVISOR_GID, base + 2, 1), + (DECLARED_GID, base + 3, 1), + (DECLARED_GROUPS[0], base + 4, 2), + ): + args.extend(["--gidmap", f"{container_id}:{intermediate_id}:{count}"]) + return args + + +def mapping_args(profile: str, slot: int) -> list[str]: + if profile == "exact": + return exact_map_args(slot) + return ["--userns", "auto:size=4096"] + + +def common_run_args( + name: str, + seccomp_path: str, + profile: str, + slot: int, + *, + volume: str | None = None, + capabilities: bool = True, + seccomp_override: str | None = None, +) -> list[str]: + args = [ + "podman", "run", "--name", name, + *mapping_args(profile, slot), + "--network", "none", "--ipc", "private", "--pid", "private", + "--uts", "private", "--read-only", + "--tmpfs", "/tmp:rw,noexec,nosuid,nodev,size=16m", + "--tmpfs", "/run:rw,noexec,nosuid,nodev,size=4m,mode=1777", + "--pids-limit", "128", "--memory", "128m", "--cpus", "0.5", + "--cap-drop", "ALL", + "--security-opt", "no-new-privileges", + "--security-opt", f"seccomp={seccomp_override or seccomp_path}", + "--user", "0:0", + ] + if capabilities: + for cap in ("SETGID", "SETUID", "SETPCAP"): + args.extend(["--cap-add", cap]) + if volume: + args.extend(["--mount", f"type=volume,src={volume},dst=/probe-state"]) + return args + + +def inspect_container(runner: Runner, name: str) -> dict[str, Any]: + value = runner.json(["podman", "inspect", name]) + if not isinstance(value, list) or len(value) != 1: + raise ConformanceError(f"unexpected inspect response for {name}") + return value[0] + + +def request_action(runner: Runner, container: str, action: str, **values: Any) -> dict[str, Any]: + args = [ + "podman", "exec", "--user", f"{SUPERVISOR_UID}:{SUPERVISOR_GID}", + container, "/probe", "request", action, + ] + args.extend(f"{key}={value}" for key, value in values.items()) + outer = runner.json(args) + if not outer.get("pass") or outer.get("error"): + raise ConformanceError(f"supervisor request {action} failed: {outer}") + data = outer.get("data") + if not isinstance(data, dict): + raise ConformanceError(f"supervisor request {action} returned no object: {outer}") + return {"response": outer, "data": data} + + +def wait_ready(runner: Runner, container: str) -> dict[str, Any]: + last = "" + for _ in range(100): + result = runner.run( + [ + "podman", "exec", "--user", f"{SUPERVISOR_UID}:{SUPERVISOR_GID}", + container, "/probe", "request", "ready", + ], + check=False, + timeout=5, + ) + last = result.stderr or result.stdout + if result.returncode == 0: + value = json.loads(result.stdout) + if value.get("pass"): + return value + time.sleep(0.05) + logs = runner.run(["podman", "logs", container], check=False, timeout=5) + inspect = runner.run(["podman", "inspect", container], check=False, timeout=5) + raise ConformanceError( + f"supervisor {container} did not become ready: {last}\n" + f"logs:\n{logs.stdout}{logs.stderr}\ninspect:\n{inspect.stdout}{inspect.stderr}" + ) + + +def read_proc(pid: int, name: str) -> str: + return pathlib.Path(f"/proc/{pid}/{name}").read_text(encoding="utf-8") + + +def parse_id_map(text: str) -> list[tuple[int, int, int]]: + return [tuple(map(int, line.split())) for line in text.splitlines() if line.strip()] + + +def mapped_host_ids(mapping: list[tuple[int, int, int]]) -> set[int]: + result: set[int] = set() + for _, host_start, count in mapping: + result.update(range(host_start, host_start + count)) + return result + + +def mapped_container_ids(mapping: list[tuple[int, int, int]]) -> set[int]: + result: set[int] = set() + for container_start, _, count in mapping: + result.update(range(container_start, container_start + count)) + return result + + +def assert_mapping_profile( + profile: str, + uid_maps: list[list[tuple[int, int, int]]], + gid_maps: list[list[tuple[int, int, int]]], +) -> dict[str, Any]: + expected_uids = ( + {0, SUPERVISOR_UID, DECLARED_UID} + if profile == "exact" + else set(range(4096)) + ) + expected_gids = ( + {0, RUNTIME_GID, SUPERVISOR_GID, DECLARED_GID, *DECLARED_GROUPS} + if profile == "exact" + else set(range(4096)) + ) + actual_uids = [mapped_container_ids(mapping) for mapping in uid_maps] + actual_gids = [mapped_container_ids(mapping) for mapping in gid_maps] + for index, values in enumerate(actual_uids): + if values != expected_uids: + raise ConformanceError( + f"workload {index} has unexpected {profile} UID geometry" + ) + for index, values in enumerate(actual_gids): + if values != expected_gids: + raise ConformanceError( + f"workload {index} has unexpected {profile} GID geometry" + ) + return { + "pass": True, + "profile": profile, + "container_uids": sorted(expected_uids), + "container_gids": sorted(expected_gids), + } + + +def parse_subids(path: pathlib.Path) -> list[tuple[str, int, int]]: + values = [] + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.split("#", 1)[0].strip() + if not line.strip(): + continue + name, start, count = line.split(":", 2) + values.append((name, int(start), int(count))) + return values + + +def owned_subid_ranges( + entries: list[tuple[str, int, int]], + caller_names: set[str], +) -> list[tuple[int, int]]: + return [ + (start, count) + for name, start, count in entries + if name in caller_names + ] + + +def query_subids( + runner: Runner, + username: str, + caller_names: set[str], + *, + gids: bool, +) -> list[tuple[int, int]]: + executable = shutil.which("getsubids") + if executable is None: + raise ConformanceError("getsubids is required to verify the active libsubid delegation") + command = [executable] + if gids: + command.append("-g") + command.append(username) + ranges: list[tuple[int, int]] = [] + for line in runner.run(command).stdout.splitlines(): + fields = line.split() + if ( + len(fields) != 4 + or not fields[0].endswith(":") + or fields[1] not in caller_names + ): + raise ConformanceError(f"unexpected getsubids output: {line!r}") + ranges.append((int(fields[2]), int(fields[3]))) + if not ranges: + kind = "GID" if gids else "UID" + raise ConformanceError(f"getsubids reported no subordinate {kind} ranges for {username}") + return ranges + + +def resolved_principals(values: set[int], *, gids: bool) -> dict[int, str]: + collisions: dict[int, str] = {} + lookup = grp.getgrgid if gids else pwd.getpwuid + for value in values: + try: + entry = lookup(value) + except KeyError: + continue + collisions[value] = entry.gr_name if gids else entry.pw_name + return collisions + + +def validate_host_authority( + uid_maps: list[list[tuple[int, int, int]]], + gid_maps: list[list[tuple[int, int, int]]], + *, + caller_names: set[str], + uid_ranges: list[tuple[str, int, int]], + gid_ranges: list[tuple[str, int, int]], + own_uids: list[tuple[int, int]], + own_gids: list[tuple[int, int]], + resolve_uids: Callable[[set[int]], dict[int, str]], + resolve_gids: Callable[[set[int]], dict[int, str]], +) -> dict[str, Any]: + def contained(value: int, ranges: list[tuple[int, int]]) -> bool: + return any(start <= value < start + count for start, count in ranges) + + uid_sets = [mapped_host_ids(mapping) for mapping in uid_maps] + gid_sets = [mapped_host_ids(mapping) for mapping in gid_maps] + for values in uid_sets: + if not all(contained(value, own_uids) for value in values): + raise ConformanceError(f"UID mapping escaped caller subordinate ranges: {values}") + collisions = resolve_uids(values) + if collisions: + raise ConformanceError(f"UID mapping aliases NSS principals: {collisions}") + for values in gid_sets: + if not all(contained(value, own_gids) for value in values): + raise ConformanceError(f"GID mapping escaped caller subordinate ranges: {values}") + collisions = resolve_gids(values) + if collisions: + raise ConformanceError(f"GID mapping aliases NSS principals: {collisions}") + if uid_sets[0] & uid_sets[1] or gid_sets[0] & gid_sets[1]: + raise ConformanceError("workload mappings overlap") + for name, start, count in uid_ranges: + if name not in caller_names and any(start <= value < start + count for value in uid_sets[0] | uid_sets[1]): + raise ConformanceError(f"UID mapping overlaps delegation for {name}") + for name, start, count in gid_ranges: + if name not in caller_names and any(start <= value < start + count for value in gid_sets[0] | gid_sets[1]): + raise ConformanceError(f"GID mapping overlaps delegation for {name}") + return { + "pass": True, + "uid_host_ids": [sorted(values) for values in uid_sets], + "gid_host_ids": [sorted(values) for values in gid_sets], + } + + +def require_authority_rejection( + name: str, + expected: str, + check: Callable[[], dict[str, Any]], +) -> dict[str, Any]: + try: + check() + except ConformanceError as exc: + message = str(exc) + if expected not in message: + raise ConformanceError( + f"authority negative case {name} failed for the wrong reason: {message}" + ) from exc + return {"pass": True, "expected": expected, "actual": message} + raise ConformanceError(f"authority negative case {name} did not fail closed") + + +def assert_host_authority( + runner: Runner, + uid_maps: list[list[tuple[int, int, int]]], + gid_maps: list[list[tuple[int, int, int]]], +) -> dict[str, Any]: + require_enumerable_subid_backend() + username = getpass.getuser() + caller_names = {username, str(os.getuid())} + uid_ranges = parse_subids(pathlib.Path("/etc/subuid")) + gid_ranges = parse_subids(pathlib.Path("/etc/subgid")) + own_uids = owned_subid_ranges(uid_ranges, caller_names) + own_gids = owned_subid_ranges(gid_ranges, caller_names) + if not own_uids or not own_gids: + raise ConformanceError( + f"no subordinate ranges for caller aliases {sorted(caller_names)}" + ) + authoritative_uids = query_subids( + runner, username, caller_names, gids=False + ) + authoritative_gids = query_subids( + runner, username, caller_names, gids=True + ) + if sorted(own_uids) != sorted(authoritative_uids): + raise ConformanceError( + f"/etc/subuid disagrees with libsubid for {username}: " + f"{own_uids} != {authoritative_uids}" + ) + if sorted(own_gids) != sorted(authoritative_gids): + raise ConformanceError( + f"/etc/subgid disagrees with libsubid for {username}: " + f"{own_gids} != {authoritative_gids}" + ) + + def resolve_uids(values: set[int]) -> dict[int, str]: + return resolved_principals(values, gids=False) + + def resolve_gids(values: set[int]) -> dict[int, str]: + return resolved_principals(values, gids=True) + + authority_args = { + "caller_names": caller_names, + "uid_ranges": uid_ranges, + "gid_ranges": gid_ranges, + "own_uids": own_uids, + "own_gids": own_gids, + } + positive = validate_host_authority( + uid_maps, + gid_maps, + **authority_args, + resolve_uids=resolve_uids, + resolve_gids=resolve_gids, + ) + + collision_uid = min(mapped_host_ids(uid_maps[0])) + collision_gid = min(mapped_host_ids(gid_maps[0])) + negative_cases = { + "uid_nss_collision": require_authority_rejection( + "uid_nss_collision", + "UID mapping aliases NSS principals", + lambda: validate_host_authority( + uid_maps, + gid_maps, + **authority_args, + resolve_uids=lambda values: ( + {collision_uid: "synthetic-nss-principal"} + if collision_uid in values + else {} + ), + resolve_gids=resolve_gids, + ), + ), + "gid_nss_collision": require_authority_rejection( + "gid_nss_collision", + "GID mapping aliases NSS principals", + lambda: validate_host_authority( + uid_maps, + gid_maps, + **authority_args, + resolve_uids=resolve_uids, + resolve_gids=lambda values: ( + {collision_gid: "synthetic-nss-principal"} + if collision_gid in values + else {} + ), + ), + ), + "uid_delegation_collision": require_authority_rejection( + "uid_delegation_collision", + "UID mapping overlaps delegation for synthetic-other-principal", + lambda: validate_host_authority( + uid_maps, + gid_maps, + **{ + **authority_args, + "uid_ranges": [ + *uid_ranges, + ("synthetic-other-principal", collision_uid, 1), + ], + }, + resolve_uids=resolve_uids, + resolve_gids=resolve_gids, + ), + ), + "gid_delegation_collision": require_authority_rejection( + "gid_delegation_collision", + "GID mapping overlaps delegation for synthetic-other-principal", + lambda: validate_host_authority( + uid_maps, + gid_maps, + **{ + **authority_args, + "gid_ranges": [ + *gid_ranges, + ("synthetic-other-principal", collision_gid, 1), + ], + }, + resolve_uids=resolve_uids, + resolve_gids=resolve_gids, + ), + ), + } + numeric_owner_probe = owned_subid_ranges( + [(str(os.getuid()), own_uids[0][0], own_uids[0][1])], + caller_names, + ) + if numeric_owner_probe != [own_uids[0]]: + raise ConformanceError("numeric subordinate-ID owner alias is not recognized") + return { + **positive, + "caller_owner_aliases": sorted(caller_names), + "numeric_owner_alias_supported": True, + "negative_collision_cases": negative_cases, + } + + +def namespace_snapshot(pid: int) -> dict[str, str]: + return { + name: os.readlink(f"/proc/{pid}/ns/{name}") + for name in ("pid", "ipc", "net", "mnt", "user") + } + + +def mount_snapshot(pid: int) -> dict[str, Any]: + text = read_proc(pid, "mountinfo") + selected: dict[str, str] = {} + for line in text.splitlines(): + fields = line.split() + if len(fields) > 5 and fields[4] in {"/", "/dev/shm", "/dev/mqueue", "/probe-state"}: + selected[fields[4]] = line + root = selected.get("/", "") + private = " shared:" not in f" {root}" and " master:" not in f" {root}" + if not private: + raise ConformanceError(f"root mount propagation is not private: {root}") + for required in ("/", "/dev/shm", "/dev/mqueue", "/probe-state"): + if required not in selected: + raise ConformanceError(f"required mount missing: {required}") + return {"private_root_propagation": private, "selected": selected} + + +def assert_sandbox(inspect: dict[str, Any], seccomp_path: str) -> dict[str, Any]: + host = inspect.get("HostConfig", {}) + mounts = inspect.get("Mounts", []) + failures = [] + checks = { + "not_privileged": not host.get("Privileged"), + "network_none": host.get("NetworkMode") == "none", + "pid_private": host.get("PidMode") == "private", + "ipc_private": host.get("IpcMode") == "private", + "uts_private": host.get("UTSMode") == "private", + "readonly_root": bool(host.get("ReadonlyRootfs")), + "no_devices": not host.get("Devices"), + "one_managed_volume": ( + len(mounts) == 1 + and mounts[0].get("Type") == "volume" + and mounts[0].get("Destination") == "/probe-state" + and mounts[0].get("Propagation") == "rprivate" + ), + "no_external_binds": all(mount.get("Type") == "volume" for mount in mounts), + "no_runtime_socket": all("sock" not in str(mount.get("Destination", "")) for mount in mounts), + "no_new_privileges": any("no-new-privileges" in item for item in host.get("SecurityOpt", [])), + "seccomp_explicit": any(seccomp_path in item for item in host.get("SecurityOpt", [])), + } + for name, passed in checks.items(): + if not passed: + failures.append(name) + if failures: + raise ConformanceError(f"sandbox inspection failed: {failures}") + return {"pass": True, "checks": checks} + + +def check_supervisor(data: dict[str, Any]) -> None: + status = data["status"] + expected_caps = f"{EXPECTED_SUPERVISOR_CAPS:016x}" + checks = { + "uids": status.get("Uid") == "100\t100\t100\t100", + "gids": status.get("Gid") == "100\t100\t100\t100", + "groups": status.get("Groups") == "100", + "dumpable": data.get("dumpable") == 0, + "effective": status.get("CapEff") == expected_caps, + "permitted": status.get("CapPrm") == expected_caps, + "inheritable": status.get("CapInh") == expected_caps, + "bounding": status.get("CapBnd") == expected_caps, + "ambient_empty": status.get("CapAmb") == "0000000000000000", + } + failed = [name for name, passed in checks.items() if not passed] + if failed: + raise ConformanceError(f"supervisor report failed {failed}: {data}") + + +def require_pass(name: str, value: dict[str, Any]) -> None: + if not value.get("pass"): + raise ConformanceError(f"{name} failed: {json.dumps(value, sort_keys=True)}") + + +def run_trial( + runner: Runner, + output: pathlib.Path, + image: str, + seccomp_path: str, + profile: str, + iteration: int, + token: str, +) -> dict[str, Any]: + trial = output / profile / f"iteration-{iteration}" + trial.mkdir(parents=True) + names = [f"reploy-mi-{token}-{profile}-{iteration}-{side}" for side in ("a", "b")] + volumes = [f"reploy-mi-{token}-{profile}-{iteration}-state-{side}" for side in ("a", "b")] + created_containers: list[str] = [] + created_volumes: list[str] = [] + cleanup: dict[str, Any] = {"containers": {}, "volumes": {}} + try: + mountpoints = [] + for volume in volumes: + runner.run(["podman", "volume", "create", volume]) + created_volumes.append(volume) + mountpoint = runner.run( + ["podman", "volume", "inspect", "--format", "{{.Mountpoint}}", volume] + ).stdout.strip() + pathlib.Path(mountpoint).chmod(0o777) + mountpoints.append(mountpoint) + for slot, (name, volume) in enumerate(zip(names, volumes)): + args = common_run_args(name, seccomp_path, profile, slot, volume=volume) + runner.run([*args, "--detach", image]) + created_containers.append(name) + wait_ready(runner, name) + + inspections = [inspect_container(runner, name) for name in names] + pids = [int(value["State"]["Pid"]) for value in inspections] + uid_maps = [parse_id_map(read_proc(pid, "uid_map")) for pid in pids] + gid_maps = [parse_id_map(read_proc(pid, "gid_map")) for pid in pids] + mapping_profile = assert_mapping_profile(profile, uid_maps, gid_maps) + namespaces = [namespace_snapshot(pid) for pid in pids] + mounts = [mount_snapshot(pid) for pid in pids] + for namespace in ("pid", "ipc", "net", "mnt", "user"): + if namespaces[0][namespace] == namespaces[1][namespace]: + raise ConformanceError(f"workloads share {namespace} namespace") + + supervisor = request_action(runner, names[0], "supervisor-report")["data"] + check_supervisor(supervisor) + app = request_action(runner, names[0], "app-report")["data"] + require_pass("application report", app) + policy = {} + for action, value in ( + ("reject-uid", 1999), + ("reject-uid", SUPERVISOR_UID), + ("reject-gid", 2999), + ("reject-gid", RUNTIME_GID), + ("reject-gid", SUPERVISOR_GID), + ("reject-group", 3999), + ("reject-group", RUNTIME_GID), + ("reject-group", SUPERVISOR_GID), + ): + key = f"{action}-{value}" + response = request_action(runner, names[0], action, value=value) + if not response["response"].get("rejected"): + raise ConformanceError(f"supervisor did not reject {action}={value}") + policy[key] = response + + private = [] + for name in names: + private.append(request_action(runner, name, "write-private")) + setid = request_action(runner, names[0], "setid-target")["data"] + filecap = request_action(runner, names[0], "filecap-target")["data"] + require_pass("set-ID privilege regain", setid) + require_pass("file-capability privilege regain", filecap) + + service = request_action(runner, names[0], "start-services", token=token)["data"] + cross = request_action( + runner, names[1], "cross-probe", token=token, target_pid=pids[0] + )["data"] + require_pass("cross-workload isolation", cross) + raw = runner.json(["podman", "exec", names[0], "/probe", "raw-matrix", profile]) + require_pass("raw identity matrix", raw) + + no_cap_name = f"reploy-mi-{token}-{profile}-{iteration}-nocap" + no_cap_args = common_run_args( + no_cap_name, seccomp_path, profile, 2, capabilities=False + ) + created_containers.append(no_cap_name) + no_cap = runner.json([*no_cap_args, "--rm", image, "cap-report"]) + require_pass("no-capability default", no_cap) + + owners = [ + { + "uid": (pathlib.Path(mountpoint) / "app-private").stat().st_uid, + "gid": (pathlib.Path(mountpoint) / "app-private").stat().st_gid, + } + for mountpoint in mountpoints + ] + seccomp_control_name = f"reploy-mi-{token}-{profile}-{iteration}-seccomp-control" + seccomp_control_args = common_run_args( + seccomp_control_name, + seccomp_path, + profile, + 3, + capabilities=False, + seccomp_override="unconfined", + ) + created_containers.append(seccomp_control_name) + seccomp_control = runner.json( + [*seccomp_control_args, "--rm", image, "seccomp-control"] + ) + require_pass("unfiltered seccomp control", seccomp_control) + + authority = assert_host_authority(runner, uid_maps, gid_maps) + expected_owners = [] + for mapping_uid, mapping_gid in zip(uid_maps, gid_maps): + uid_segment = next(item for item in mapping_uid if item[0] <= DECLARED_UID < item[0] + item[2]) + gid_segment = next(item for item in mapping_gid if item[0] <= DECLARED_GID < item[0] + item[2]) + expected_owners.append( + { + "uid": uid_segment[1] + DECLARED_UID - uid_segment[0], + "gid": gid_segment[1] + DECLARED_GID - gid_segment[0], + } + ) + if owners != expected_owners: + raise ConformanceError(f"unexpected persistent ownership: {owners} != {expected_owners}") + + sandbox = [assert_sandbox(value, seccomp_path) for value in inspections] + evidence = { + "pass": True, + "profile": profile, + "iteration": iteration, + "containers": names, + "volumes": volumes, + "pids": pids, + "uid_maps": uid_maps, + "gid_maps": gid_maps, + "mapping_profile": mapping_profile, + "authority": authority, + "namespaces": namespaces, + "mounts": mounts, + "supervisor": supervisor, + "application": app, + "policy_rejections": policy, + "private_writes": private, + "setid": setid, + "filecap": filecap, + "service": service, + "cross_workload": cross, + "raw_matrix": raw, + "no_capability_default": no_cap, + "seccomp_unfiltered_control": seccomp_control, + "owners": owners, + "expected_owners": expected_owners, + "sandbox": sandbox, + "inspect": inspections, + } + write_json(trial / "evidence.json", evidence) + return evidence + finally: + for name in reversed(created_containers): + result = runner.run(["podman", "rm", "-f", name], check=False, timeout=30) + cleanup["containers"][name] = { + "returncode": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + } + for volume in reversed(created_volumes): + result = runner.run(["podman", "volume", "rm", "-f", volume], check=False) + cleanup["volumes"][volume] = { + "returncode": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + } + cleanup["remaining_containers"] = runner.run( + ["podman", "ps", "-aq", "--filter", f"name=reploy-mi-{token}-"] + ).stdout.splitlines() + cleanup["remaining_volumes"] = runner.run( + ["podman", "volume", "ls", "-q", "--filter", f"name=reploy-mi-{token}-"] + ).stdout.splitlines() + cleanup["pass"] = not cleanup["remaining_containers"] and not cleanup["remaining_volumes"] + write_json(trial / "cleanup.json", cleanup) + if not cleanup["pass"] and sys.exc_info()[0] is None: + raise ConformanceError(f"trial cleanup incomplete: {cleanup}") + + +def main() -> int: + args = parse_args() + profiles = [item.strip() for item in args.profiles.split(",") if item.strip()] + if args.iterations < 1: + raise ConformanceError("--iterations must be positive") + if not profiles or any(profile not in {"exact", "bounded"} for profile in profiles): + raise ConformanceError("--profiles must contain exact and/or bounded") + timestamp = dt.datetime.now(dt.UTC).strftime("%Y%m%dT%H%M%SZ") + output = args.output_dir or pathlib.Path(tempfile.mkdtemp(prefix="reploy-mi-conformance-")) + output = output.resolve() + if output.exists(): + if any(output.iterdir()): + raise ConformanceError(f"evidence directory is not empty: {output}") + else: + output.mkdir(parents=True) + runner = Runner(output) + token = ( + f"{timestamp.lower().replace('t', '').replace('z', '')}-{os.getpid()}-" + f"{secrets.token_hex(16)}" + ) + image = f"localhost/reploy-local/multiidentity-conformance:{token}" + baseline: dict[str, list[str]] | None = None + summary: dict[str, Any] = { + "schema": "reploy-private-multiidentity-conformance-v1", + "started_at": timestamp, + "iterations": args.iterations, + "profiles": profiles, + "pass": False, + } + try: + baseline = inventory(runner, "podman") + host = host_context(runner) + write_json(output / "host.json", host) + negative = docker_negative(runner) + write_json(output / "docker-negative.json", negative) + image_evidence = build_image(runner, output, image) + write_json(output / "image.json", image_evidence) + trials = [] + seccomp_path = host["seccomp_profile"]["path"] + for profile in profiles: + for iteration in range(1, args.iterations + 1): + trials.append( + run_trial( + runner, output, image, seccomp_path, + profile, iteration, token, + ) + ) + summary.update( + { + "pass": True, + "host": host, + "docker_negative": negative, + "image": image_evidence, + "trials": [ + { + "profile": trial["profile"], + "iteration": trial["iteration"], + "pass": trial["pass"], + "uid_maps": trial["uid_maps"], + "gid_maps": trial["gid_maps"], + "owners": trial["owners"], + } + for trial in trials + ], + } + ) + except Exception as exc: + summary["error"] = f"{type(exc).__name__}: {exc}" + finally: + remove = runner.run(["podman", "image", "rm", "-f", image], check=False, timeout=60) + summary["image_cleanup"] = { + "returncode": remove.returncode, + "stdout": remove.stdout, + "stderr": remove.stderr, + } + if baseline is not None: + final = inventory(runner, "podman") + summary["podman_inventory_before"] = baseline + summary["podman_inventory_after"] = final + summary["engine_restored"] = baseline == final + if summary.get("pass") and baseline != final: + summary["pass"] = False + summary["error"] = "Podman inventory was not restored" + summary["completed_at"] = dt.datetime.now(dt.UTC).strftime("%Y%m%dT%H%M%SZ") + write_json(output / "summary.json", summary) + runner.save_commands() + print(output) + if not summary.get("pass"): + raise ConformanceError(str(summary.get("error", "conformance failed"))) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except ConformanceError as exc: + print(f"conformance failed: {exc}", file=sys.stderr) + raise SystemExit(1) diff --git a/tools/conformance/multiidentity/posix_ipc.c b/tools/conformance/multiidentity/posix_ipc.c new file mode 100644 index 00000000..656e2bfc --- /dev/null +++ b/tools/conformance/multiidentity/posix_ipc.c @@ -0,0 +1,104 @@ +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static void object_name(char *buffer, size_t size, const char *token) { + if (snprintf(buffer, size, "/reploy-%s", token) >= (int)size) { + fprintf(stderr, "POSIX IPC object name is too long\n"); + exit(2); + } +} + +static int serve(const char *token) { + char name[256]; + object_name(name, sizeof(name), token); + + int fd = shm_open(name, O_CREAT | O_EXCL | O_RDWR, 0600); + if (fd < 0) { + perror("shm_open create"); + return 1; + } + if (ftruncate(fd, 4096) != 0) { + perror("ftruncate POSIX shared memory"); + return 1; + } + char *memory = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (memory == MAP_FAILED) { + perror("mmap POSIX shared memory"); + return 1; + } + const char *payload = "posix-shm-round-trip"; + memcpy(memory, payload, strlen(payload) + 1); + int shm_pass = strcmp(memory, payload) == 0; + + sem_t *semaphore = sem_open(name, O_CREAT | O_EXCL, 0600, 1); + if (semaphore == SEM_FAILED) { + perror("sem_open create"); + return 1; + } + int sem_pass = sem_wait(semaphore) == 0 && sem_post(semaphore) == 0; + int pass = shm_pass && sem_pass; + printf("{\"pass\":%s,\"shm_open_round_trip\":%s,\"sem_open_round_trip\":%s}\n", + pass ? "true" : "false", + shm_pass ? "true" : "false", + sem_pass ? "true" : "false"); + fflush(stdout); + if (!pass) { + return 1; + } + for (;;) { + pause(); + } +} + +static int probe(const char *token) { + char name[256]; + object_name(name, sizeof(name), token); + + errno = 0; + int fd = shm_open(name, O_RDONLY, 0); + int shm_errno = errno; + if (fd >= 0) { + close(fd); + } + int shm_denied = fd < 0 && shm_errno == ENOENT; + + errno = 0; + sem_t *semaphore = sem_open(name, 0); + int sem_errno = errno; + if (semaphore != SEM_FAILED) { + sem_close(semaphore); + } + int sem_denied = semaphore == SEM_FAILED && sem_errno == ENOENT; + int pass = shm_denied && sem_denied; + printf("{\"pass\":%s,\"shm_open_denied\":%s,\"shm_errno\":%d," + "\"sem_open_denied\":%s,\"sem_errno\":%d}\n", + pass ? "true" : "false", + shm_denied ? "true" : "false", shm_errno, + sem_denied ? "true" : "false", sem_errno); + return pass ? 0 : 1; +} + +int main(int argc, char **argv) { + if (argc != 3) { + fprintf(stderr, "usage: posix-ipc-probe serve|probe TOKEN\n"); + return 2; + } + if (strcmp(argv[1], "serve") == 0) { + return serve(argv[2]); + } + if (strcmp(argv[1], "probe") == 0) { + return probe(argv[2]); + } + fprintf(stderr, "unknown mode: %s\n", argv[1]); + return 2; +} diff --git a/tools/conformance/multiidentity/probe/main.go b/tools/conformance/multiidentity/probe/main.go new file mode 100644 index 00000000..0fc4864e --- /dev/null +++ b/tools/conformance/multiidentity/probe/main.go @@ -0,0 +1,1250 @@ +//go:build linux + +// Command probe is a disposable Linux identity-boundary test fixture. It is +// intentionally not linked into Reploy and exposes no product API. +package main + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "syscall" + "time" + "unsafe" + + "golang.org/x/sys/unix" +) + +const ( + supervisorUID = 100 + supervisorGID = 100 + declaredUID = 1001 + declaredGID = 2001 + declaredGroup1 = 3001 + declaredGroup2 = 3002 + undeclaredUID = 1999 + undeclaredGID = 2999 + undeclaredGroup = 3999 + runtimeGID = 5 + outsideID = 70000 + + socketPath = "/run/reploy-multiidentity-probe.sock" + servicePort = 33441 + + canaryFDsEnv = "REPLOY_PROBE_CANARY_FDS" + closeRangeVerifiedEnv = "REPLOY_PROBE_CLOSE_RANGE_VERIFIED" +) + +var supervisorMemory byte = 0x5a + +type result struct { + Test string `json:"test"` + Pass bool `json:"pass"` + Expected string `json:"expected,omitempty"` + Actual string `json:"actual,omitempty"` + Error string `json:"error,omitempty"` + Details map[string]any `json:"details,omitempty"` +} + +type request struct { + Action string `json:"action"` + Value int `json:"value,omitempty"` + Token string `json:"token,omitempty"` + TargetPID int `json:"target_pid,omitempty"` +} + +type response struct { + Pass bool `json:"pass"` + Rejected bool `json:"rejected,omitempty"` + Error string `json:"error,omitempty"` + Data json.RawMessage `json:"data,omitempty"` +} + +func main() { + if len(os.Args) == 1 || os.Args[1] == "supervisor" { + must(runSupervisor()) + return + } + var err error + switch os.Args[1] { + case "request": + err = runRequest(os.Args[2:]) + case "child-launch": + err = runChildLaunch(os.Args[2:]) + case "app-report": + err = runAppReport(os.Args[2:]) + case "privilege-target": + err = runPrivilegeTarget() + case "raw-matrix": + err = runRawMatrix(os.Args[2:]) + case "raw-attempt": + err = runRawAttempt(os.Args[2:]) + case "cap-report": + err = emitCapabilityReport("no_capability_default") + case "seccomp-control": + err = runSeccompControl() + case "service-server": + err = runServiceServer(os.Args[2:]) + case "cross-probe": + err = runCrossProbe(os.Args[2:]) + case "write-private": + err = writePrivate(os.Args[2:]) + case "stamp-filecap": + err = stampFileCapability(os.Args[2:]) + default: + err = fmt.Errorf("unknown mode %q", os.Args[1]) + } + must(err) +} + +func must(err error) { + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func emit(v any) error { + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + return enc.Encode(v) +} + +func statusFields(pid int) (map[string]string, error) { + b, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid)) + if err != nil { + return nil, err + } + wanted := map[string]bool{ + "Uid": true, "Gid": true, "Groups": true, + "CapInh": true, "CapPrm": true, "CapEff": true, + "CapBnd": true, "CapAmb": true, "NoNewPrivs": true, + "Seccomp": true, + } + out := map[string]string{} + for _, line := range strings.Split(string(b), "\n") { + key, value, ok := strings.Cut(line, ":") + if ok && wanted[key] { + out[key] = strings.TrimSpace(value) + } + } + return out, nil +} + +func capMask(caps ...int) uint32 { + var mask uint32 + for _, cap := range caps { + if cap < 32 { + mask |= 1 << uint(cap) + } + } + return mask +} + +func setSupervisorCapabilities() error { + data := [2]unix.CapUserData{{ + Effective: capMask(unix.CAP_SETGID, unix.CAP_SETUID, unix.CAP_SETPCAP), + Permitted: capMask(unix.CAP_SETGID, unix.CAP_SETUID, unix.CAP_SETPCAP), + Inheritable: capMask(unix.CAP_SETGID, unix.CAP_SETUID, unix.CAP_SETPCAP), + }} + hdr := unix.CapUserHeader{Version: unix.LINUX_CAPABILITY_VERSION_3} + return unix.Capset(&hdr, &data[0]) +} + +func becomeSupervisor() error { + if err := unix.Prctl(unix.PR_SET_KEEPCAPS, 1, 0, 0, 0); err != nil { + return fmt.Errorf("keep supervisor capabilities: %w", err) + } + if err := syscall.Setgroups([]int{supervisorGID}); err != nil { + return fmt.Errorf("set supervisor groups: %w", err) + } + if err := syscall.Setresgid(supervisorGID, supervisorGID, supervisorGID); err != nil { + return fmt.Errorf("set supervisor gids: %w", err) + } + if err := syscall.Setresuid(supervisorUID, supervisorUID, supervisorUID); err != nil { + return fmt.Errorf("set supervisor uids: %w", err) + } + if err := setSupervisorCapabilities(); err != nil { + return fmt.Errorf("set supervisor capabilities: %w", err) + } + if err := unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0); err != nil { + return fmt.Errorf("make supervisor non-dumpable: %w", err) + } + return nil +} + +type canaries struct { + files []*os.File + fds []int +} + +func openCanaries() (*canaries, error) { + c := &canaries{} + add := func(fd int, name string) error { + if fd < 0 { + return fmt.Errorf("open %s canary", name) + } + _, err := unix.FcntlInt(uintptr(fd), unix.F_SETFD, 0) + if err != nil { + unix.Close(fd) + return fmt.Errorf("clear close-on-exec for %s canary: %w", name, err) + } + c.fds = append(c.fds, fd) + return nil + } + f, err := os.OpenFile("/probe-state/supervisor-private", os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, err + } + c.files = append(c.files, f) + if err := add(int(f.Fd()), "private file"); err != nil { + return nil, err + } + sp, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM, 0) + if err != nil { + return nil, err + } + for _, fd := range sp { + if err := add(fd, "socket"); err != nil { + return nil, err + } + } + var pipe [2]int + if err := unix.Pipe(pipe[:]); err != nil { + return nil, err + } + for _, fd := range pipe { + if err := add(fd, "control pipe"); err != nil { + return nil, err + } + } + pidfd, err := unix.PidfdOpen(os.Getpid(), 0) + if err != nil { + return nil, err + } + if err := add(pidfd, "pidfd"); err != nil { + return nil, err + } + return c, nil +} + +func (c *canaries) close() { + seen := map[int]bool{} + for _, f := range c.files { + seen[int(f.Fd())] = true + _ = f.Close() + } + for _, fd := range c.fds { + if !seen[fd] { + _ = unix.Close(fd) + } + } +} + +func runSupervisor() error { + // Linux capabilities are per-thread. Keep all privileged child-launch + // operations on the one thread whose narrowly allowlisted capability set is + // established below; request handling is intentionally serial. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + if err := becomeSupervisor(); err != nil { + return err + } + canaries, err := openCanaries() + if err != nil { + return fmt.Errorf("open supervisor canaries: %w", err) + } + defer canaries.close() + _ = os.Remove(socketPath) + listener, err := net.Listen("unix", socketPath) + if err != nil { + return fmt.Errorf("listen on control socket: %w", err) + } + defer listener.Close() + if err := os.Chmod(socketPath, 0o600); err != nil { + return err + } + for { + conn, err := listener.Accept() + if err != nil { + return err + } + func() { + defer conn.Close() + var req request + if err := json.NewDecoder(io.LimitReader(conn, 64<<10)).Decode(&req); err != nil { + _ = json.NewEncoder(conn).Encode(response{Error: err.Error()}) + return + } + resp := handleRequest(req, canaries) + _ = json.NewEncoder(conn).Encode(resp) + }() + } +} + +func rawResponse(v any) response { + b, err := json.Marshal(v) + if err != nil { + return response{Error: err.Error()} + } + return response{Pass: true, Data: b} +} + +func handleRequest(req request, canaries *canaries) response { + switch req.Action { + case "ready": + return rawResponse(map[string]any{"ready": true, "pid": os.Getpid()}) + case "supervisor-report": + status, err := statusFields(os.Getpid()) + if err != nil { + return response{Error: err.Error()} + } + dumpable, err := unix.PrctlRetInt(unix.PR_GET_DUMPABLE, 0, 0, 0, 0) + if err != nil { + return response{Error: err.Error()} + } + sid, err := unix.Getsid(0) + if err != nil { + return response{Error: err.Error()} + } + return rawResponse(map[string]any{ + "status": status, "dumpable": dumpable, "sid": sid, + "canary_fds": canaries.fds, + "memory_address": fmt.Sprintf("%x", uintptr(unsafe.Pointer(&supervisorMemory))), + }) + case "app-report": + return launchCaptured(canaries, "/probe", "app-report", strconv.Itoa(os.Getpid()), + fmt.Sprintf("%x", uintptr(unsafe.Pointer(&supervisorMemory)))) + case "write-private": + return launchCaptured(canaries, "/probe", "write-private", "/probe-state/app-private") + case "setid-target": + return launchCaptured(canaries, "/probe-setid", "privilege-target") + case "filecap-target": + return launchFileCapabilityTarget(canaries) + case "start-services": + if req.Token == "" { + return response{Error: "missing service token"} + } + cmd, err := launchCommand(canaries, "/probe", "service-server", req.Token) + if err != nil { + return response{Error: err.Error()} + } + defer closeExtraFiles(cmd) + logFile, err := os.OpenFile("/probe-state/service.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return response{Error: err.Error()} + } + cmd.Stdout, cmd.Stderr = logFile, logFile + if err := cmd.Start(); err != nil { + logFile.Close() + return response{Error: err.Error()} + } + done := make(chan error, 1) + go func() { + done <- cmd.Wait() + _ = logFile.Close() + }() + for i := 0; i < 250; i++ { + readyData, err := os.ReadFile("/probe-state/service-ready") + if err == nil { + var ready map[string]any + if err := json.Unmarshal(readyData, &ready); err != nil { + return response{Error: fmt.Sprintf("decode service readiness: %v", err)} + } + ready["pid"] = cmd.Process.Pid + return rawResponse(ready) + } + select { + case err := <-done: + logData, _ := os.ReadFile("/probe-state/service.log") + return response{Error: fmt.Sprintf("service child exited before readiness: %v: %s", err, strings.TrimSpace(string(logData)))} + default: + } + time.Sleep(20 * time.Millisecond) + } + logData, _ := os.ReadFile("/probe-state/service.log") + return response{Error: fmt.Sprintf("service child did not become ready: %s", strings.TrimSpace(string(logData)))} + case "cross-probe": + return launchCaptured(canaries, "/probe", "cross-probe", req.Token, strconv.Itoa(req.TargetPID)) + case "reject-uid": + return policyRejection(req.Value, map[int]bool{declaredUID: true}, "uid") + case "reject-gid": + return policyRejection(req.Value, map[int]bool{declaredGID: true}, "gid") + case "reject-group": + return policyRejection(req.Value, map[int]bool{declaredGroup1: true, declaredGroup2: true}, "group") + default: + return response{Error: fmt.Sprintf("unknown action %q", req.Action)} + } +} + +func policyRejection(value int, accepted map[int]bool, kind string) response { + if accepted[value] { + return response{Error: fmt.Sprintf("test requested accepted %s %d", kind, value)} + } + b, _ := json.Marshal(result{Test: "supervisor_reject_" + kind, Pass: true, + Expected: "rejected", Actual: "rejected", Details: map[string]any{"value": value}}) + return response{Pass: true, Rejected: true, Data: b} +} + +func launchCommand(canaries *canaries, path string, args ...string) (*exec.Cmd, error) { + cmdArgs := append([]string{"child-launch", path}, args...) + cmd := exec.Command("/probe", cmdArgs...) + cmd.SysProcAttr = &syscall.SysProcAttr{ + AmbientCaps: []uintptr{unix.CAP_SETGID, unix.CAP_SETUID, unix.CAP_SETPCAP}, + } + for index, fd := range canaries.fds { + dup, err := unix.Dup(fd) + if err != nil { + closeExtraFiles(cmd) + return nil, fmt.Errorf("duplicate canary fd %d: %w", fd, err) + } + cmd.ExtraFiles = append(cmd.ExtraFiles, os.NewFile(uintptr(dup), fmt.Sprintf("canary-%d", index))) + } + childFDs := make([]string, len(cmd.ExtraFiles)) + for index := range cmd.ExtraFiles { + childFDs[index] = strconv.Itoa(3 + index) + } + cmd.Env = append(os.Environ(), canaryFDsEnv+"="+strings.Join(childFDs, ",")) + return cmd, nil +} + +func closeExtraFiles(cmd *exec.Cmd) { + for _, file := range cmd.ExtraFiles { + _ = file.Close() + } + cmd.ExtraFiles = nil +} + +func launchCaptured(canaries *canaries, path string, args ...string) response { + cmd, err := launchCommand(canaries, path, args...) + if err != nil { + return response{Error: err.Error()} + } + defer closeExtraFiles(cmd) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + err = cmd.Run() + if err != nil { + return response{Error: fmt.Sprintf("child failed: %v: %s", err, strings.TrimSpace(stderr.String()))} + } + data := bytes.TrimSpace(stdout.Bytes()) + if !json.Valid(data) { + return response{Error: fmt.Sprintf("child returned invalid JSON: %q", data)} + } + return response{Pass: true, Data: append(json.RawMessage(nil), data...)} +} + +func launchFileCapabilityTarget(canaries *canaries) response { + xattr := make([]byte, 64) + size, err := unix.Getxattr("/probe-filecap", "security.capability", xattr) + if err != nil { + return response{Error: fmt.Sprintf("read file capability fixture: %v", err)} + } + xattr = xattr[:size] + cmd, err := launchCommand(canaries, "/probe-filecap", "privilege-target") + if err != nil { + return response{Error: err.Error()} + } + defer closeExtraFiles(cmd) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + err = cmd.Run() + if err != nil { + message := strings.TrimSpace(stderr.String()) + if strings.Contains(message, "exec target /probe-filecap: operation not permitted") { + return rawResponse(map[string]any{ + "pass": true, "outcome": "execution_denied", + "security_capability": fmt.Sprintf("%x", xattr), + }) + } + return response{Error: fmt.Sprintf("file-capability child failed: %v: %s", err, message)} + } + var target map[string]any + if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &target); err != nil { + return response{Error: fmt.Sprintf("file-capability child returned invalid JSON: %v", err)} + } + return rawResponse(map[string]any{ + "pass": target["pass"] == true, "outcome": "executed_without_privilege", + "security_capability": fmt.Sprintf("%x", xattr), "target": target, + }) +} + +func runRequest(args []string) error { + if len(args) < 1 { + return errors.New("request requires an action") + } + req := request{Action: args[0]} + for _, arg := range args[1:] { + key, value, ok := strings.Cut(arg, "=") + if !ok { + return fmt.Errorf("invalid request argument %q", arg) + } + switch key { + case "value": + n, err := strconv.Atoi(value) + if err != nil { + return err + } + req.Value = n + case "token": + req.Token = value + case "target_pid": + n, err := strconv.Atoi(value) + if err != nil { + return err + } + req.TargetPID = n + default: + return fmt.Errorf("unknown request argument %q", key) + } + } + conn, err := net.DialTimeout("unix", socketPath, 2*time.Second) + if err != nil { + return err + } + defer conn.Close() + if err := json.NewEncoder(conn).Encode(req); err != nil { + return err + } + var resp response + if err := json.NewDecoder(io.LimitReader(conn, 1<<20)).Decode(&resp); err != nil { + return err + } + if err := emit(resp); err != nil { + return err + } + if resp.Error != "" || !resp.Pass { + return errors.New("request failed") + } + return nil +} + +func runChildLaunch(args []string) error { + if len(args) < 1 { + return errors.New("child-launch requires an executable") + } + runtime.LockOSThread() + defer runtime.UnlockOSThread() + if err := unix.Prctl(unix.PR_SET_KEEPCAPS, 1, 0, 0, 0); err != nil { + return err + } + for cap := 0; cap <= 63; cap++ { + if cap == unix.CAP_SETPCAP { + continue + } + if err := unix.Prctl(unix.PR_CAPBSET_DROP, uintptr(cap), 0, 0, 0); err != nil && err != unix.EINVAL { + return fmt.Errorf("drop bounding capability %d: %w", cap, err) + } + } + if err := unix.Prctl(unix.PR_CAPBSET_DROP, unix.CAP_SETPCAP, 0, 0, 0); err != nil { + return fmt.Errorf("drop CAP_SETPCAP from bounding set: %w", err) + } + if err := unix.Setgroups([]int{declaredGroup1, declaredGroup2}); err != nil { + return err + } + if err := unix.Setresgid(declaredGID, declaredGID, declaredGID); err != nil { + return err + } + if err := unix.Setresuid(declaredUID, declaredUID, declaredUID); err != nil { + return err + } + empty := [2]unix.CapUserData{} + hdr := unix.CapUserHeader{Version: unix.LINUX_CAPABILITY_VERSION_3} + if err := unix.Capset(&hdr, &empty[0]); err != nil { + return err + } + if err := unix.Prctl(unix.PR_CAP_AMBIENT, unix.PR_CAP_AMBIENT_CLEAR_ALL, 0, 0, 0); err != nil { + return err + } + if err := unix.Prctl(unix.PR_SET_KEEPCAPS, 0, 0, 0, 0); err != nil { + return err + } + if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil { + return err + } + if _, err := unix.Setsid(); err != nil { + return err + } + canaryFDs, err := parseCanaryFDs(os.Getenv(canaryFDsEnv)) + if err != nil { + return err + } + for _, fd := range canaryFDs { + if _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); err != nil { + return fmt.Errorf("canary fd %d was not inherited by the trusted launcher: %w", fd, err) + } + } + if err := unix.CloseRange(3, ^uint(0), 0); err != nil { + return err + } + for _, fd := range canaryFDs { + if _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); err != unix.EBADF { + return fmt.Errorf("canary fd %d survived close_range: %v", fd, err) + } + } + remainingFDs, err := listFDs() + if err != nil { + return err + } + if fmt.Sprint(remainingFDs) != "[0 1 2]" { + return fmt.Errorf("unexpected descriptors after close_range: %v", remainingFDs) + } + closeRangeEvidence := fmt.Sprintf("canaries=%d;allowlist=0,1,2", len(canaryFDs)) + if err := os.Setenv(closeRangeVerifiedEnv, closeRangeEvidence); err != nil { + return err + } + if err := os.Unsetenv(canaryFDsEnv); err != nil { + return err + } + if err := unix.Exec(args[0], args, os.Environ()); err != nil { + return fmt.Errorf("exec target %s: %w", args[0], err) + } + return nil +} + +func parseCanaryFDs(value string) ([]int, error) { + if value == "" { + return nil, errors.New("trusted launcher received no canary descriptors") + } + parts := strings.Split(value, ",") + fds := make([]int, 0, len(parts)) + for _, part := range parts { + fd, err := strconv.Atoi(part) + if err != nil || fd < 3 { + return nil, fmt.Errorf("invalid canary fd %q", part) + } + fds = append(fds, fd) + } + return fds, nil +} + +func listFDs() ([]int, error) { + entries, err := os.ReadDir("/proc/self/fd") + if err != nil { + return nil, err + } + var out []int + for _, entry := range entries { + fd, err := strconv.Atoi(entry.Name()) + if err != nil { + continue + } + target, err := os.Readlink(filepath.Join("/proc/self/fd", entry.Name())) + if err != nil { + // os.ReadDir's own descriptor is closed before target inspection. + continue + } + if target == "/proc/self/fd" || strings.HasSuffix(target, "/fd") { + continue + } + out = append(out, fd) + } + sort.Ints(out) + return out, nil +} + +func denied(name string, err error) result { + return result{Test: name, Pass: err != nil, Expected: "denied", Actual: ternary(err != nil, "denied", "allowed"), Error: errorString(err)} +} + +func ternary[T any](condition bool, yes, no T) T { + if condition { + return yes + } + return no +} + +func errorString(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +func runAppReport(args []string) error { + if len(args) != 2 { + return errors.New("app-report requires supervisor pid and memory address") + } + supervisorPID, err := strconv.Atoi(args[0]) + if err != nil { + return err + } + address, err := strconv.ParseUint(args[1], 16, 64) + if err != nil { + return err + } + status, err := statusFields(os.Getpid()) + if err != nil { + return err + } + fds, err := listFDs() + if err != nil { + return err + } + groups, err := os.Getgroups() + if err != nil { + return err + } + sid, err := unix.Getsid(0) + if err != nil { + return err + } + supervisorSID, err := unix.Getsid(supervisorPID) + if err != nil { + return err + } + + results := []result{ + {Test: "final_uids", Pass: status["Uid"] == "1001\t1001\t1001\t1001", Expected: "1001 1001 1001 1001", Actual: status["Uid"]}, + {Test: "final_gids", Pass: status["Gid"] == "2001\t2001\t2001\t2001", Expected: "2001 2001 2001 2001", Actual: status["Gid"]}, + {Test: "final_groups", Pass: fmt.Sprint(groups) == "[3001 3002]", Expected: "[3001 3002]", Actual: fmt.Sprint(groups)}, + {Test: "empty_capability_sets", Pass: allCapabilitySetsZero(status), Actual: fmt.Sprint(capabilityFields(status))}, + {Test: "no_new_privileges", Pass: status["NoNewPrivs"] == "1", Expected: "1", Actual: status["NoNewPrivs"]}, + {Test: "seccomp_filter", Pass: status["Seccomp"] == "2", Expected: "2", Actual: status["Seccomp"]}, + {Test: "inherited_descriptor_cleanup", Pass: os.Getenv(closeRangeVerifiedEnv) == "canaries=6;allowlist=0,1,2", Expected: "canaries=6;allowlist=0,1,2", Actual: os.Getenv(closeRangeVerifiedEnv), Details: map[string]any{"runtime_fds": fds}}, + {Test: "session_separation", Pass: sid != supervisorSID, Expected: "different", Actual: fmt.Sprintf("child=%d supervisor=%d", sid, supervisorSID)}, + } + + results = append(results, + attemptIDChange("post_drop_setuid", func() error { return unix.Setuid(undeclaredUID) }), + attemptIDChange("post_drop_setreuid", func() error { return unix.Setreuid(undeclaredUID, undeclaredUID) }), + attemptIDChange("post_drop_setresuid", func() error { return unix.Setresuid(undeclaredUID, undeclaredUID, undeclaredUID) }), + attemptIDChange("post_drop_setfsuid", func() error { return setfsuidChecked(undeclaredUID) }), + attemptIDChange("post_drop_setgid", func() error { return unix.Setgid(undeclaredGID) }), + attemptIDChange("post_drop_setregid", func() error { return unix.Setregid(undeclaredGID, undeclaredGID) }), + attemptIDChange("post_drop_setresgid", func() error { return unix.Setresgid(undeclaredGID, undeclaredGID, undeclaredGID) }), + attemptIDChange("post_drop_setfsgid", func() error { return setfsgidChecked(undeclaredGID) }), + attemptIDChange("post_drop_setgroups", func() error { return unix.Setgroups([]int{undeclaredGroup}) }), + ) + + results = append(results, + denied("child_kill_permission", unix.Kill(supervisorPID, 0)), + denied("child_sigcont", unix.Kill(supervisorPID, unix.SIGCONT)), + ) + pidfd, pidfdErr := unix.PidfdOpen(supervisorPID, 0) + if pidfdErr == nil { + sendErr := unix.PidfdSendSignal(pidfd, unix.SIGCONT, nil, 0) + _ = unix.Close(pidfd) + results = append(results, denied("child_pidfd_send_signal", sendErr)) + } else { + results = append(results, result{Test: "child_pidfd_send_signal", Pass: true, Expected: "denied", Actual: "pidfd_open_denied", Error: pidfdErr.Error()}) + } + ptraceErr := unix.PtraceAttach(supervisorPID) + if ptraceErr == nil { + _ = unix.PtraceDetach(supervisorPID) + } + results = append(results, denied("child_ptrace", ptraceErr)) + mem, memErr := os.OpenFile(fmt.Sprintf("/proc/%d/mem", supervisorPID), os.O_RDWR, 0) + if memErr == nil { + _ = mem.Close() + } + results = append(results, denied("child_proc_mem", memErr)) + local := []byte{0} + localIO := []unix.Iovec{{Base: &local[0], Len: 1}} + remoteIO := []unix.RemoteIovec{{Base: uintptr(address), Len: 1}} + _, readErr := unix.ProcessVMReadv(supervisorPID, localIO, remoteIO, 0) + results = append(results, denied("child_process_vm_readv", readErr)) + _, writeErr := unix.ProcessVMWritev(supervisorPID, localIO, remoteIO, 0) + results = append(results, denied("child_process_vm_writev", writeErr)) + _, vmspliceErr := vmspliceRoundTrip() + results = append(results, result{ + Test: "seccomp_vmsplice_denied", Pass: vmspliceErr == unix.EPERM, + Expected: "operation not permitted", Actual: errorString(vmspliceErr), + }) + rootErr := os.WriteFile("/rootfs-write-must-fail", []byte("x"), 0o600) + results = append(results, denied("readonly_rootfs", rootErr)) + writableErr := os.WriteFile("/probe-state/app-write", []byte("x"), 0o600) + results = append(results, result{Test: "declared_writable_storage", Pass: writableErr == nil, Expected: "allowed", Actual: ternary(writableErr == nil, "allowed", "denied"), Error: errorString(writableErr)}) + + pass := true + for _, item := range results { + pass = pass && item.Pass + } + return emit(map[string]any{"pass": pass, "status": status, "results": results}) +} + +func vmspliceRoundTrip() (int, error) { + var pipe [2]int + if err := unix.Pipe(pipe[:]); err != nil { + return 0, err + } + defer unix.Close(pipe[0]) + defer unix.Close(pipe[1]) + payload := []byte("vmsplice-control") + iov := unix.Iovec{Base: &payload[0], Len: uint64(len(payload))} + written, err := unix.Vmsplice(pipe[1], []unix.Iovec{iov}, 0) + if err != nil { + return 0, err + } + received := make([]byte, len(payload)) + read, err := unix.Read(pipe[0], received) + if err != nil { + return written, err + } + if written != len(payload) || read != len(payload) || !bytes.Equal(received, payload) { + return written, fmt.Errorf("unexpected vmsplice round trip: wrote %d read %d", written, read) + } + return written, nil +} + +func runSeccompControl() error { + written, err := vmspliceRoundTrip() + return emit(map[string]any{ + "test": "seccomp_vmsplice_unfiltered_control", + "pass": err == nil, + "written": written, + "error": errorString(err), + }) +} + +func capabilityFields(status map[string]string) map[string]string { + out := map[string]string{} + for _, key := range []string{"CapInh", "CapPrm", "CapEff", "CapBnd", "CapAmb"} { + out[key] = status[key] + } + return out +} + +func allCapabilitySetsZero(status map[string]string) bool { + for _, value := range capabilityFields(status) { + if strings.TrimLeft(value, "0") != "" { + return false + } + } + return true +} + +func attemptIDChange(name string, fn func() error) result { + err := fn() + return denied(name, err) +} + +func setfsuidChecked(id int) error { + if err := unix.Setfsuid(id); err != nil { + return err + } + return requireFilesystemID("Uid", id) +} + +func setfsgidChecked(id int) error { + if err := unix.Setfsgid(id); err != nil { + return err + } + return requireFilesystemID("Gid", id) + +} + +func requireFilesystemID(field string, expected int) error { + status, err := statusFields(os.Getpid()) + if err != nil { + return err + } + values := strings.Fields(status[field]) + if len(values) != 4 { + return fmt.Errorf("unexpected %s status field %q", field, status[field]) + } + if values[3] != strconv.Itoa(expected) { + return fmt.Errorf("filesystem %s remained %s instead of %d", strings.ToLower(field), values[3], expected) + } + return nil +} + +func runPrivilegeTarget() error { + status, err := statusFields(os.Getpid()) + if err != nil { + return err + } + info, err := os.Stat(os.Args[0]) + if err != nil { + return err + } + fixture, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return errors.New("set-ID fixture has no Linux stat metadata") + } + fixturePass := fixture.Uid == supervisorUID && fixture.Gid == supervisorGID && + info.Mode()&os.ModeSetuid != 0 && info.Mode()&os.ModeSetgid != 0 + pass := os.Getuid() == declaredUID && os.Geteuid() == declaredUID && + os.Getgid() == declaredGID && os.Getegid() == declaredGID && + allCapabilitySetsZero(status) && fixturePass + return emit(map[string]any{ + "pass": pass, "uid": os.Getuid(), "euid": os.Geteuid(), + "gid": os.Getgid(), "egid": os.Getegid(), "status": status, + "fixture": map[string]any{ + "path": os.Args[0], "uid": fixture.Uid, "gid": fixture.Gid, + "setuid": info.Mode()&os.ModeSetuid != 0, + "setgid": info.Mode()&os.ModeSetgid != 0, + }, + }) +} + +func emitCapabilityReport(test string) error { + status, err := statusFields(os.Getpid()) + if err != nil { + return err + } + return emit(map[string]any{"test": test, "pass": allCapabilitySetsZero(status), "status": status}) +} + +type rawCase struct { + Name string + Op string + ID int + Expected bool +} + +func runRawMatrix(args []string) error { + if len(args) != 1 || (args[0] != "exact" && args[0] != "bounded") { + return errors.New("raw-matrix requires exact or bounded") + } + profile := args[0] + classes := []struct { + Name string + UID, GID, Group int + Exact, Bounded bool + }{ + {"declared", declaredUID, declaredGID, declaredGroup1, true, true}, + {"undeclared", undeclaredUID, undeclaredGID, undeclaredGroup, false, true}, + {"outside", outsideID, outsideID, outsideID, false, false}, + } + var cases []rawCase + for _, class := range classes { + expected := class.Bounded + if profile == "exact" { + expected = class.Exact + } + for _, op := range []string{"setuid", "setreuid", "setresuid", "setfsuid"} { + cases = append(cases, rawCase{class.Name + "_" + op, op, class.UID, expected}) + } + for _, op := range []string{"setgid", "setregid", "setresgid", "setfsgid"} { + cases = append(cases, rawCase{class.Name + "_" + op, op, class.GID, expected}) + } + cases = append(cases, rawCase{class.Name + "_setgroups", "setgroups", class.Group, expected}) + } + // The raw mechanism maps this runtime-only GID, but supervisor policy must reject it. + cases = append(cases, rawCase{"runtime_required_setgid", "setgid", runtimeGID, true}) + results := make([]result, 0, len(cases)) + for _, tc := range cases { + cmd := exec.Command("/probe", "raw-attempt", tc.Op, strconv.Itoa(tc.ID)) + err := cmd.Run() + actual := err == nil + results = append(results, result{Test: tc.Name, Pass: actual == tc.Expected, + Expected: ternary(tc.Expected, "allowed", "denied"), Actual: ternary(actual, "allowed", "denied"), Error: errorString(err)}) + } + pass := true + for _, item := range results { + pass = pass && item.Pass + } + return emit(map[string]any{"profile": profile, "pass": pass, "results": results}) +} + +func runRawAttempt(args []string) error { + if len(args) != 2 { + return errors.New("raw-attempt requires operation and id") + } + id, err := strconv.Atoi(args[1]) + if err != nil { + return err + } + runtime.LockOSThread() + defer runtime.UnlockOSThread() + switch args[0] { + case "setuid": + return unix.Setuid(id) + case "setreuid": + return unix.Setreuid(id, id) + case "setresuid": + return unix.Setresuid(id, id, id) + case "setfsuid": + return setfsuidChecked(id) + case "setgid": + return unix.Setgid(id) + case "setregid": + return unix.Setregid(id, id) + case "setresgid": + return unix.Setresgid(id, id, id) + case "setfsgid": + return setfsgidChecked(id) + case "setgroups": + return unix.Setgroups([]int{id}) + default: + return fmt.Errorf("unknown raw operation %q", args[0]) + } +} + +func writePrivate(args []string) error { + if len(args) != 1 { + return errors.New("write-private requires a path") + } + if err := os.WriteFile(args[0], []byte("private\n"), 0o600); err != nil { + return err + } + return emit(map[string]any{"pass": true, "path": args[0]}) +} + +func tokenKey(token string) int { + var value uint32 = 2166136261 + for _, b := range []byte(token) { + value = (value ^ uint32(b)) * 16777619 + } + return int(value&0x3fffffff) | 0x10000 +} + +func startPOSIXIPCServer(token string) (*exec.Cmd, map[string]any, error) { + cmd := exec.Command("/posix-ipc-probe", "serve", token) + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, nil, err + } + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + return nil, nil, err + } + var report map[string]any + if err := json.NewDecoder(stdout).Decode(&report); err != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + return nil, nil, fmt.Errorf("read POSIX IPC server readiness: %w", err) + } + if report["pass"] != true { + _ = cmd.Process.Kill() + _ = cmd.Wait() + return nil, nil, fmt.Errorf("POSIX IPC server failed its positive controls: %v", report) + } + return cmd, report, nil +} + +func runServiceServer(args []string) error { + if len(args) != 1 { + return errors.New("service-server requires token") + } + token := args[0] + key := tokenKey(token) + shm, err := unix.SysvShmGet(key, 4096, unix.IPC_CREAT|unix.IPC_EXCL|0o600) + if err != nil { + return fmt.Errorf("create SysV shm: %w", err) + } + _, _, semErr := unix.Syscall(unix.SYS_SEMGET, uintptr(key), 1, unix.IPC_CREAT|unix.IPC_EXCL|0o600) + if semErr != 0 { + return fmt.Errorf("create SysV semaphore: %w", semErr) + } + _, _, msgErr := unix.Syscall(unix.SYS_MSGGET, uintptr(key), unix.IPC_CREAT|unix.IPC_EXCL|0o600, 0) + if msgErr != 0 { + return fmt.Errorf("create SysV message queue: %w", msgErr) + } + posixServer, posixIPC, err := startPOSIXIPCServer(token) + if err != nil { + return err + } + defer func() { + _ = posixServer.Process.Kill() + _ = posixServer.Wait() + }() + // The POSIX API requires a leading slash, but libc strips it before the + // Linux mq_open syscall. This static probe calls the syscall directly. + mqName := "reploy-" + token + mqPtr, _ := unix.BytePtrFromString(mqName) + mq, _, mqErr := unix.Syscall6(unix.SYS_MQ_OPEN, uintptr(unsafe.Pointer(mqPtr)), unix.O_CREAT|unix.O_EXCL|unix.O_RDWR|unix.O_NONBLOCK, 0o600, 0, 0, 0) + if mqErr != 0 { + info, statErr := os.Stat("/dev/mqueue") + if statErr != nil { + return fmt.Errorf("create POSIX message queue: %w; stat /dev/mqueue: %v", mqErr, statErr) + } + stat := info.Sys().(*syscall.Stat_t) + return fmt.Errorf("create POSIX message queue: %w; /dev/mqueue mode=%s uid=%d gid=%d", mqErr, info.Mode(), stat.Uid, stat.Gid) + } + defer unix.Close(int(mq)) + fmt.Fprintln(os.Stderr, "service: POSIX message queue opened") + payload := []byte("mq-round-trip") + _, _, mqSendErr := unix.Syscall6( + unix.SYS_MQ_TIMEDSEND, mq, uintptr(unsafe.Pointer(&payload[0])), + uintptr(len(payload)), 1, 0, 0, + ) + if mqSendErr != 0 { + return fmt.Errorf("send POSIX message: %w", mqSendErr) + } + fmt.Fprintln(os.Stderr, "service: POSIX message sent") + received := make([]byte, 8192) + var priority uint32 + receivedSize, _, mqReceiveErr := unix.Syscall6( + unix.SYS_MQ_TIMEDRECEIVE, mq, uintptr(unsafe.Pointer(&received[0])), + uintptr(len(received)), uintptr(unsafe.Pointer(&priority)), 0, 0, + ) + if mqReceiveErr != 0 { + return fmt.Errorf("receive POSIX message: %w", mqReceiveErr) + } + if string(received[:receivedSize]) != string(payload) || priority != 1 { + return fmt.Errorf("unexpected POSIX message round trip") + } + fmt.Fprintln(os.Stderr, "service: POSIX message received") + // A network-none namespace deliberately leaves loopback down. Binding the + // wildcard address still creates namespace-local sockets for the isolation + // probe without requiring network setup. + tcpLn, err := net.Listen("tcp4", fmt.Sprintf("0.0.0.0:%d", servicePort)) + if err != nil { + return err + } + defer tcpLn.Close() + fmt.Fprintln(os.Stderr, "service: TCP listener ready") + udp, err := net.ListenPacket("udp4", fmt.Sprintf("0.0.0.0:%d", servicePort)) + if err != nil { + return err + } + defer udp.Close() + fmt.Fprintln(os.Stderr, "service: UDP listener ready") + abstract, err := net.Listen("unix", "\x00reploy-"+token) + if err != nil { + return err + } + defer abstract.Close() + fmt.Fprintln(os.Stderr, "service: abstract Unix listener ready") + go acceptAndDiscard(tcpLn) + go acceptAndDiscard(abstract) + go echoUDP(udp) + if err := os.WriteFile("/probe-state/service-secret", []byte("secret"), 0o600); err != nil { + return err + } + ready, err := json.Marshal(map[string]any{ + "pass": true, "sysv_shm_id": shm, "posix_ipc": posixIPC, + "posix_mq_round_trip": true, + }) + if err != nil { + return err + } + if err := os.WriteFile("/probe-state/service-ready", ready, 0o644); err != nil { + return err + } + select {} +} + +func acceptAndDiscard(listener net.Listener) { + for { + conn, err := listener.Accept() + if err != nil { + return + } + _ = conn.Close() + } +} + +func echoUDP(conn net.PacketConn) { + buf := make([]byte, 64) + for { + n, addr, err := conn.ReadFrom(buf) + if err != nil { + return + } + _, _ = conn.WriteTo(buf[:n], addr) + } +} + +func runCrossProbe(args []string) error { + if len(args) != 2 { + return errors.New("cross-probe requires token and target pid") + } + token := args[0] + target, err := strconv.Atoi(args[1]) + if err != nil { + return err + } + key := tokenKey(token) + var results []result + _, shmErr := unix.SysvShmGet(key, 1, 0) + results = append(results, denied("cross_sysv_shm", shmErr)) + _, _, semErr := unix.Syscall(unix.SYS_SEMGET, uintptr(key), 1, 0) + results = append(results, denied("cross_sysv_sem", semErr)) + _, _, msgErr := unix.Syscall(unix.SYS_MSGGET, uintptr(key), 0, 0) + results = append(results, denied("cross_sysv_msg", msgErr)) + posixOutput, posixErr := exec.Command("/posix-ipc-probe", "probe", token).CombinedOutput() + var posixProbe map[string]any + posixDecodeErr := json.Unmarshal(bytes.TrimSpace(posixOutput), &posixProbe) + posixPass := posixErr == nil && posixDecodeErr == nil && posixProbe["pass"] == true + results = append(results, result{ + Test: "cross_posix_shm_and_sem", Pass: posixPass, + Expected: "shm_open and sem_open denied with ENOENT", + Actual: strings.TrimSpace(string(posixOutput)), + Error: errorString(errors.Join(posixErr, posixDecodeErr)), + }) + private, privateErr := os.Open("/probe-state/service-secret") + if privateErr == nil { + _ = private.Close() + } + results = append(results, denied("cross_private_state", privateErr)) + mqName := "reploy-" + token + mqPtr, _ := unix.BytePtrFromString(mqName) + mq, _, mqErr := unix.Syscall6(unix.SYS_MQ_OPEN, uintptr(unsafe.Pointer(mqPtr)), unix.O_RDONLY|unix.O_NONBLOCK, 0, 0, 0, 0) + if mqErr == 0 { + _ = unix.Close(int(mq)) + } + results = append(results, denied("cross_posix_mq", mqErr)) + tcp, tcpErr := net.DialTimeout("tcp4", fmt.Sprintf("127.0.0.1:%d", servicePort), 200*time.Millisecond) + if tcpErr == nil { + _ = tcp.Close() + } + results = append(results, denied("cross_tcp", tcpErr)) + udp, udpErr := net.DialTimeout("udp4", fmt.Sprintf("127.0.0.1:%d", servicePort), 200*time.Millisecond) + if udpErr == nil { + _ = udp.SetDeadline(time.Now().Add(200 * time.Millisecond)) + _, udpErr = udp.Write([]byte("probe")) + if udpErr == nil { + _, udpErr = udp.Read(make([]byte, 16)) + } + _ = udp.Close() + } + results = append(results, denied("cross_udp", udpErr)) + abs, absErr := net.DialTimeout("unix", "\x00reploy-"+token, 200*time.Millisecond) + if absErr == nil { + _ = abs.Close() + } + results = append(results, denied("cross_abstract_unix", absErr)) + results = append(results, + denied("cross_process_visibility", unix.Kill(target, 0)), + denied("cross_process_sigcont", unix.Kill(target, unix.SIGCONT)), + ) + pidfd, pidfdErr := unix.PidfdOpen(target, 0) + if pidfdErr == nil { + pidfdErr = unix.PidfdSendSignal(pidfd, unix.SIGCONT, nil, 0) + _ = unix.Close(pidfd) + } + results = append(results, denied("cross_pidfd_signal", pidfdErr)) + ptraceErr := unix.PtraceAttach(target) + if ptraceErr == nil { + _ = unix.PtraceDetach(target) + } + results = append(results, denied("cross_ptrace", ptraceErr)) + mem, memErr := os.Open(fmt.Sprintf("/proc/%d/mem", target)) + if memErr == nil { + _ = mem.Close() + } + results = append(results, denied("cross_proc_mem", memErr)) + pass := true + for _, item := range results { + pass = pass && item.Pass + } + return emit(map[string]any{"pass": pass, "results": results}) +} + +func stampFileCapability(args []string) error { + if len(args) != 1 { + return errors.New("stamp-filecap requires a path") + } + // security.capability revision 2, effective flag, SETGID and SETUID permitted. + data := make([]byte, 20) + binary.LittleEndian.PutUint32(data[0:4], 0x02000001) + binary.LittleEndian.PutUint32(data[4:8], capMask(unix.CAP_SETGID, unix.CAP_SETUID)) + if err := unix.Setxattr(args[0], "security.capability", data, 0); err != nil { + return fmt.Errorf("set file capability: %w", err) + } + return nil +}