diff --git a/.cargo/config.toml.example b/.cargo/config.toml.example index 70621fc..148c066 100644 --- a/.cargo/config.toml.example +++ b/.cargo/config.toml.example @@ -1,16 +1,32 @@ -# Copy to .cargo/config.toml to build the ABI V9 / RESOLVE_UNIX path: -# cp .cargo/config.toml.example .cargo/config.toml +# NOT NEEDED FOR A NORMAL BUILD as of v0.2. +# +# The V9 landlock crate is wired in by a committed `[patch.crates-io]` section in +# Cargo.toml pointing at `third_party/rust-landlock`, whose sources are VENDORED +# (committed to this repo directly — not a git submodule), so a fresh clone +# builds with no init step and no override: +# # cargo build --features resolve-unix -# `paths` must point at a landlock checkout that adds ABI V8/V9 + -# AccessFs::ResolveUnix — upstream rust-landlock PR #135 -# (https://github.com/landlock-lsm/rust-landlock/pull/135). Its head branch lives -# in the author's fork, so fetch it by PR number rather than by branch name: +# +# (`--features resolve-unix` is mandatory since the V9 floor — a build without it +# stops at the compile_error! in src/landlock.rs. See D-4b.) +# +# NOTE: the Landlock ABI is a property of the RUNNING KERNEL — +# LANDLOCK_CREATE_RULESET_VERSION reports what the kernel implements. A V9-capable +# crate on a 6.15 kernel still detects V7, and oring refuses at the floor gate. +# Running needs an actual >= 7.1 kernel (or a VM booting one, as CI does), not +# just this crate. +# +# --- The only remaining use: point at a DIFFERENT landlock checkout ------------ +# Useful when developing against the fork itself, or testing upstream +# rust-landlock#135 (https://github.com/landlock-lsm/rust-landlock/pull/135) +# before it is vendored into third_party/. Copy to .cargo/config.toml and adjust. +# +# A `paths` override SHADOWS the [patch.crates-io] in Cargo.toml and makes cargo +# emit a "recommended to use the [patch] feature" warning — that warning is +# expected here and means the override is in effect. +# # git clone https://github.com/landlock-lsm/rust-landlock ../rust-landlock # git -C ../rust-landlock fetch origin pull/135/head:pr135 # git -C ../rust-landlock checkout pr135 -# (verified: oring builds and its full suite passes with --features resolve-unix -# against this checkout on a real V9 kernel.) Without this override the -# `resolve-unix` feature will not compile against the published crate, so -# `cargo install oring --features resolve-unix` needs a local #135 checkout until -# upstream ships V9 in a release. -paths = ["../rust-landlock"] +# +# paths = ["../rust-landlock"] diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..80f202d --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,5 @@ +#!/bin/sh +# SPDX-License-Identifier: BSD-3-Clause +# Installed by `make hooks`, never automatically — a repository that edits the +# developer's git configuration on build is a repository nobody should trust. +exec "$(git rev-parse --show-toplevel)/scripts/check-commit-msg.sh" "$@" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1beb7e1..4030aa0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,194 +1,93 @@ # SPDX-License-Identifier: BSD-3-Clause -# Makes the gates SECURITY.md/DESIGN describe REAL: fmt, clippy, cargo-deny, -# static-musl build + static-link assertion, and the test suite. Actions are -# pinned by SHA. GitHub's ubuntu-24.04 runner is below oring's V7/6.15 floor, so -# on it the confinement tests SKIP-and-count (require_or_skip gates on -# ABI>=FLOOR_ABI) and lint-build instead asserts the real fail-closed REFUSE. -# The confinement suite is WITNESSED on a real >=floor kernel by the -# `confinement-vng` job below: it boots a >=6.15 guest under virtme-ng/KVM (KVM -# is available on the free runner via a udev rule) and runs the suite with -# ORING_REQUIRE_KERNEL=1 so a skip is a HARD failure. +# +# CI is the make targets. Nothing here does anything a maintainer cannot run +# with one command, and nothing here is the authority on whether oring confines. +# +# WHAT THIS CANNOT DO, stated first because it is the whole shape of the file. +# oring's floor is Landlock ABI V10 / kernel 7.2. No hosted GitHub runner is at +# that floor — 7.2 is not released — so a hosted runner cannot witness a single +# containment cell. `make check` sets ORING_REQUIRE_KERNEL=1 precisely so a +# machine that cannot witness containment REFUSES to report that it did, which +# means running `make check` here would fail on every push, for a reason that has +# nothing to do with the commit. +# +# So this workflow runs the half a sub-floor runner can genuinely check: format, +# lints, dependency policy, the static build, and the seccomp SMT gate — which is +# pure computation over the emitted cBPF and needs no Landlock at all. +# +# CONTAINMENT IS WITNESSED BY `make check` ON A >=7.2 HOST, and nowhere else. +# On the maintainer's box that is 34 suites, 0 failed, plus a matrix of 34 checks +# with all 21 denial cells attributed against a bare control. There is no CI job +# that reproduces it and this file does not pretend otherwise. +# +# A previous version of this file defined three virtme-ng/KVM jobs that built a +# 7.2-rc4 kernel and ran the suite under it. They were never executed — its own +# header said so — and a job that has never run is not evidence, it is an +# intention with YAML around it. Deleted rather than left staged. name: ci + on: push: pull_request: - workflow_dispatch: # manual trigger — also runs the staged confinement-v9 job + workflow_dispatch: -# Supersede an in-flight run on the same ref (the confinement-vng kernel build -# is long; don't stack two). concurrency: group: ci-${{ github.ref }} cancel-in-progress: true jobs: - lint-build: + check: runs-on: ubuntu-24.04 steps: + # Ring 1 is a rev-pinned git dependency on the maintainer's fork (V10), + # required to compile at all since the V10 floor (D-4b). Unlike the vendored + # copy it replaced, this needs the network at build time — `cargo` fetches + # the pinned revision. - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - run: rustup show && rustup target add x86_64-unknown-linux-musl + - run: sudo apt-get update && sudo apt-get install -y z3 + - run: cargo install cargo-deny cargo-auditable --locked + - name: fmt - run: cargo fmt --check - # NOT `-D warnings`: that would escalate the intentional pedantic=warn - # lints to errors. The Cargo [lints] `clippy all=deny` already makes the - # hard set a build failure; pedantic stays advisory. - # `--features bpf-dump` (NOT --all-features): lint the analysis-only - # bpf-dump code (seccomp::dump_groups, the bpf-dump bin, tests/seccomp_smt.rs) - # too, but do NOT enable `resolve-unix` — that feature needs the out-of-tree - # V9 landlock fork (ABI::V8/V9), which the published crate lacks, so it - # cannot compile in CI (see .cargo/config.toml.example, #135). + run: make fmt - name: clippy - run: cargo clippy --all-targets --features bpf-dump --locked - # One install of the security tooling from crates.io (--locked). cargo-deny - # comes from crates.io, NOT its marketplace action, whose bundled binary - # was too old to parse the current RustSec DB (CVSS 4.0). cargo-auditable - # embeds the dependency SBOM in the build below; cargo-audit scans it. - - name: install security tooling - run: cargo install cargo-deny cargo-auditable cargo-audit --locked - - name: cargo-deny (advisories/licenses/bans/sources) - run: cargo deny check - - name: static-musl build (with embedded SBOM) - run: cargo auditable build --release --locked --target x86_64-unknown-linux-musl + run: make clippy + # bans + licenses + sources only. `advisories` fetches the live RustSec + # database, so it would turn this red on a day nobody touched the + # repository; it lives in `make advisories` and `make check-full`. + - name: deny + run: make deny + - name: static-musl build with an embedded SBOM + run: make build env: # Pin the embedded git SHA to a declared input so the same commit - # yields the same bytes in CI and in a .git-absent tarball build. + # yields the same bytes here and in a .git-absent tarball build. ORING_GIT_SHA: ${{ github.sha }} - # rustc links musl targets as static-pie, so file(1) reports "static-pie - # linked", NOT "statically linked" — the literal-string form of this - # assertion failed on a real build. Accept both static spellings; the - # thing being excluded is "dynamically linked". + # rustc links musl as static-pie, so file(1) says "static-pie linked", NOT + # "statically linked" — the literal-string form of this assertion failed on + # a real build. What is being excluded is "dynamically linked". - name: static-link assertion run: | file target/x86_64-unknown-linux-musl/release/oring | tee /dev/stderr \ | grep -qE "static-pie linked|statically linked" - # End-to-end smoke of the SHIPPED musl binary (distinct from `cargo test`, - # which builds debug binaries). The hosted runner's kernel varies around - # the V7 floor: on a >=V7 runner oring seals and execs the target (exit 0); - # on a sub-floor runner it refuses (exit 120). Both are healthy fail-closed - # outcomes; anything else (crash/partial seal) is a real failure. - - name: shipped-binary seal smoke test + # Pure computation over the emitted cBPF — the H2 truncation guard + # (CVE-2019-7303's class) and the socket() protocol wall. No kernel needed, + # so this is real evidence even here. + - name: seccomp SMT gate + run: make smt + + # The one containment fact a SUB-FLOOR runner can establish: that oring + # refuses rather than under-confines. Exit 120 is the fail-closed path and + # is what this runner should produce; exit 0 would mean the runner fleet + # reached 7.2 and this assertion needs revisiting. + - name: fail-closed refusal below the floor run: | set +e target/x86_64-unknown-linux-musl/release/oring --profile balanced --workdir /tmp -- /bin/true rc=$?; echo "exit=$rc" case "$rc" in - 0) echo "PASS: sealed and ran the target (runner is >=V7)" ;; - 120) echo "PASS: refused below the V7 floor (fail-closed)" ;; + 120) echo "PASS: refused below the V10 floor (fail-closed)" ;; + 0) echo "FAIL: sealed on a runner expected to be below the floor —" + echo " the fleet moved; re-read this job and make check's kernel gate"; exit 1 ;; *) echo "FAIL: unexpected exit $rc"; exit 1 ;; esac - # Audit the SHIPPED binary's embedded SBOM against RustSec (complements the - # source-level cargo-deny advisories check above). - - name: audit the shipped binary (RustSec) - run: cargo audit bin target/x86_64-unknown-linux-musl/release/oring - # Unit tests run everywhere; confinement integration tests skip-and-count - # on this below-floor runner (require_or_skip). Not passing ORING_REQUIRE_KERNEL. - - name: tests - run: cargo test --locked - # Ring-2 truncation-invariance gate — the H2 regression guard (§5). Pure - # computation over the emitted cBPF, so it needs a solver but NOT a Landlock - # kernel, and runs fully on this runner (~2s). ORING_REQUIRE_SMT=1 makes a - # missing z3 fatal rather than a silent skip. - - name: install z3 - run: sudo apt-get update && sudo apt-get install -y z3 - - name: seccomp SMT gate - run: cargo test --locked --features bpf-dump --test seccomp_smt - env: - ORING_REQUIRE_SMT: "1" - - # Witness containment on a real >=V7 kernel — the "proven/gated/witnessed" leg. - # The hosted ubuntu-24.04 runner kernel is now itself >=6.15 (V7), so boot IT - # directly under vng/KVM (~30s) instead of compiling a kernel. Clean guest, - # ORING_REQUIRE_KERNEL=1 so a skip is a HARD FAILURE. The V9/RESOLVE_UNIX path - # needs a 7.1 kernel — see the staged confinement-v9 job below. - confinement-vng: - runs-on: ubuntu-24.04 - timeout-minutes: 30 - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - run: rustup show - - name: Enable KVM on the free runner - run: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ - | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - ls -la /dev/kvm - - name: Install virtme-ng - run: | - sudo apt-get update -qq - sudo apt-get install -y qemu-system-x86 >/dev/null - pipx install virtme-ng - vng --version - # Pre-build the test binaries on the host (glibc); the guest shares the - # host FS (hostfs), so the in-guest `cargo test` just executes them. - - name: Build test binaries (host) - run: cargo test --no-run --locked 2>&1 | tail -3 - - name: Confinement suite under vng/KVM (runner kernel; skip => fail) - run: | - set -o pipefail - KREL="$(uname -r)" - sudo chmod +r "/boot/vmlinuz-$KREL" "/boot/config-$KREL" 2>/dev/null || true - # Boot the runner's own kernel (~30s) — no build. Propagate HOME + - # ~/.cargo/bin so host cargo/rustup (shared via hostfs) resolve. - run_in_vng() { timeout 600 vng --run "/boot/vmlinuz-$KREL" -- env HOME="$HOME" PATH="$HOME/.cargo/bin:$PATH" "$@"; } - echo "== guest Landlock ABI (proves >=V7) ==" - run_in_vng sh -c 'echo "uname=$(uname -r)"; echo "lsm=$(cat /sys/kernel/security/lsm)"; python3 -c "import ctypes; print(\"GUEST_LANDLOCK_ABI=\"+str(ctypes.CDLL(None).syscall(444,0,0,1)))"' 2>&1 \ - | grep -E 'uname=|lsm=|GUEST_LANDLOCK_ABI=' | tee guest.info - GABI="$(sed -n 's/.*GUEST_LANDLOCK_ABI=\([0-9]*\).*/\1/p' guest.info | tail -1)" - [ "${GABI:-0}" -ge 7 ] || { echo "FAIL: guest Landlock ABI ${GABI:-unknown} < V7 floor"; exit 1; } - echo "== confinement suite, ORING_REQUIRE_KERNEL=1 ==" - run_in_vng ORING_REQUIRE_KERNEL=1 cargo test --locked 2>&1 | tee vng.log | tail -100 - grep -qE 'test result: ok' vng.log || { echo "FAIL: suite not ok"; exit 1; } - - name: Live jail suite under vng/KVM (runner kernel) - run: | - set -o pipefail - KREL="$(uname -r)" - timeout 600 vng --run "/boot/vmlinuz-$KREL" -- env HOME="$HOME" PATH="$HOME/.cargo/bin:$PATH" \ - ORING_REQUIRE_KERNEL=1 ORING_LIVE=1 \ - cargo test --locked --test containment_live 2>&1 | tee live.log | tail -60 - grep -qE 'test result: ok' live.log || { echo "FAIL: live suite not ok"; exit 1; } - - # STAGED (on-demand: workflow_dispatch only). Builds a mainline 7.1 kernel - # (Landlock ABI V9) and runs the suite on it — the highest ABI, exercising the - # scope/abstract-socket cells at V9. The pathname-AF_UNIX cells still skip - # without `--features resolve-unix` + the out-of-tree fork (the published crate - # can't build V9), which is why this is not per-push: ~20min kernel build. - confinement-v9: - if: github.event_name == 'workflow_dispatch' - runs-on: ubuntu-24.04 - timeout-minutes: 75 - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - run: rustup show - - name: Enable KVM on the free runner - run: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ - | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - ls -la /dev/kvm - - name: Install virtme-ng + kernel build deps - run: | - sudo apt-get update -qq - sudo apt-get install -y qemu-system-x86 build-essential flex bison bc \ - libelf-dev libssl-dev dwarves cpio >/dev/null - pipx install virtme-ng - vng --version - - name: Build test binaries (host) - run: cargo test --no-run --locked 2>&1 | tail -3 - - name: Build the V9 kernel (mainline v7.1) - run: | - set -o pipefail - git clone --depth 1 -b v7.1 https://github.com/torvalds/linux kern - printf 'CONFIG_SECURITY=y\nCONFIG_SECURITY_LANDLOCK=y\nCONFIG_LSM="landlock,yama,bpf"\nCONFIG_NAMESPACES=y\nCONFIG_USER_NS=y\nCONFIG_NET_NS=y\nCONFIG_UTS_NS=y\nCONFIG_IPC_NS=y\nCONFIG_PID_NS=y\n' > landlock.cfg - ( cd kern && timeout 3000 vng --build --config ../landlock.cfg --verbose 2>&1 | tail -20 ) - - name: Confinement suite on V9 (skip => fail) - run: | - set -o pipefail - run_in_vng() { ( cd kern && timeout 600 vng -- env HOME="$HOME" PATH="$HOME/.cargo/bin:$PATH" "$@" ); } - echo "== guest Landlock ABI (expect V9) ==" - run_in_vng sh -c 'echo "uname=$(uname -r)"; echo "lsm=$(cat /sys/kernel/security/lsm)"; python3 -c "import ctypes; print(\"GUEST_LANDLOCK_ABI=\"+str(ctypes.CDLL(None).syscall(444,0,0,1)))"' 2>&1 \ - | grep -E 'uname=|lsm=|GUEST_LANDLOCK_ABI=' | tee guest.info - GABI="$(sed -n 's/.*GUEST_LANDLOCK_ABI=\([0-9]*\).*/\1/p' guest.info | tail -1)" - [ "${GABI:-0}" -ge 9 ] || { echo "FAIL: guest Landlock ABI ${GABI:-unknown} < V9"; exit 1; } - run_in_vng ORING_REQUIRE_KERNEL=1 cargo test --locked 2>&1 | tee vng.log | tail -100 - grep -qE 'test result: ok' vng.log || { echo "FAIL: suite not ok"; exit 1; } diff --git a/.gitignore b/.gitignore index ef4a2c4..ef66ebb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,8 @@ target/ *.sha256 *.swp .cargo/config.toml -docs-internal/ +# docs-internal/ REMOVED 2026-07-29 and deliberately NOT ignored any more. +# It held 75 review documents / 22,008 lines — untracked, inside the product repo, +# grading claims that ship. Corrections there were unrecoverable. It is now evidence +# in the maintainer's research corpus, tracked and gated. If a `docs-internal/` +# reappears here, it should show up in `git status` and be questioned, not hidden. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4762a26..3a8a34c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,198 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +Nothing yet. + +## [0.2.0] - 2026-08-02 + +### Security + +- **A decoyed `$HOME` could silence the `shared_cache_write=` disclosure** (T25). + The field read `$HOME` alone, so `HOME=/tmp/decoy` — or `HOME` unset — reported + `none` over a policy granting write on the real `~/.cargo/registry`. It now + takes the union of the passwd home and `$HOME`. Confinement was never affected; + this was the operator's view of it. `docs/RESIDUALS.md`. + +- **Caller bytes reached the terminal unescaped at 13 of ~14 diagnostic sites** + (T26). A forged `SEALED` line was injectable through argv alone — + `ESC[2K\r` erases the real `REFUSE` and prints a plausible seal line. + `crate::diag::safe` now escapes at the `OringError::Display` chokepoint and at + the warning sites. The seal line remains forgeable by the **child**, which + shares stderr; what closed is forgery by the caller. + +- **`$TMPDIR` is no longer read from the environment.** A caller-supplied value + was pushed into a writable tier, and `check_profile_grants` did not catch a + path one component below `$HOME`: `TMPDIR=$HOME/.ssh --profile balanced` sealed + and the child got `O_WRONLY` on `authorized_keys`. Use `--tmpdir` or + `--exec-tmp`. + +- **Tier overlap is refused.** Landlock ORs an ancestor's rule with a + descendant's, so `read_exec ∪ write_noexec = write_exec` — a tier neither + declaration names. `--workdir /tmp` under `balanced` upgraded all of shared + `/tmp` to write+exec while `grants=` reported success. + +- **`socket()`/`socketpair()` are constrained by `type`, not just family and + protocol.** `SOCK_SEQPACKET` reached SCTP's slot. `socketpair` was worse: it + carried only the family gate, and `sock_create()` calls `request_module()` + before checking `->socketpair`, so a sealed child could make modprobe load + `sctp.ko` on its behalf. + +- **`--workdir` refuses a symlink**, matching `--exec-tmp`. The old + `metadata()` check followed links, so a link to a `$PATH` directory granted a + writable tier over it while `--explain` printed only the link. + +### Added + +- **No `CLONE_NEW*` combination is allowed by the emitted filter** — proved over + all argument values by z3 (`tests/seccomp_smt.rs`), per flag and in + combination, with a control asserting `CLONE_NEWTIME` on `clone` *is* allowed + so the query cannot pass vacuously. Only `CLONE_NEWUSER` is attributable at run + time — unprivileged the others return `EPERM` bare — so a dropped bit or a + combined mask would have left the whole suite green. +- **`setns` has a cell** (T14): `EBADF` bare, `EPERM` sealed. It is the one + namespace-family syscall whose denial is directly attributable without a user + namespace. + +- **`NO_NEW_PRIVS` is measured** (`tests/nnp.rs`, T15) — bare `0/0`, sealed + `1/2`, one variable. A headline guarantee that had no test. The seal line's + `nnp=` literal is checked against the kernel for the same run. + +- **The FD scrub is pinned at both ends** (`tests/fd_scrub.rs`, T10), at the + boundary: fd 2 is the highest preserved, fd 3 the lowest scrubbed, because an + off-by-one in `close_range_cloexec(3)` is the mutation the code can suffer and + cells at 0 and 7 stay green through it. + +- **Three documented controls are demonstrated** (`tests/control_efficacy.rs`, + `tests/containment_matrix.rs`, CON-7): the workdir-review recipe, + `oring --explain`, and `--proc self`. Each carries a vacuity arm. + `tests/design_residuals.rs` now fails when a residual names a control and cites + no fixture. + +- **The containment matrix attributes its own cells** (T21). Every denial cell + runs bare first; one whose bare arm satisfies the sealed expectation is + reported `UNATTRIBUTABLE`, not `PASS`, and must be declared. **21 of 21 are + attributed** and the declared list is empty. + +- **No shipped profile grants access to a host credential store** (T16). An + ∀-over-profiles invariant over `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.netrc`, + `~/.kube`, `~/.docker/config.json`, `~/.config/gh` and the shell histories, + across all four tiers, ancestry both ways. + +- **`egress_udp` / `ingress_udp`** — Landlock UDP rights at ABI V10. Grant port + `0` in `ingress_udp` to permit kernel autobind, or a non-empty `egress_udp` is + inert; oring warns on that shape. Unset means unrestricted, not denied. + +- **`unhandled=` on the seal line**, plus a warning when a class is only + partially restricted. Derived by set difference, so a right oring does not yet + plumb reports itself. Not suppressible by `ORING_QUIET`. + +- **`errata=` on the seal line** — which declared Landlock fixes the running + kernel carries. A **lower bound**, not a certificate: behaviour changes have + shipped with no erratum. `?` when the kernel does not answer, which stays + distinguishable from `0x0`. + +- **`--tmpdir `** — the `write_noexec` sibling of `--exec-tmp`, and the + replacement for reading `$TMPDIR`. oring sets the child's `TMPDIR` to what it + granted, or unsets it. + +### Changed + +- **BREAKING — hard Landlock floor raised from V7 (kernel 6.15) to V10 (kernel + 7.2).** oring refuses on any kernel below 7.2, with no degraded path. V10 is in + the floor for the UDP rights: below it Landlock cannot restrict UDP at all, so + DNS egress could only be disclosed, never gated. V9's `RESOLVE_UNIX` is in it + because Landlock has no hook on `connect()` to a pathname AF_UNIX socket below + V9, so no filesystem tier can gate `docker.sock` or ssh-agent. `docs/DESIGN.md` + §4, D-4b. + +- **BREAKING — UDP is restricted by default in both built-in profiles.** + `balanced` grants outbound `:53` only (with `ingress_udp: [0]`); `strict` + grants none, so **names do not resolve under `strict`**. Both now report + `unhandled=-` and pass `--require-full-coverage`. Not a closure of the DNS + residual: Landlock net is port-only, so `:53` still reaches any address. + +- **BREAKING — no built-in profile grants write on a shared package cache.** + `~/.cargo/{registry,git,.package-cache}`, `~/go/pkg`, `~/.npm` and `~/.cache` + are no longer writable. Cargo verifies a tarball once at download and never + again, so a payload planted in a cached `build.rs` executes at the operator's + uid in an unrelated project. **What breaks:** fetching an uncached dependency — + loudly, naming the crate. Pre-warm outside the cage, or grant one deliberately + with `--policy`. + +- **BREAKING — `--features resolve-unix` is required**, not optional; a build + without it stops at a `compile_error!`. `cargo install oring` still does not + build, now because the floor needs V10, which no release carries. + +- **Ring 1 is no longer vendored.** `third_party/rust-landlock` is deleted; + `[patch.crates-io]` points at `eslerm/rust-landlock` pinned by **revision**, so + the input cannot move under a build. Verified against the deleted tree: one + file differs, by eleven lines, all doc comments. RustSec still cannot scan it — + `[advisories]` is keyed to registry packages — and `deny.toml` allows the + source explicitly. **Cost:** a fresh clone now needs the network. + +- **History rewritten before this release.** Every commit predating `v0.2.0` has + a new SHA: messages carrying absolute home directories and machine names were + rewritten, because a commit message publishes and cannot be unpublished. Only + messages changed — tree hashes and commit counts were verified identical before + the backups were deleted. `make hooks` installs a guard; `make check-history` + sweeps every ref. + +### Removed + +- The `RESOLVE_UNIX` opt-in posture and its per-seal residual warning — the + condition it reported is now a refusal. `warn_on_sockets` is retained as a + tripwire against a future floor lowering. + +- The pathname-AF_UNIX residual and the `unix-connect-*` cells' build gating: + those are now unconditional proof cells. + +### Fixed + +- **The x32 guard's test coverage could not see the guard break.** The guard is + one BPF instruction; mutating it killed x32 `getpid`, allowed every other x32 + syscall, and left the suite green — every cell probed one syscall number. Fixed + at the model level: `x32_guard_matches_its_spec_over_a_dense_table` asserts the + emitted program equals its specification over 12,354 points, killing all 89 + enumerated mutants. Adding process cells at more numbers cannot close the class. + `docs/VERIFICATION.md`. + +- **The `NO_NEW_PRIVS` invariant was described as double and has three legs, one + dead.** `seccompiler` sets `PR_SET_NO_NEW_PRIVS` itself before installing the + filter, so the documented `EACCES`-without-NNP leg can never fire through + oring, and `preexec::set_no_new_privs()` is defence-in-depth rather than the + sole mechanism. Recorded so it is not deleted on the strength of a green run. + +- **git was unusable in-cage for anyone whose config lives in + `$XDG_CONFIG_HOME`.** `~/.config/git/{config,ignore,attributes}` are now + granted `read_only`. No new exposure — `~/.gitconfig` was already granted and + it is the same content. It failed *fatally* rather than degrading because + Landlock hooks the open and not the metadata check, a combination DAC cannot + produce; that class is a residual and is not closeable here. + +- **`--profile balanced` could not resolve DNS on a systemd host.** + `/etc/resolv.conf` is a symlink into `/run` and Landlock rules the resolved + inode, so the `/etc` grant never reached it — and glibc then fell back to + `127.0.0.1:53`, silently resolving through a resolver the operator never chose. + Fixed by granting the literal path. + +- **The abi-ahead warning could be switched off by improving the crate.** It + keyed on whether the crate could *name* a right rather than on whether anything + was unrestricted, so bumping to V10 silenced it while UDP stayed open. Now + derived from the handled set. + +- **Peer `/proc//environ` is walled for an out-of-domain peer** (ATTACKS row + 12) — and **not** for a sibling: `domain_scope_le()` is ancestor-or-equal, so + two children of one seal read each other's `environ`. The denier consults no + handled right and predates the 5.13 merge, so no floor bought it. Measured, not + assumed. `maps`/`mem` have no cell yet; peer metadata remains a residual. + +- Scratch-directory validation is split from creation, so `--explain` rejects a + plan the real run would reject instead of previewing it as sound. + +[rust-landlock#135]: https://github.com/landlock-lsm/rust-landlock/pull/135 + ## [0.1.0] - 2026-07-24 ### Added @@ -43,3 +235,4 @@ target is never executed), `unsafe` isolated to one audited module, and panic-fr on the production path. BSD-3-Clause. [0.1.0]: https://github.com/hexproofdev/oring/releases/tag/v0.1.0 +[0.2.0]: https://github.com/hexproofdev/oring/releases/tag/v0.2.0 diff --git a/CLAUDE.md b/CLAUDE.md index af3ce5f..257ed20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,8 +6,32 @@ denylist to *itself*, sets `NO_NEW_PRIVS`, scrubs FDs and signal state, then `execve`s the target — which inherits every restriction irrevocably and can never widen it. x86_64 Linux only, enforced by a `compile_error!` (the seccomp arch guard is x86_64-specific). Static-musl, no root, no daemon. -Read `docs/DESIGN.md` before changing anything: it carries the threat model, the -two rings, the seal order, and the residuals oring cannot close. v1. +v1. + +## What to read before changing something + +`docs/DESIGN.md` used to carry all of this and this line used to say "read it +before changing anything" — which meant loading ~34k tokens to change one line. +It was split on 2026-08-01 by what the reader came to do. **Section numbers did +not change**, so every `DESIGN §n` citation still resolves, and +`docs/DESIGN.md` carries the table that says which file holds which section. + +- **Always:** `docs/DESIGN.md` — architecture, the `§n` index, deployment, + packaging, status. Small enough to keep in context. +- **Touching the seal, either ring, or the pre-exec order:** + `docs/INTERNALS.md` (§4 Landlock, §5 seccomp, §6 the seal order). **§6 first.** +- **Touching policy fields, profiles, or the CLI:** `docs/REFERENCE.md` + (§7 schema, §8 flags). +- **Making or changing a claim about what oring protects against:** + `docs/RESIDUALS.md` (§2 — the threat model, the guarantees, every residual with + its loss and disclosure tag, the non-goals). Gated by + `tests/design_residuals.rs`. +- **Wondering why something is the way it is:** `docs/RATIONALE.md` (§14, `D-n`). + Meet the argument, not the absence. +- **Correcting a published claim:** the current state goes in the document, the + account of what was wrong goes in `docs/CORRECTIONS.md` as a dated `C-n`, and + the document cites it as `[corrections: C-n]`. Do not re-litigate in place — + that is what made §2 a third of the design document. ## The discipline @@ -41,30 +65,70 @@ two rings, the seal order, and the residuals oring cannot close. v1. `expect`, `panic`, `unreachable`, `todo`, `indexing_slicing`, `string_slice`, and `arithmetic_side_effects`. No panicking sites on the seal path; tests may unwrap freely. -- Hard kernel floor: Landlock **V7 / 6.15**. Below it oring refuses rather than - run a partial cage — there is no degraded path. ABI V9 (`RESOLVE_UNIX`, which - walls `connect()` to a pathname AF_UNIX socket) is *opt-in* hardening behind - `--features resolve-unix` + an out-of-tree V9 fork (rust-landlock#135); without - it that one residual is unmediated and reported loudly at seal. +- Hard kernel floor: Landlock **V10 / 7.2** (`FLOOR_ABI`, `src/landlock.rs`). + Below it oring refuses rather than run a partial cage — there is no degraded + path. Two rights are why the floor sits where it does. `RESOLVE_UNIX` (V9) walls + `connect()` to a pathname AF_UNIX socket (docker.sock / ssh-agent): Landlock has + **no hook on that connect below V9**, so beneath it no FS tier can gate the class + — withholding a `/tmp` grant only obscures the socket [D-4b]. The UDP net rights + (V10) are what let the profiles restrict DNS instead of disclosing it as + unhandled. Through v0.1 `RESOLVE_UNIX` was an opt-in feature with a loud residual + warning; that was the degraded-path-with-a-warning pattern [D-4] rejects + everywhere else, so v0.2 folded it into the floor and the V10 raise followed the + same argument. **The ABI is a kernel property** — a V10-capable crate on a 6.15 + kernel still detects V7 and refuses. Price, stated plainly: every pre-7.2 kernel + is refused, and until upstream ships these rights in a release the crate must + come from the out-of-tree fork, so `cargo install oring` does not build. - The child's environment is an allowlist, built fresh — never scrubbed (`BASE_ENV_KEEP` in `src/main.rs`). Credentials are withheld by construction, not by enumerating what to drop. -- Dependencies stay minimal and musl-clean: `landlock` (crates.io, tops at V7), - `seccompiler` (Firecracker's pure-Rust BPF emitter), `libc`, `serde`. The - checked-in build is a clean registry dependency that RustSec / `cargo deny` - cover; the shipped binary embeds an auditable SBOM (`cargo audit bin oring`). +- Dependencies stay minimal and musl-clean: `landlock`, `seccompiler` + (Firecracker's pure-Rust BPF emitter), `libc`, `serde`. The clean-registry- + dependency guarantee (RustSec / `cargo deny` coverage) is **suspended for + `landlock` only**, and knowingly: the floor needs the **V10 UDP rights**, which + no published release names, so `landlock` comes from the out-of-tree fork until + upstream releases them — at which point bump the version, delete the + `resolve-unix` feature and its `compile_error!`, and the guarantee is restored + with no behaviour change. **`AccessFs::ResolveUnix` (V9) is no longer part of + that reason:** upstream released it in v0.4.7, which is the release vendored in + the pinned fork. This line said "the published crate (V8 as of 2026-07) cannot + name" both rights until 2026-08-01; it was true when written, and citing V9 as + a cause of the suspension is now wrong in a way that would license deleting the + fork the day someone re-checked it. The shipped binary still embeds an + auditable SBOM (`cargo audit bin oring`). + + Same reason, harder consequence: **oring cannot be published to crates.io** as + it stands. crates.io rejects git dependencies, and cargo drops + `[patch.crates-io]` on publish, so the packaged tree compiles against the + registry `landlock` and fails — measured 2026-08-01, `cargo package` stops with + 5 errors on the absent `AccessNet::{BindUdp,ConnectSendUdp}`. "`cargo install + oring` does not build" is the weaker half of this and was the only half stated. ## Standing rejections Each was argued down in `docs/DESIGN.md` — meet the argument, not the absence. Sub-floor degraded ABI paths (`--allow-net-degrade`, the scope/audit skips): gone -— below V7 oring refuses with one clear message [D-4]. `deny_extra`/`allow_extra` +— below the floor oring refuses with one clear message [D-4, D-4b]. `deny_extra`/`allow_extra` config fields: cut from v1, because validating syscall names would drag a name→number arch table onto the sole config-parse path [D-19]. A bundled credential broker: wrong shape — a daemon-free, unprivileged, self-sealing binary cannot hold a key on a child's behalf; that needs root, a persistent process, or a netns. oring does blast-radius reduction, not credential brokering. Sealing a -multithreaded process: refused — `restrict_self`/seccomp are per-thread and v1 -carries no TSYNC. Config fuzzing: deferred — the policy parser sits on a -parent/config trust boundary, not an attacker-controlled path, and `panic=abort` -makes any parse bug fail closed. +multithreaded process: refused **as a choice, not a constraint** — both rings can +now do it (`LANDLOCK_RESTRICT_SELF_TSYNC` at ABI V10; `seccompiler`'s +`apply_filter_all_threads`, present in the pinned 0.5), so the old "they are +per-thread and v1 carries no TSYNC" argument is dead. What stands is that a +single-threaded sealer is simpler, the CLI has no threads, and the +`/proc/self/task` guard fails closed for the embedder path. The open question is +whether TSYNC sealing would be *safe*: Landlock's flag overrides sibling threads' +configuration irrespective of previously established domains, which is stronger +than anything oring does elsewhere. Config fuzzing: deferred on **cost**, and the +label that used to license it is retracted — this line read "not an +attacker-controlled path" until 2026-07-30, which `docs/DESIGN.md` §12 corrected on +2026-07-29 and this second copy did not follow: `ORING_POLICY` is ambient, `main.rs` +refuses `--profile` beside it *precisely because* an untrusted earlier step can set it, +and policy-derived bytes reached 13 of ~14 diagnostic sites unsanitised until +2026-08-02, when `crate::diag::safe` and the `OringError::Display` chokepoint +closed it (T26). `panic=abort` +still makes a parse bug fail closed — on `[profile.release]` only; debug and test +builds unwind. Trust boundaries have one home: `THREAT_MODEL.md` §1. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..0fc9892 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,136 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the maintainer at **security@hexproof.dev**. All complaints will be +reviewed and investigated promptly and fairly. + + + + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +**What to expect.** oring is maintained by one person. A report is acknowledged +within 7 days. Where a report concerns the maintainer, say so and it will be +referred to a neutral third party before any decision is taken — a +single-maintainer project cannot investigate itself, and pretending otherwise +would be worse than saying so here. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. Violating +these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. Violating these +terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. + +[homepage]: https://www.contributor-covenant.org +[Mozilla CoC]: https://github.com/mozilla/inclusion diff --git a/CONSTRAINTS.md b/CONSTRAINTS.md new file mode 100644 index 0000000..4bc98db --- /dev/null +++ b/CONSTRAINTS.md @@ -0,0 +1,265 @@ + +# CONSTRAINTS — what oring must enforce, and what enforces it + +**Living document, gated in-tree.** `cargo test --test constraint_register` checks every +row mechanically, so it runs in `make check` with no workflow change. It also +runs in CI, which checks the half a sub-floor runner can — this register is +prose and needs no kernel. `docs/DESIGN.md` §2 derives the loss structure; this is what you read +to find out which of it is actually held up by an artifact. + +**This file names defects that are not fixed.** That is the point of it — a register +whose `gaps` fields are empty is a register nobody is using. Read `status` and `gaps` +before you read any guarantee elsewhere in this repository. + +## Read this before trusting any row + +**Ids are `CON-n`, and that is not oring's `D-n`.** `docs/DESIGN.md` carries a decision +table of 28 `D-n` rows; those are design decisions, this is a constraint register, and +the two namespaces are unrelated. They were `C1`–`C4` for three hours on 2026-07-28 +until that collided with an attack taxonomy's `cat-C1`–`cat-C6` in the maintainer's +research corpus, which is the whole reason the prefix is long and boring. + +**Why a constraint register and not a risk register.** A risk register needs a loss +magnitude and a probability. oring has neither: no loss here is quantified in units +anyone cares about, and a *design* defect has no failure rate — the `--proc none` +symlink case fires 100% of the time given a symlink. Registers of that shape also grow +monotonically and never retire a row. A constraint is a testable statement with an +artifact or an admitted gap, so this one converges: rows close, and the retirement rule +at the bottom requires that they be deleted when they do. + +## The two columns that are usually one + +**ENFORCED BY** and **OBSERVABLE VIA** answer different questions, and conflating them +is a mistake this project has paid for more than once. A constraint can be *enforced +and invisible* (a withheld path still reaches the child on fd 0, with a seal line +byte-identical to the run that denied it), or *reported and false* (`shared_cache_write=none` +printed by a function that could not know). *Enforced* is a property of the system's +behaviour; *observable* is a property of what the operator can see. **A controller can +only act on what it observes**, so a row with an enforcement artifact and no instrument +is not finished. + +## Status vocabulary — a closed set, checked + +| status | meaning | +|---|---| +| `enforced` | an artifact enforces it **and fails when the mechanism is removed** | +| `enforced-unpinned` | the system enforces it, but nothing fails if that changes | +| `unenforced` | no artifact; the constraint is a statement of intent | +| `unenforced-by-decision` | deliberately not enforced; the row records the decision | + +`enforced` is a claim about mutation, not about a green suite. Where a row claims it, +`last-verified` says what was mutated and what turned red. + +## Derivation — the gate checks BOTH directions against this list + +The first version of this register walked only the loss level, and its gate could not +see that: the gate enumerated rows and asked whether each named a loss, so **a loss +with no constraint was invisible by construction** and the check returned clean. It now +reads this section too, and fails if any entry below is named by no row. + +### Losses +- `BREACH` — the confined child obtains a capability the policy did not grant +- `LAUNDER` — a bounded hazard is made broader or longer-lived +- `DIVERGE` — the confined tool produces a wrong result the operator relies on + +### Hazards +- `HZ-REACHABLE` — an ungranted capability is reachable +- `HZ-DIVERGENCE` — confined and unconfined behaviour differ under one configuration +- `HZ-INSTRUCTED` — the operator is instructed into the hazard + +### Feedback defects (multipliers, not ranks — `MISREPORT` is worse than `OPAQUE`) +- `MISREPORT` — oring's output asserts a state a same-run measurement contradicts +- `OPAQUE` — a failure names neither the withheld resource nor the sandbox + +A feedback defect multiplies a loss rather than ranking beside one: it is what stops the +operator from noticing the loss happened. It is listed here because the gate resolves +loss names in `docs/DESIGN.md` §2 residual tags against this section, and residuals do +name them. + +--- + +## CON-1 — Every capability the child obtains is named by the policy, or is named on the seal line as unrestricted + +- **derives-from:** `BREACH` +- **controller:** oring +- **enforced-by:** `tests/containment_matrix.rs`, `tests/deny_fs.rs`, `tests/deny_net.rs`, `tests/deny_syscall.rs`, `tests/deny_strict.rs`, `tests/protocol_wall.rs`, `src/seccomp.rs` (`x32_guard_matches_its_spec_over_a_dense_table` — 12,354 points, kills 89 of 89 enumerated mutants), `src/landlock.rs` (`tier_invariants`) +- **observable-via:** `oring --denials `, and the `SEALED` line's `grants=` / `unhandled=` — but note both instruments are **inside CON-5's scope**, so their integrity is that row's problem and not this one's +- **status:** `enforced` +- **last-verified:** 2026-07-29 — 140 pass under `cargo test --locked --features resolve-unix` (27 suites, 0 failed, 2 ignored), plus 3 in `seccomp_smt` under `bpf-dump` with z3 5.0.0. Identical count with and without `ORING_REQUIRE_KERNEL=1` on this host (7.2.0-rc4, at the V10 floor), so **nothing skipped here — which is exactly why this number is a weak instrument on a sub-floor host.** Note also that **6 of the 140 are the two doc gates added alongside this register and exercise none of oring's behaviour**: a total that mixes confinement cells with document checks is a worse signal than either alone, and it went up by 6 on a day no confinement code changed +- **gaps:** **Missing — invocation-conditional coverage.** `tests/containment_matrix.rs` early-returns unless `ORING_REQUIRE_KERNEL=1`; measured, a bare `cargo test` on a sub-floor host reports green in 0.00s **having run 0 of 31 cells**. `make check` sets the variable; CI cannot, because no hosted runner is at the floor. + + **Missing — composition.** One pair is exercised (`tcp-wall-both-rings`, with a reachability control), and it is **not proven to catch anything the per-mechanism cells miss**: dropping `SYS_sendto` fails this cell *and* `msg_fastopen_is_blocked`, and no mutant isolating the conjunction was found. All three known composition instances are CON-1 violations no per-mechanism cell can reach. + + **Not evidence — `tests/deny_x32.rs`.** Its own header forbids citing it as proof: a measured mutant leaked six x32 syscalls while every process cell stayed green. The model-level check is what covers that class. + + **Closed since this row was written**, listed because the row previously carried the argument and a reader should not re-open it: the in-domain `--proc self` pair (T19), the FD scrub at both ends (T10), `NO_NEW_PRIVS` (T15), and per-cell attribution against a bare control (T21). The closure narratives are in `CHANGELOG.md`. + +- **would-change:** whether the first tagged release ships + +## CON-2 — No run leaves the system with a broader or longer-lived exposure than it found + +- **derives-from:** `LAUNDER` +- **controller:** oring +- **enforced-by:** `src/profile.rs` (`no_profile_grants_write_on_a_shared_package_cache`), `src/policy.rs` (`every_named_shared_cache_is_detected_individually`), `tests/self_protection.rs` +- **observable-via:** the `SEALED` line's `shared_cache_write=` — **unreliable, see gaps** +- **status:** `enforced` +- **last-verified:** 2026-07-28 — mutation-measured, which is the only evidence that supports this status: adding `write_noexec.push(h.join(".cargo/registry"))` back into `balanced()` turns `no_profile_grants_write_on_a_shared_package_cache` **red**. **Re-measured 2026-07-29 and it still turns red**, but the recipe changed and is now TWO lines in TWO places: `write_noexec` is no longer bound `mut` (that keyword was residue from the grant removal and had been leaving a warning in the build), so the mutation is (1) restore the `mut`, and (2) push the grant **inside the `if let Some(h) = home()` block**, not at the binding — `h` is not in scope there. Stated because the first attempt put it at the binding, **did not compile, and produced no failing test** — and a mutation that fails to build looks exactly like one the suite caught if you only check for a red run +- **gaps:** **This row read `enforced-unpinned` for a day, on the written claim that "nothing fails if a grant is added back". That claim was false when written** — the pinning test had landed the day before. Wrong-when-written, not stale, and recorded here because that is the distinction this project applies to everyone else's claims. What survives: the tiers that test does *not* cover are still unpinned, and there is no property asserting that the shipped profiles leave `~/.ssh` out of **every** tier — a convenience grant added to `balanced()` would keep the whole suite green while falsifying examples in `docs/DESIGN.md` §2. **The disclosure half is CON-5's:** `shared_cache_write=` reports `none` in cases where the function could not know, and a decoyed `HOME` defeats it outright. +- **would-change:** whether the shared-cache decision is closed in the artifact rather than in the design + +## CON-3 — The confined tool's effect equals its unconfined effect under the same configuration, or the difference is reported + +- **derives-from:** `DIVERGE` +- **controller:** oring +- **enforced-by:** *(gap — the constraint is still unenforced, but the COUNTEREXAMPLE is now pinned)* `tests/residual_boundary.rs` (`access_says_readable_where_open_is_denied`) measures the canonical `DIVERGE` instance with three arms and one variable each: unconfined both calls succeed; confined with the path withheld **`access(R_OK)` reports it readable while `open()` is denied**; confined with the path granted, neither diverges. `tests/signal_dispositions.rs` carries a genuine bare arm but is **disposition-valued, not errno-valued**; `tests/agent_survival.rs` is one-sided. `tests/agent_survival.rs` (`the_confined_result_equals_the_unconfined_result`) is the **positive arm**: identical inputs, `git write-tree` confined under `balanced` and bare, and the content-addressed hashes must match. `write-tree` is the observable precisely because it folds in only names, modes and blob contents — a commit hash carries author and two timestamps and would differ for reasons unrelated to confinement. +- **observable-via:** *(gap)* no `env=` token on the `SEALED` line, and no environment section in `--explain` +- **status:** `unenforced` +- **last-verified:** 2026-07-29 — measured, not read. The divergence: unconfined `0`, confined-withheld `10` (access OK / open DENIED), confined-granted `0`. Cause verified against **this kernel's own** `security/landlock/fs.c`: `file_open` registered, `inode_permission` **0**, `inode_getattr` **0**, so there is no hook an ABI bump could add a right to. Also `command grep` over `tests/*.rs`: the two bare arms in-tree (`protocol_wall::run_payload_bare`, `socket_bare`) both return `i32` +- **gaps:** **Not closeable at this layer.** oring cannot know which paths a tool will pre-flight, so it cannot report a divergence per run. Making a path read as *absent* is a mount-namespace capability; Landlock's only vocabulary is deny. + + **The status vocabulary has no word for this row.** There IS an artifact, but it pins the **counterexample**, not the constraint: `unenforced` says *no artifact*, which is false, and `enforced` would also be false. Kept at `unenforced` because the constraint does not hold, rather than inventing a fifth status for one row. + + **Both arms exist and they disagree, which is the honest state.** The positive arm passes over a bounded domain — one tool, one operation, one profile — while the pinned counterexample is real and unreported. What the pin buys is that the divergence can no longer change *silently*: if a kernel closes it, the test fails and the residual must be rewritten. + +- **would-change:** whether oring may claim effect-equivalence at all + +## CON-5 — Every difference between the confinement the policy names and the confinement actually installed is refused before `execve`, or disclosed on stderr in a form neither the child nor `ORING_QUIET` can suppress + +- **derives-from:** `MISREPORT` +- **controller:** oring +- **enforced-by:** `tests/udp_wall.rs` (`quiet_cannot_suppress_the_unhandled_disclosure` — `ORING_QUIET` arrives from the environment, i.e. from the party the threat model calls the adversary, and it suppresses the status line but **not** the under-coverage report), `src/landlock.rs` (`below_floor_boundary_is_fail_closed`), `src/denials.rs` (`requires_the_audit_transport`), `tests/residual_boundary.rs` (`the_under_coverage_disclosure_is_discardable_by_the_caller` — pins the LIMITATION, not the guarantee), `tests/udp_wall.rs` (`require_full_coverage_refuses_when_a_right_is_unhandled`, `require_full_coverage_seals_when_every_right_is_handled`, `the_coverage_refusal_survives_stderr_being_discarded` — the **refused-before-`execve`** limb, reached by the opt-in `--require-full-coverage`) +- **observable-via:** *(gap — structural, not an omission)* **every instrument oring ships is inside this constraint's own scope.** Any observer that could falsify it must be a second, out-of-band measurement of child state. +- **status:** `enforced-unpinned` +- **last-verified:** 2026-07-29 — source read. The `SEALED` line has **13** substituted fields (`proc=` added this day), and **two** now carry an assertion: `unhandled=`, which compares the printed token to the *request*, and `proc=`, which is **derived from the installed tiers** specifically so it can disagree with `--proc` — measured, a `--policy` granting `/proc/cpuinfo` with no flag reports `custom:1`. `nnp=1` remains a literal +- **gaps:** **Missing — the disclosure channel is suppressible by the caller.** The statement was narrowed on 2026-07-29 because it was false as written: it claimed a channel the run cannot suppress. Measured on `balanced` at V10 — with fd 2 piped the `unhandled=` line is present; with `2>/dev/null` **zero** disclosure lines appear and the seal proceeds. `ORING_QUIET` is refused; a shell redirect is not. + + **Not closeable — there is no second channel.** The write to `/dev/null` succeeds, so no error check inside oring can detect it. `src/denials.rs` is a *consumer* of kernel-emitted records; a producer needs `CAP_AUDIT_WRITE` an unprivileged tool lacks; and `/dev/log` would be grantable only by the policy under audit, because the disclosure is emitted after the seal. + + **Mitigated, opt-in.** `--require-full-coverage` turns any unhandled right into refusal code 120 before `execve` — an exit code is the one signal a redirect cannot remove. It is not the default, so the gap stands for every default run. + +- **would-change:** whether `--check` may be documented as a CI gate; whether a disclose-rather-than-refuse decision may rest on a disclosure that is false as an audit record + +## CON-6 — Every grant in force was authored explicitly, and no ambient source widens or overrides an authored one, or the run refuses to seal + +- **derives-from:** `HZ-REACHABLE` +- **controller:** oring +- **enforced-by:** `tests/self_protection.rs`, `tests/policy.rs`, `tests/policy_props.rs`, `tests/tmpdir_not_from_env.rs`, `tests/exec_tmp.rs` +- **observable-via:** `oring --explain`, and refusal codes 120–122 +- **status:** `enforced` +- **last-verified:** 2026-07-29 — source read across 41 test functions, **none of which enforced a register row before this one existed** +- **gaps:** **The rule is authored vs ambient, not trusted vs hostile.** Authored: `--policy`, `--profile`, `--tmpdir`, `--exec-tmp`. Ambient: `ORING_POLICY`, `$TMPDIR`, `$HOME`. `docs/DESIGN.md` asserted the caller was *trusted* in two places while three shipped guards refused ambient widening — the register stated the rule before the design document did. + + **Any future ambient read is a candidate violation by construction**, which is what makes this row worth more than its test list. Two measured escalation instances: a `~/.cargo/bin` `$PATH` over-grant, and `TMPDIR=$HOME/.ssh --profile balanced` sealing with write on `authorized_keys`. + + **The prediction it made has since been confirmed and closed.** This field named `writable_shared_caches` reading `$HOME` as a candidate; a decoyed `HOME` did defeat the `shared_cache_write=` disclosure, and it was fixed on 2026-08-02 by taking the union of the passwd home and `$HOME` (T25, `tests/decoy_home.rs`). The row is retained because the *class* is open, not the instance. + +- **would-change:** whether any profile may derive a tier from caller-supplied state + +## CON-7 — Every control oring's documentation names for a residual is demonstrated by a fixture that detects that residual and leaves the payload unfired + +- **derives-from:** `HZ-INSTRUCTED` +- **controller:** the operator, whose process model oring's documentation supplies — **the documentation is the control action, not the controller** +- **enforced-by:** *(partial — 3 of 6)* `tests/control_efficacy.rs` demonstrates two: the **workdir-review recipe** against the `core.fsmonitor` poisoning (the retracted `git` review fires the payload and never names it; the documented recipe detects it and leaves it unfired), and **`oring --explain`** against the `access()`/`open()` divergence (names the granted tier, not the withheld path, and executes nothing — checked against a positive control that runs the same command without `--explain`). `tests/containment_matrix.rs` demonstrates the third, **`--proc self`**, via the matched `proc-indomain-env-full` / `-self` pair. Every one carries its own vacuity arm. Red-tested: three mutants for the recipe, three for `--explain`, all confirmed red. **Three controls remain undemonstrated, and two of those cannot be demonstrated here.** +- **observable-via:** greppable, and now gated — `tests/design_residuals.rs::every_named_control_cites_a_fixture_or_is_a_declared_exemption` fails when a residual names a `**Control.**` and cites no fixture unless it is a declared exemption, and **also** fails on a stale exemption, so the list can only shrink. Measured 2026-08-02: 6 residuals name a control, **5 cite a fixture**, 1 is a declared exemption +- **status:** `unenforced` +- **last-verified:** 2026-07-28 — measured: `git status` and `git diff` return **empty output at exit 0** while `core.fsmonitor` fires the planted payload **four times during the review itself**, at the operator's uid, outside the cage +- **gaps:** **Still `unenforced`, and the word is exact: the constraint says EVERY named control, and 3 of 6 are demonstrated.** What changed on 2026-08-01 is that the gap is counted and cannot grow silently. + + **Two of the remaining three are not demonstrable in this tree at all** — a host firewall and a container runtime are companion controls outside oring — so the end state is four fixtures plus two stated exemptions, not six fixtures. Deciding that is `would-change` on this row. + + **The citation gate is the weaker half and knows it.** Before the first fixture landed, 2 of 6 residuals cited a `tests/` path and **both citations were of the residual rather than of the control** — one pins a *limitation*, which is the opposite of demonstrating a mitigation. A field that repeats the request cannot disagree with it, the same weakness this register records about `unhandled=` at CON-5. + + **This row exists because the same sentence was retracted twice in two days** — first "run the first build inside the cage", then "review with `git`", which *is* the execution. A control that fires the hazard it detects is worse than none, because it is performed with confidence. Both accounts are in `docs/CORRECTIONS.md` (C-10, C-11). + +- **would-change:** whether `docs/DESIGN.md` §2 may name a control that cites no fixture + +## CON-8 — The confined tool still completes its legitimate work under the shipped profiles + +- **derives-from:** `HZ-DIVERGENCE` +- **controller:** oring +- **enforced-by:** `tests/agent_survival.rs` (a real `cargo build` and a real `git init` + commit under the shipped profile with a harness-owned `$HOME`), plus roughly 14 still-allowed control cells across `tests/deny_syscall.rs`, `tests/protocol_wall.rs`, `tests/deny_strict.rs`, and — added 2026-07-29 — `name_resolution_survives_under_balanced` and `the_profiles_udp_posture_holds_at_the_socket_layer` +- **observable-via:** *(gap — partial)* the child's exit status, which **collides with oring's refusal codes 120–122**, so that signal alone cannot distinguish a confined tool that failed from a run oring refused +- **status:** `enforced` +- **last-verified:** 2026-07-29 — re-verified against the UDP tightening (see gaps): 171 passed, 0 failed, `make check` rc=0, and the two new cells each shown to go red under mutation. The same caveat as CON-1 applies about what a suite total mixes together +- **gaps:** **Missing — no tightening is gated against this row.** Every tightening of a profile is a candidate violation, and nothing runs the survival suite as a precondition for one. + + **This row is the surviving half of a deleted pair.** `CON-4` asserted a run does not deny service to the *host*; it was retired on 2026-08 as out of scope, because host resource exhaustion is the runtime's job. This half asserts the run still works for the *child*, and nothing else protects the confined tool from oring's own tightening. + + **The fixture has been blind once, measured.** When UDP became a profile default, every existing `agent_survival.rs` cell built its own policy and so could not see it — the suite stayed green through a change that broke name resolution under `strict`. Fixed by adding cells that exercise the shipped profiles; recorded because the same shape will recur with the next default. + +- **would-change:** whether any tightening ships + +## CON-9 — On a substrate that cannot enforce the policy, oring refuses rather than sealing partially + +- **derives-from:** `OPAQUE` +- **controller:** oring +- **enforced-by:** `src/landlock.rs` — `below_floor_boundary_is_fail_closed` pins the boundary including `abi=0` and `abi<0`; `floor_and_abi_maps` asserts `FLOOR_ABI == 10` as a literal and pins the `resolve_unix_active` pairing, so no kernel oring agrees to seal on can run with the AF_UNIX class unmediated — at V10 that wall is guaranteed with a margin rather than by equality +- **observable-via:** refusal code 120, and the `abi=` / `errata=` seal fields +- **status:** `enforced` +- **last-verified:** 2026-07-29 — source read; `RulesetStatus::NotEnforced` and `RulesetStatus::PartiallyEnforced` are both hard refusals +- **gaps:** This is the constraint that makes the V10 floor a **product decision** rather than a build flag, and it is the one a widen-reach pressure would trade first — which is the argument for naming it before that pressure arrives rather than after. `errata=` is a **lower bound, not a certificate**. +- **would-change:** whether the V10 floor may be lowered for reach + +--- + +## The adversary inside the machinery + +`docs/DESIGN.md` §2 carries a second residual subsection — *what could be wrong with +ORING ITSELF* — and **no row above covers its class.** oring is the child: there is no +process boundary between sealer and sealed, the sealing library is vendored out-of-tree +and unaudited, seccompiler's lowering is not re-proven here, and `panic = "abort"` is +`[profile.release]` only, so "a parse bug fails closed" does not hold for a debug or +test build. + +**A constraint for that class arguably cannot have an artifact from inside the seal**, +which is the same structural problem CON-5's `observable-via` states: any check oring +runs on itself is inside its own scope. It is recorded here as a named absence rather +than given a row that no artifact could ever fill. + +## The number worth tracking + +**Fraction enforced: 5 of 8.** + +**It was 5 of 9 an hour earlier and no work landed.** Retiring `CON-4` moved the +denominator and not the numerator — the same property this section already warned about +in the other direction, now demonstrated on itself. Read as a burn-down it would show +progress that did not happen. It describes the register, not the tool. + +The numerator counts `enforced` **only** — CON-1, CON-2, CON-6, CON-8, CON-9. Counting +`enforced-unpinned` as well would make it 6 of 8, because CON-5 is enforced by a +mechanism nothing pins. Both readings are defensible and they differ, so **the gate +recomputes the number from the `status` fields and fails if this line disagrees with +it.** A hand-maintained count is exactly the kind of row this project has already +watched drift four times. + +**It moved from "1 of 4" without a line of oring changing**, and that is a property of +the metric worth stating rather than hiding: **it moves when work lands and when scope +changes.** Three of the five were already true and unnamed; one was misclassified by a +claim that was false when written. A burn-down read off this number across a scope +change would report progress that did not happen. + +The **rate** at which rows move toward `enforced` needs a second dated measurement and +is **absent rather than fabricated**. + +## Retirement rule + +**A row that would change no decision is deleted, not archived.** Every other document +in this repository grows monotonically; this one is required not to. + +**It has now been applied once, which is the only evidence that it is real.** `CON-4` +("a run does not deny service to the host") was **deleted on 2026-07-29** — not +archived, and not left behind as documentation. It derived from a loss called `EXHAUST`, +it had no enforcer and no instrument, and `docs/DESIGN.md` non-goal 6 already gave up +cgroups and rlimits deliberately on the grounds that each *"needs a persistent or +privileged component oring by definition is not"*. **0 of 113 attack rows carried any +resource-exhaustion evidence.** It changed no decision, so it went. + +`EXHAUST` went with it, necessarily: the gate checks the derivation in **both** +directions, so a loss no row claims fails the build. **Measured rather than asserted** — +deleting the row while keeping the loss reports `derivation entries: 9, claimed by a +row: 8, orphaned: 1` and fails with *"Loss \"EXHAUST\" is named by no constraint"*. The +rule could not be half-applied, and that is the strongest argument for checking the +reverse direction at all: keeping the loss would have been the natural half-measure, and +it is now impossible. + +Three losses remain: `BREACH`, `LAUNDER`, `DIVERGE`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8dd7a45 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,96 @@ + +# Contributing to oring + +## The gate + +```sh +make doctor # prerequisites, and what is missing +make check # fmt + clippy + deny + test + the seccomp SMT gate +``` + +**`make check` is the gate, and it is the authority.** CI runs make targets too, +but it runs only the ones a sub-floor runner can honestly check — format, lints, +dependency policy, the static build, the SMT gate, and the fail-closed refusal. +No hosted runner is at oring's Landlock V10 / kernel 7.2 floor, so **CI witnesses +no containment at all**. If you want to know whether the cage holds, run +`make check` on a >=7.2 host; there is no substitute and CI is not one. + +**`make check` fails on a host below the kernel floor**, deliberately. The +containment tests need Landlock ABI V10 (kernel 7.2), and a machine that cannot +witness containment must not report that it did. Use `make test` for the +skip-tolerant variant while developing; the gate is the thing that has to pass. + +Two slower targets, neither on the per-commit path: + +- `make check-full` — the gate with every `ORING_REQUIRE_*` skip made fatal, + plus the live-database advisory checks. +- `make verify` — the Kani proofs (`x32_guard_total`, `abi_from_version_clamp`). + Minutes, and the only gate those two proofs have. + +## What a reviewer checks + +Read [`docs/DESIGN.md`](docs/DESIGN.md) for the map, then the file that covers +what you are touching — [`CLAUDE.md`](CLAUDE.md) routes by subject. Beyond the +gate, a review asks: + +- **Does it stay fail-closed?** Every step in `pipeline::seal()` is HARD (`?`). + Any error before the seal means the target is never executed. No branch skips + a ring; none execs while under-confined. +- **Is the seal order intact?** NNP before seccomp. Landlock before seccomp. FD + scrub after Landlock. A wrong order is a confinement bypass, not a style nit — + `docs/INTERNALS.md` §6 says why each step precedes the next. +- **Is there a runnable check?** A new guarantee needs one: a test, a proof, an + SMT gate, or a witnessed matrix cell. A property stated only in prose is an + open question — say so and open it rather than asserting it. +- **Does the mutation test fail?** For anything claiming `enforced` in + `CONSTRAINTS.md`, remove the mechanism and confirm something turns red. A + green suite is not evidence that a gate can fail. +- **Is `unsafe` still confined to `src/sys.rs`?** It is denied crate-wide. Every + raw op sits in its own `unsafe {}` block with a `SAFETY:` note. +- **Is the production path panic-free?** `unwrap`, `expect`, `panic`, + `indexing_slicing`, `arithmetic_side_effects` and friends are denied outside + `cfg(test)`. Tests may unwrap freely. +- **Is the rationale recorded where the code is?** When you reject an approach, + write down the rejection. Cuts that lose their context get re-proposed. + +## Documentation changes with behaviour changes + +A document changes in the same commit as the behaviour it describes. A document +maintained separately is maintained late, and this project has paid for that +more than once. + +Two rules that are enforced rather than asked for: + +- **Correcting a published claim?** The current state goes in the document; the + account of what was wrong goes in [`docs/CORRECTIONS.md`](docs/CORRECTIONS.md) + as a dated `C-n`, cited from the document as `[corrections: C-n]`. Do not + re-litigate in place. +- **Counted claims are gated.** `tests/citation_handles.rs` and + `tests/design_residuals.rs` check numbers in the documents against the tree. If + you add a residual or a `BUILD-QUEUE.md T` handle, the count in the document + moves with it or the gate fails. + +## Commits + +- One concern per commit. The subject says what changed and, where it is not + obvious, why — `git log --oneline` is a review surface. +- **No host-specific detail in a commit message.** No absolute home directories, + no machine names, no operator layout. Commit messages publish and cannot be + unpublished; paste measurement transcripts with `$HOME` substituted. +- Say what you measured. "Measured 2026-08-01 on 7.2.0-rc4: …" is worth more + than a paragraph of reasoning, and it is what makes the claim checkable later. + +## Reporting a vulnerability + +Not here. [`SECURITY.md`](SECURITY.md) is the route, and a security issue should +not arrive as a public pull request. + +## Licence of contributions + +oring is BSD-3-Clause. **By submitting a contribution you agree that it is +licensed under BSD-3-Clause**, the same terms as the project — inbound equals +outbound. There is no CLA and no copyright assignment; you keep your copyright. + +If you cannot make that grant for a piece of work — because an employer owns it, +or because it derives from differently-licensed code — say so in the pull request +before it is reviewed rather than after. diff --git a/Cargo.lock b/Cargo.lock index 5d4025b..7c6cd6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -137,9 +137,8 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "landlock" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "635839550ae8b90d9fd2571460a6645dc0aec070225956ca7a2831ed31d2795d" +version = "0.4.7" +source = "git+https://github.com/eslerm/rust-landlock?rev=d7bb0c1fac6b58f06be7718fe3fd0be8631356a1#d7bb0c1fac6b58f06be7718fe3fd0be8631356a1" dependencies = [ "enumflags2", "libc", @@ -181,7 +180,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "oring" -version = "0.1.0" +version = "0.2.0" dependencies = [ "insta", "landlock", diff --git a/Cargo.toml b/Cargo.toml index 8d32135..896ea55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oring" -version = "0.1.0" +version = "0.2.0" edition = "2021" rust-version = "1.80" description = "Unprivileged, self-applied, pre-exec confinement wrapper: Landlock + seccomp, then execve" @@ -10,10 +10,19 @@ readme = "README.md" authors = ["Mark Esler "] keywords = ["landlock", "seccomp", "sandbox", "isolation", "rootless"] categories = ["command-line-utilities", "os::linux-apis"] -# The published crate builds against the registry `landlock` (V7). The vendored -# fork + CI/dev scaffolding are not part of that build, so keep them out of the -# .crate (docs-internal/ and spike/ are gitignored and already excluded). -exclude = ["third_party/", ".github/"] +# The published crate builds against the registry `landlock`. CI/dev scaffolding +# is not part of that build, so keep it out of the .crate. (`third_party/` was +# here until 2026-08-02, when ring 1 became a rev-pinned git dependency and the +# vendored copy was deleted.) NOTE 2026-07-29: `docs-internal/` no longer exists here — its 75 review +# documents were evidence, not product, and moved to the maintainer's research corpus +# where they are tracked and gated. They were gitignored in this tree, which meant +# 22,008 lines graded shipped claims from outside version control. +# CLAUDE.md is agent/contributor instructions, not part of the document set a +# stranger is expected to read, and it reached the published .crate until +# 2026-08-01 (measured with `cargo package --list`). It carries no host paths, +# so this is placement rather than disclosure — but a working file does not +# belong in the artifact a consumer receives. +exclude = [".github/", "bench/", "CLAUDE.md"] [[bin]] name = "oring" @@ -31,24 +40,51 @@ name = "bpf-dump" path = "src/bin/bpf_dump.rs" required-features = ["bpf-dump"] +# Analysis-only: the cost harness behind `bench/run.sh` (see `bench/README.md`). +# Off by default, so a shipped build is unaffected. It lives in the package +# rather than beside the scratch fixtures it replaced for ONE reason: `make +# check` runs `cargo clippy --all-targets`, so this compiles on the everyday +# gate and cannot rot as the code it measures moves. It calls +# `seccomp::dump_groups` directly, so what it times is what `apply` installs. +[[bin]] +name = "bench" +path = "src/bin/bench.rs" +required-features = ["bench"] + [features] bpf-dump = [] +# `bench` implies `bpf-dump`: the ring-2 measurements need the emitted programs, +# and a `bench` build without them would compile to subcommands that refuse. +bench = ["bpf-dump"] # `resolve-unix` compiles in the ABI V9 / RESOLVE_UNIX path — the Landlock right # that walls `connect()` to a pathname AF_UNIX socket (docker.sock / ssh-agent). -# It requires a `landlock` crate that names ABI V8/V9 + `AccessFs::ResolveUnix`, -# which the published crate does NOT yet have (it tops out at V7). That binding -# comes from upstream rust-landlock PR #135 (landlock-lsm/rust-landlock#135) or -# an equivalent V9 branch; until it is in a crates.io release, enabling this -# feature needs an out-of-tree path override (see `.cargo/config.toml.example`) -# and so `cargo install oring --features resolve-unix` will NOT build from -# crates.io. WITHOUT this feature (the default, public build) oring runs at its -# V7 floor and pathname-AF_UNIX connect is an UNMEDIATED, loudly-reported -# residual. When upstream ships V9, bump the dep and this feature can default-on. +# +# As of v0.2 this feature is REQUIRED, not optional: a build without it fails +# fast via `compile_error!` in src/landlock.rs rather than shipping a binary that +# refuses at every seal. It stays expressed as a feature only so that failure is +# one legible message instead of a pile of unresolved `ABI::V9` paths. +# +# WHAT CHANGED 2026-07-29, because the old text here was measurably false. It +# said the feature "requires a `landlock` crate that names ABI V8/V9 + +# AccessFs::ResolveUnix, which the published crate does NOT yet have (it tops out +# at V7)". Upstream **released** that in v0.4.7 (rust-landlock#135), so the +# published crate does have it, and the stated reason for the whole arrangement no +# longer holds. +# +# CONSEQUENCE, stated plainly and still true, but for a DIFFERENT reason: +# `cargo install oring` does not build against the registry alone, and ring 1 is +# not a clean registry dep that RustSec / `cargo deny` fully cover. That is now +# the price of the **V10** floor, not of RESOLVE_UNIX — the vendored tree exists +# for the UDP rights (see `[patch.crates-io]`), which no release carries. +# +# OPEN, and deliberately not decided here: with V9 published, this feature no +# longer gates anything a registry build lacks, so it is a candidate for deletion +# in favour of gating on V10 or nothing at all. That is a packaging decision. resolve-unix = [] # docs.rs must build with default features ONLY. `resolve-unix` needs an -# out-of-tree landlock (rust-landlock#135) and cannot compile against the -# published crate, so it must never be enabled on docs.rs. docs.rs already +# out-of-tree landlock (the pinned `abi-v10` fork) and cannot compile against +# the published crate, so it must never be enabled on docs.rs. docs.rs already # defaults to default-features; pinning it here keeps a later `all-features` # from silently breaking the hosted docs build. [package.metadata.docs.rs] @@ -56,12 +92,18 @@ features = [] all-features = false [dependencies] -# Ring 1 — pure Rust, no C, musl-clean. Published crates.io crate: reaches -# Landlock ABI V7 (FS tiers + Refer/Truncate + TCP net + IoctlDev + IPC scope + -# audit) — the full set oring's V7 floor needs. The V9 RESOLVE_UNIX right is an -# opt-in built via the `resolve-unix` feature + a local fork override, so the -# checked-in repo has a clean registry dependency that RustSec / `cargo deny` -# actually cover. +# Ring 1 — pure Rust, no C, musl-clean. The published crates.io crate reaches +# Landlock **ABI V9** as of v0.4.7 (FS tiers + Refer/Truncate + TCP net + +# IoctlDev + IPC scope + audit + RESOLVE_UNIX). oring's floor is **V10**, whose +# UDP rights no release carries, so this requirement is satisfied by the pinned +# fork in `[patch.crates-io]`, NOT by the registry copy. The version +# string stays here so the switch back is a one-line bump the day upstream +# releases V10. Until then this is knowingly not a pure registry dependency. +# +# The caret requirement admits the fork's 0.4.7; it is left at 0.4.5 rather than +# bumped because the version here only has to ADMIT what the patch resolves. This comment previously said the registry crate +# "reaches ABI V7" and that the floor was V9 — both were true when written and +# neither is now. landlock = "0.4.5" # Ring 2 — Firecracker's pure-Rust BPF emitter; MaskedEq; no libseccomp.so link. seccompiler = "0.5" @@ -99,6 +141,55 @@ pedantic = { level = "warn", priority = -1 } [build-dependencies] # build.rs captures the git SHA for the seal attestation line (§11/§12). +# Ring 1 comes from the maintainer's fork, pinned by REVISION. +# +# `rev`, not `branch`, and that is the whole point of the line. A branch is +# mutable: `cargo update` follows it wherever it moves, so the crate that +# installs the cage could change under a build that never asked it to. A rev +# names an immutable object. `Cargo.lock` pins the resolved commit either way +# for `--locked`, but the input identity should be fixed at the source, not only +# in the lockfile. +# +# WHAT THIS REV IS, verified 2026-08-02 against the tree it replaces: upstream +# v0.4.7 (`62fec0f`, released, on crates.io) plus exactly three commits — uapi +# updated to Linux 7.2, ABI V10 handling, and a sandboxer sync. Diffed file by +# file against the sources previously vendored here: ONE file differs, +# `src/net.rs`, by ELEVEN LINES, all of them `///` doc comments. Ring 1's +# behaviour is byte-for-byte what the suite was green against before the switch. +# +# WHY NOT VENDORED ANY MORE. It is the maintainer's own fork and does not belong +# copied inside this repository. The cost, stated because the vendoring comment +# used to give it as the reason to vendor: a fresh clone now needs the network to +# build, where committed sources needed nothing. +# +# WHAT THIS DOES NOT BUY, so nobody switches back expecting it: RustSec coverage +# is NOT restored. `[advisories]` is keyed to registry packages, so a git source +# is no more scannable than the path patch was — see `deny.toml`, which had to +# ALLOW this source for `cargo deny check sources` to pass at all. And +# `cargo publish` is still refused: crates.io rejects git dependencies too. +# +# WHEN UPSTREAM SHIPS V10: delete this section, bump the `landlock` version +# above, drop the `allow-git` entry in `deny.toml`, and registry-dependency +# coverage is restored with no behaviour change. +[patch.crates-io] +landlock = { git = "https://github.com/eslerm/rust-landlock", rev = "d7bb0c1fac6b58f06be7718fe3fd0be8631356a1" } + +# Release strips symbols, so a flame graph of a release binary is hex addresses +# and any conclusion drawn from one is unfounded. This profile is the fixture +# that makes oring's own cost attributable: same optimisation settings as +# release, symbols kept. It changes nothing about the shipped artifact — it is a +# separate profile, selected with `cargo build --profile profiling`. +# +# `debug = 1` (line tables) rather than 2: enough for `perf`/`samply` to resolve +# frames, without the size. Frame pointers are NOT set here because they are a +# rustflag, not a profile key — pass `-C force-frame-pointers=yes` when a +# frame-pointer walker truncates the stacks, and measure that cost rather than +# assuming the usual "<1%". +[profile.profiling] +inherits = "release" +debug = 1 +strip = false + [profile.release] opt-level = "z" lto = true diff --git a/Makefile b/Makefile index 434d573..b2fb376 100644 --- a/Makefile +++ b/Makefile @@ -1,69 +1,458 @@ # SPDX-License-Identifier: BSD-3-Clause # oring — local gate runner: how oring is built, tested, and proven. # -# make same as `make check` -# make doctor check prerequisites (rustc + the musl target) -# make check the gate: fmt + clippy + test (what CI runs) +# make same as `make help` (a bare `make` must not launch a gate) +# make doctor check prerequisites and name what is missing +# make check the gate: fmt + clippy + deny + test + the seccomp SMT gate. +# THE authority on whether the cage holds — CI cannot be, because +# no hosted runner is at the V10/7.2 floor. +# make check-full same gate, plus the live-database advisory checks, with +# EVERY ORING_REQUIRE_* skip made fatal # make build release static-musl binary with an embedded auditable SBOM # make test the test suite (the ring tests need a Landlock kernel) # make verify Kani symbolic proofs (the x32 seccomp guard, the ABI clamp) -# make audit scan the shipped binary's SBOM against RustSec +# make fmt / clippy / deny the ordinary verbs, flags in one place +# make advisories / audit LIVE RustSec database — not in `check` +# make hooks install the tracked git hooks (never automatic) +# make check-history sweep every ref for host detail in commit messages # make clean remove build artifacts # # Tooling, installed once as needed: # rustup target add x86_64-unknown-linux-musl # build +# cargo install cargo-deny --locked # check # cargo install cargo-auditable cargo-audit --locked # build, audit # cargo install --locked kani-verifier && cargo kani setup # verify -# z3 (system) # the seccomp SMT gate in `make test` +# z3 (system) # the seccomp SMT gate in `make check` CARGO := cargo TARGET := x86_64-unknown-linux-musl BIN := target/$(TARGET)/release/oring -.PHONY: all help doctor check build test verify audit clean +# `resolve-unix` is REQUIRED, not optional: the floor is Landlock V10 and +# src/landlock.rs `compile_error!`s without it. It stays a non-default feature +# only so docs.rs can still build against the published landlock crate (V9 as of +# v0.4.7, which carries RESOLVE_UNIX but NOT the V10 UDP rights the floor needs) +# — which means every compiling invocation here must pass it explicitly. The V10 +# crate itself arrives via `[patch.crates-io]` — a rev-pinned git dependency on +# the maintainer's fork, no longer vendored in-tree. +FEATURES := resolve-unix + +# The feature set for any cargo invocation that builds EVERY target in the +# package — `clippy --all-targets` and `cargo kani` both do. `bpf-dump` and +# `bench` are `required-features` binaries, and cargo does not skip them: it +# exits 101 with "target `` … requires the features: ``" BEFORE +# doing any work. +# +# THIS IS ONE VARIABLE BECAUSE THE SAME DEFECT LANDED TWICE. `make verify` +# passed `$(FEATURES),bpf-dump` inline; adding the `bench` binary broke it, and +# it stayed broken — exit 2, both Kani proofs re-checked by NOTHING — while +# `make check` stayed green, because clippy's copy of the list had been updated +# and verify's had not. Kani appears in no CI workflow, so that target is the +# only gate the x32 guard and the ABI clamp have, and the x32 guard is the +# invariant a whole-suite green run demonstrably cannot see (CHANGELOG, "the +# x32 guard's test coverage could not see the guard break"). A new +# required-features binary must be added HERE, once, not at each call site. +ALL_TARGET_FEATURES := $(FEATURES),bpf-dump,bench + +# Where the suite's full output lands, so the gate can COUNT what it did instead +# of trusting a summary line. Under target/, so `make clean` takes it. +SUITELOG := target/oring-suite.log +SMTLOG := target/oring-smt.log + +.PHONY: all help doctor check check-full suite-audit build test verify \ + fmt clippy deny smt advisories audit hooks check-history clean + +# Every capability CI exercises has a target here, and CI runs the target rather +# than the command. CI runs only the subset a SUB-FLOOR runner can honestly +# check — fmt, clippy, deny, build, smt, and the fail-closed refusal — because +# `check` sets ORING_REQUIRE_KERNEL=1 and would fail there on every push. Until 2026-08-01 `.github/workflows/ci.yml` invoked cargo +# directly at nine call sites and `make ...` at none, which had two costs: a +# contributor who ran the one target the README names could pass locally and +# fail CI on `cargo deny`, which `make check` did not run at all; and the +# feature list existed twice, so consolidating it into ALL_TARGET_FEATURES fixed +# one copy and left CI's. A capability the project exercises and does not expose +# as a target is where the README, CI and the maintainer's habits drift apart. + +# Extra ORING_REQUIRE_* settings for the suite line in `check`. Empty by +# default; `check-full` sets it. See the check-full comment for why this is a +# separate target and not simply added to `check`. +REQUIRE_EXTRA := all: check help: @echo "oring — make " - @echo " make doctor check prerequisites (rustc + the musl target)" - @echo " make check the gate: fmt + clippy + test" + @echo " make doctor check prerequisites (rustc, musl target, cargo-deny, z3)" + @echo " make check the gate: fmt + clippy + deny + test + seccomp SMT gate" + @echo " make check-full same, plus the live-database advisory checks, and" + @echo " every ORING_REQUIRE_* skip made fatal" + @echo " make suite-audit LOG= what a suite log shows RAN, vs what it claims" @echo " make build release static-musl binary with an embedded auditable SBOM" @echo " make test the test suite (the ring tests need a Landlock kernel)" @echo " make verify Kani symbolic proofs (x32 guard, ABI clamp)" - @echo " make audit scan the shipped binary's SBOM against RustSec" + @echo " make fmt rustfmt --check" + @echo " make clippy clippy over every target" + @echo " make deny cargo-deny: bans + licenses + sources (reproducible)" + @echo " make smt the seccomp SMT gate alone (no Landlock kernel needed)" + @echo " make advisories cargo-deny advisories — FETCHES the live RustSec DB" + @echo " make audit scan the shipped binary's SBOM against RustSec (live DB)" + @echo " make hooks install the tracked git hooks (commit-msg guard)" + @echo " make check-history sweep every ref for host detail in commit messages" @echo " make clean remove build artifacts" +# `check`'s prerequisites, all of them. cargo-deny and z3 are here because +# `check` now fails without them rather than skipping — a gate that quietly +# drops a step on a machine missing a tool is the clean-looking zero this +# project's whole gate discipline exists to prevent. doctor: @command -v cargo >/dev/null 2>&1 && echo "✓ cargo $$(cargo --version | cut -d' ' -f2)" || { echo "✗ cargo not found — https://rustup.rs"; exit 1; } @rustup target list --installed 2>/dev/null | grep -qx $(TARGET) && echo "✓ musl target present" || echo "… missing musl target — run: rustup target add $(TARGET)" + @command -v cargo-deny >/dev/null 2>&1 && echo "✓ cargo-deny $$(cargo deny --version 2>/dev/null | cut -d' ' -f2)" || echo "… missing cargo-deny (make check needs it) — cargo install cargo-deny --locked" + @command -v z3 >/dev/null 2>&1 && echo "✓ z3 $$(z3 --version 2>/dev/null | cut -d' ' -f3)" || echo "… missing z3 (the seccomp SMT gate needs it) — install from your distro" + @command -v cargo-audit >/dev/null 2>&1 && echo "✓ cargo-audit (make audit)" || echo "… missing cargo-audit (make audit only) — cargo install cargo-audit --locked" + @command -v cargo-kani >/dev/null 2>&1 && echo "✓ kani (make verify)" || echo "… missing kani (make verify only) — cargo install --locked kani-verifier && cargo kani setup" @echo "✓ ready" -# The everyday gate — the same fmt/clippy/test CI runs. clippy uses --features -# bpf-dump (the analysis-only code), NOT --all-features: resolve-unix needs the -# out-of-tree V9 fork and cannot compile against the published crate. +# The ordinary verbs, wrapped so the flags live in one place and CI can call the +# target instead of copying the command. +fmt: + $(CARGO) fmt --check + +clippy: + $(CARGO) clippy --all-targets --features $(ALL_TARGET_FEATURES) --locked + +# THE REPRODUCIBLE SUBSET, and the subset name is the whole point. +# +# `cargo deny check` with no arguments runs FOUR checks, and one of them — +# `advisories` — fetches the live RustSec database. That makes a bare invocation +# change verdict on a day nobody touched the repository, so it is not a +# per-commit gate however it is described. This target names its three +# reproducible subsets explicitly: they read a committed policy against a +# committed lockfile and answer the same way at the same commit forever. +deny: + $(CARGO) deny check bans licenses sources + +# The ring-2 truncation-invariance gate on its own. `check` runs this as a +# separate step because tests/seccomp_smt.rs is `#![cfg(feature = "bpf-dump")]` +# and compiles to NOTHING without it — a clean-looking zero. Exposed as a target +# so a machine that cannot witness containment can still run the half that is +# pure computation over the emitted cBPF and needs no Landlock kernel. +smt: + ORING_REQUIRE_SMT=1 $(CARGO) test --features $(FEATURES),bpf-dump --locked \ + --test seccomp_smt -- --nocapture --test-threads=1 + +# The live-database half, deliberately separate and deliberately NOT in `check`. +# Belongs in `check-full` or a scheduled job. A red result here means the world +# changed, not that the commit did. +advisories: + $(CARGO) deny check advisories + +# Install the tracked hooks. NEVER automatic: a repository that edits the +# developer's git configuration as a side effect of building is a repository +# nobody should trust. Provide the target; let the human run it. +hooks: + git config core.hooksPath .githooks + @echo "✓ hooks installed (core.hooksPath = .githooks)" + @echo " commit-msg: refuses host-specific detail in a commit message" + @echo " to remove: git config --unset core.hooksPath" + +# Sweep EVERY ref for host-specific detail in commit messages. +# +# The hook guards one message as it is written; this guards the history that +# already exists, and the two are not the same check. A message already +# committed is invisible to the hook forever. Not part of `check`: it is a +# pre-push question, and on a large history it is slow. +# +# Counts and reports the FRAME — refs scanned and commits examined — because a +# zero over an unstated span is the defect this project's gates exist to catch. +# SPAN: --branches --tags, which is what a clone receives — NOT --all. +# +# The first version printed a frame of "refs/heads refs/tags" and then scanned +# `git rev-list --all`, which also walks refs/backup/ and refs/original/. Run +# straight after a history rewrite it reported 32 offending commits that existed +# only in the backup refs the rewrite had just created, so a correct rewrite +# looked like a failed one. A count over a span you did not state is the defect +# this target exists to catch, committed in the target itself. +# +# refs/backup/ and refs/original/ are deliberately out of span: `git clone` takes +# refs/heads and refs/tags and nothing else, so an object reachable only from a +# backup ref does not publish. Delete those refs when you no longer want the +# undo, not to make this pass. +check-history: + @refs=$$(git for-each-ref --format='%(refname)' refs/heads refs/tags | wc -l); \ + commits=$$(git rev-list --branches --tags | wc -l); \ + echo " scanning $$commits commit(s) across $$refs ref(s) [refs/heads + refs/tags]"; \ + bad=0; \ + for c in $$(git rev-list --branches --tags); do \ + if git log -1 --format='%s%n%b' $$c | grep -qE '/home/[a-z_][a-z0-9_-]{2,}|/Users/[a-z_][a-z0-9_-]{2,}' \ + || git log -1 --format='%s%n%b' $$c | grep -qwE 'xtal|xtra'; then \ + echo " ✗ $$(git log -1 --format='%h %s' $$c)"; bad=$$((bad+1)); fi; \ + done; \ + if [ $$bad -eq 0 ]; then echo " ✓ no host-specific detail in any commit message"; else \ + echo " $$bad commit message(s) carry host-specific detail."; \ + echo " These PUBLISH. Reword before pushing; after a push there is no remedy."; \ + exit 1; fi + +# The everyday gate — the same fmt/clippy/test CI runs. clippy adds bpf-dump and +# bench (the analysis-only code) on top of the mandatory feature set, NOT +# --all-features, so the set stays explicit as features are added. +# +# `bench` is here for anti-rot and nothing else: `src/bin/bench.rs` measures what +# `seccomp::apply` installs and what `pipeline::seal` costs, so it goes stale the +# first time either moves. --all-targets compiles it on every gate run. It is +# never RUN here — `bench/run.sh` takes minutes and needs a quiet box, which is +# the opposite of what a pre-commit gate should be. check: @echo "== FMT ==" $(CARGO) fmt --check @echo "== CLIPPY ==" - $(CARGO) clippy --all-targets --features bpf-dump --locked + $(CARGO) clippy --all-targets --features $(ALL_TARGET_FEATURES) --locked +# `deny` and not `advisories`: the three subsets here are reproducible from the +# commit, so they can gate it. The advisory check fetches a live database and is +# in `check-full`. Splitting them is what makes a red `check` mean the commit is +# wrong rather than that RustSec published something overnight. + @echo "== DENY (bans + licenses + sources) ==" + $(MAKE) --no-print-directory deny @echo "== TEST ==" - $(CARGO) test --locked - @echo "== oring: gate passed (fmt + clippy + test) ==" +# ORING_REQUIRE_KERNEL turns a SKIP into a hard failure. Without it this gate +# reported a green suite it had not run: `containment_matrix` printed +# "1 passed" having executed 0 of its 28 cells, because a skipped test and a +# passing test are indistinguishable in cargo's summary — "ok. 0 passed" reads +# green and the SKIP line goes to stderr, which cargo discards without +# --nocapture. The entire containment surface sat behind that. +# +# CONSEQUENCE, chosen deliberately: `make check` now FAILS on a host below the +# V9 floor instead of passing vacuously. That is D-4's own posture — refuse +# rather than run a partial cage — applied to the gate: a machine that cannot +# witness containment must not report that it did. `make test` is unchanged and +# still skips, for anyone who wants the old behaviour. +# +# ORING_REQUIRE_AUDIT, ORING_REQUIRE_UDP and ORING_REQUIRE_NET are NOT set here — see `check-full`. +# --nocapture is LOAD-BEARING, not verbosity. Every skip in this tree announces +# itself with `eprintln!("SKIP: …")` on stderr, and cargo DISCARDS stderr without +# it — so the evidence that a cell did not run is thrown away by default, while +# the cell still counts as PASSED. Logged rather than piped so cargo's own exit +# status is preserved without depending on `pipefail`. +# +# On success show cargo's own summary lines only; on failure show EVERYTHING. +# --nocapture is needed for the SKIP lines to exist at all, but it also unleashes +# every test's stderr, and a gate nobody can read is a gate nobody runs. The full +# log is always on disk at $(SUITELOG). +# +# NO COMMENT MAY SIT INSIDE THE CONTINUED RECIPE BELOW, and the four lines above +# used to. Measured on GNU Make 4.4.1: a `#` line between two backslash-continued +# recipe lines ENDS the recipe line, so what follows runs in a SECOND shell — +# where `rc` is unset. `[ $$rc -ne 0 ]` then errored ("unary operator expected"), +# and an errored test takes the ELSE branch, so the failure arm was dead: a red +# suite printed the same summary grep as a green one, never the log, and +# `exit $$rc` exited on the LAST command's status rather than cargo's. Red-tested +# with `false` in cargo's place: broken form printed the success arm, hoisted form +# printed the failure arm and exited 1. What kept `make check` honest meanwhile was +# `suite-audit` counting `FAILED` lines — a second instrument covering for a dead +# first one, which is exactly the arrangement that hides a defect for a year. + @mkdir -p target + @ORING_REQUIRE_KERNEL=1 $(REQUIRE_EXTRA) $(CARGO) test --features $(FEATURES) --locked -- --nocapture --test-threads=1 > $(SUITELOG) 2>&1; rc=$$?; \ + if [ $$rc -ne 0 ]; then cat $(SUITELOG); else grep -E '^(test result:|error)' $(SUITELOG) || true; fi; \ + $(MAKE) --no-print-directory suite-audit LOG=$(SUITELOG) || rc=$$?; \ + exit $$rc +# The SMT gate is a SEPARATE step, mirroring CI exactly, because +# tests/seccomp_smt.rs is `#![cfg(feature = "bpf-dump")]`: under the line above +# it compiles to NOTHING and cargo prints "ok. 0 passed" — a clean-looking zero. +# Until 2026-07-26 this gate therefore never ran locally, while `make check` +# printed "gate passed"; the H2 truncation guard (CVE-2019-7303's class) is what +# it proves, and it is the regression guard for the socket() protocol wall. +# ORING_REQUIRE_SMT=1 makes a missing z3 fatal rather than a silent skip. +# Kept as its own invocation, not a blanket `--features …,bpf-dump` on the line +# above, so the main suite still runs against the SHIPPED feature set. +# +# On success show cargo's own summary lines only; on failure show EVERYTHING. +# --nocapture is needed for the SKIP lines to exist at all, but it also unleashes +# every test's stderr, and a gate nobody can read is a gate nobody runs. The full +# log is always on disk at $(SMTLOG). +# +# Comments stay ABOVE the recipe line here for the reason spelled out at the +# suite step: inside a backslash continuation they split it into a second shell +# and lose `rc`. This copy was the visible one — it echoed the `if` line and the +# `[: -ne: unary operator expected` into every gate run — but both copies were +# broken, and the suite one was worse for being silent. + @echo "== SECCOMP SMT GATE ==" + @ORING_REQUIRE_SMT=1 $(CARGO) test --features $(FEATURES),bpf-dump --locked --test seccomp_smt -- --nocapture --test-threads=1 > $(SMTLOG) 2>&1; rc=$$?; \ + if [ $$rc -ne 0 ]; then cat $(SMTLOG); else grep -E '^(test result:|error)' $(SMTLOG) || true; fi; \ + $(MAKE) --no-print-directory suite-audit LOG=$(SMTLOG) || rc=$$?; \ + exit $$rc +# THE BANNER IS CONDITIONAL, and that is the point of this whole target. It used +# to print "gate passed" unconditionally — over a run in which, measured on this +# host, THREE cells did not execute (two live-suite cells needing ORING_LIVE, and +# the SCTP cell on a kernel with no sctp.ko). A gate may legitimately be unable to +# witness something; it may not report an unqualified pass over it. +# UNANCHORED and counted by OCCURRENCE, not by line. Anchoring on `^SKIP:` undercounted +# — measured, 2 against 3 — because `--nocapture` lets parallel test threads interleave +# writes MID-LINE, so a skip announcement can land behind another test's output and lose +# column 1. That is a clean-looking TWO produced by the very gate written to catch +# clean-looking numbers. Both causes are addressed: the gate runs `--test-threads=1` so +# output cannot interleave, and the count no longer depends on that having worked. +# +# `wc -l` over matches, NOT `grep -c | paste | bc`. The first version was +# the latter with a `|| echo 0` fallback, whose failure mode is REPORTING ZERO SKIPS +# — i.e. printing the unqualified banner — on any host without bc. A construct that +# fabricates a clean answer when its tooling is missing is the defect this gate exists +# to catch, so it is gone rather than guarded. + @skips=$$(grep -hoE 'SKIP:' $(SUITELOG) $(SMTLOG) 2>/dev/null | wc -l); \ + if [ "$$skips" -eq 0 ]; then \ + echo "== oring: gate passed (fmt + clippy + deny + test + smt) — every cell witnessed == [$(SUITELOG)]"; \ + else \ + echo "== oring: gate passed (fmt + clippy + deny + test + smt), BUT $$skips cell(s) were NOT WITNESSED on this host =="; \ + echo " this run is not a full witness; see the NOT WITNESSED list above, or \`make check-full\` to make the ORING_REQUIRE_* classes fatal"; \ + fi + +# `check` with all five ORING_REQUIRE_* variables SET on the suite line. +# +# WHAT THIS DOES NOT DO: it does not fix the defect. `make check` still passes +# while the audit-attribution and UDP suites skip, and that is the actual +# finding — ORING_REQUIRE_AUDIT, ORING_REQUIRE_UDP and ORING_REQUIRE_NET are set by no OTHER make +# target and no CI workflow, so a green gate does not mean those suites ran. +# The indistinguishable-pass is worse than the usual clean-looking zero: the +# skip paths return EARLY, so each cell counts as PASSED. Measured under a +# planted mutant, `udp_wall` reports `ok. 9 passed; 0 failed` having executed +# zero cells. Not a clean-looking zero — a clean-looking nine. +# +# WHY THIS RECURSES INSTEAD OF DEPENDING ON `check` — it is a bug fix, not style. +# The first version was `check-full: check` plus a target-specific +# `REQUIRE_EXTRA`. Target-specific variables do propagate to prerequisites, but +# make builds `check` ONCE PER INVOCATION, so the variable context that wins is +# decided by GOAL ORDER: `make check check-full` ran the suite with NO extra +# vars and then printed the strict banner over it, rc=0 — a false green, in the +# target whose whole purpose is to prevent one. Silent and order-dependent +# (`make check-full check` was correct), which is the worst shape it could have. +# Passing REQUIRE_EXTRA on a sub-make command line makes it a command-line +# variable, the highest-precedence kind, so no goal ordering or outer override +# can strip it. The banner then cannot print over a run that did not set them. +# +# WHY THE FIX IS NOT SIMPLY ADDING THEM TO `check` — an operator-visible choice, +# escalated rather than made here: +# ORING_REQUIRE_KERNEL set `make check`'s bar AT oring's product floor (V9): +# a host that cannot witness containment must not report that it did. +# ORING_REQUIRE_UDP would set it ABOVE that floor — the UDP cells need V10 +# (kernel 7.2), so `make check` would start FAILING on 7.1, the minimum kernel +# oring itself supports and ships for. ORING_REQUIRE_AUDIT is a different +# question again: it needs a readable `_TRANSPORT=audit` journal, which is +# host configuration rather than kernel version, so it would fail in any +# container without journald+auditd. +# Two reasonable answers, different observable behaviour for an operator. Filed +# as T7 in the build queue for a decision; this target exists so the strict run +# is one reproducible command in the meantime, rather than four env vars typed +# by hand and reported on trust. +# The banner says SET, not "all skips are fatal", and the distinction is real: +# ORING_REQUIRE_SMT makes only the z3-absent path fatal (tests/seccomp_smt.rs +# :326). An inconclusive z3 verdict still warns and `continue`s (:284-291), so +# a pair z3 never decided is counted as proved. Claiming "every skip fatal" +# would overstate exactly the axis the tree's own claims ledger already grades F. +check-full: + $(MAKE) check REQUIRE_EXTRA='ORING_REQUIRE_AUDIT=1 ORING_REQUIRE_UDP=1 ORING_REQUIRE_NET=1' +# The live-database half runs HERE and never in `check`. Both targets fetch from +# RustSec, so both can change verdict without a commit; that is fine in the slow +# gate and is exactly what disqualifies them from the per-commit one. + @echo "== ADVISORIES (live RustSec database) ==" + $(MAKE) --no-print-directory advisories + @echo "== oring: gate passed with ORING_REQUIRE_{KERNEL,SMT,AUDIT,UDP} all SET ==" +# Even THIS run is not a full witness, and the NOT WITNESSED list above now proves it +# rather than leaving it to this comment. Only 9 of the 20 skip sites in the tree are +# reachable by any ORING_REQUIRE_* variable; the other 11 (no cat(1), no cargo, no git, +# HOME unset, no journald cursor, userns unavailable, SCTP absent, ORING_LIVE unset) +# have no flag at all. Measured on the development host: `check-full` still reports 3 +# unwitnessed cells. "All four SET" was never the same claim as "everything ran". + @echo " and see the NOT WITNESSED list above: setting all five does NOT make every skip fatal" + +# suite-audit — say what a run DID, not what it claims. `make suite-audit LOG=` +# +# Standalone on purpose: that is how this check is itself red-tested, by feeding it +# a synthesized log. A gate never seen red is not known to work. +# +# THREE QUESTIONS, none of which cargo's summary line answers. +# +# 1. DID EVERY SUITE REPORT? Cargo prints one `Running` line per test binary plus +# one `Doc-tests` line, so FED = those, REPORTED = `test result:` lines, and the +# two must be equal. SELF-CALIBRATING — no hardcoded total to go stale. On this +# tree it is 26 + 1 = 27 on both sides. +# +# MEASURED 2026-07-29, and it corrects the causal story this comment first told. +# A binary that FAILS normally still prints `test result: FAILED`, so fed and +# reported stay EQUAL (27/27) and question 2 is what catches it. What this count +# catches is a binary that produces NO SUMMARY AT ALL — `std::process::abort()` +# measured 27 fed against 26 reported. That is the crash / SIGSEGV / killed-guest +# case, and no amount of reading `test result:` lines can see it, because the line +# does not exist. Two questions, two distinct failure shapes; neither subsumes the +# other. +# +# 2. DID ANYTHING FAIL? This is the one that answers the CI defect. +# `.github/workflows/ci.yml` gates three jobs on `grep -qE 'test result: ok'`, +# and a run with one failing binary contains BOTH `ok` lines and a `FAILED` line +# — measured — so the grep matches a failed run. Counting FAILED is the fix, and +# it is order-independent, unlike anything that depends on which binary failed +# first. Those three sites are staged for repair but UNEXERCISED: there are no +# runners here and `vng` is not installed, so whether the guest's exit status +# propagates out remains unestablished. +# +# 3. WHAT DID NOT RUN? The one that had no instrument. `require_or_skip` returns +# EARLY, so a skipped cell counts as PASSED and is indistinguishable from a real +# one in cargo's summary. MEASURED on this tree, 2026-07-29: with the UDP +# requirement raised above this host's ABI, `udp_wall` prints +# `ok. 9 passed; 0 failed` having executed ONE of nine cells — a clean-looking +# NINE, not a clean-looking zero. The 8 SKIP lines that would show it go to +# stderr, which cargo discards without --nocapture. Hence --nocapture in `check`. +# +# A SKIP IS NOT MADE FATAL HERE, deliberately. Making it fatal is the escalated +# decision recorded in the check-full comment: ORING_REQUIRE_UDP needs V10, so it +# would fail `make check` on 7.1 — the minimum kernel oring ships for. What is +# forbidden is an UNQUALIFIED PASS over a run that witnessed less than everything. +# The 11-of-20 skip sites that NO ORING_REQUIRE_* variable can reach (no cat(1), no +# cargo, no git, HOME unset, no journald cursor, userns unavailable, SCTP absent) +# are counted here too, and no flag would have covered them. +# +# Exit: 0 audited clean · 1 a suite failed or never reported · 2 nothing examined. +LOG ?= $(SUITELOG) +suite-audit: + @test -f "$(LOG)" || { \ + echo " ✗ suite-audit: no log at $(LOG). Exit 2 — nothing was examined, and that"; \ + echo " is not the same as nothing being wrong."; exit 2; } + @fed=$$(grep -cE '^ *(Running|Doc-tests) ' "$(LOG)" || true); \ + rep=$$(grep -cE '^test result:' "$(LOG)" || true); \ + fail=$$(grep -cE '^test result: FAILED' "$(LOG)" || true); \ + skip=$$(grep -oE 'SKIP:' "$(LOG)" | wc -l); \ + echo " suites fed: $$fed reported: $$rep FAILED: $$fail cells skipped: $$skip [$(LOG)]"; \ + rc=0; \ + if [ "$$fed" -eq 0 ]; then \ + echo " ✗ no test binary ran at all — a zero here is not a pass"; rc=1; fi; \ + if [ "$$fed" -ne "$$rep" ]; then \ + echo " ✗ $$((fed - rep)) suite(s) started and never reported a result line. A binary"; \ + echo " that aborts, segfaults or is killed prints no summary at all, so no amount of"; \ + echo " reading 'test result:' lines can see it — only counting can."; rc=1; fi; \ + if [ "$$fail" -ne 0 ]; then \ + echo " ✗ $$fail suite(s) reported FAILED"; rc=1; fi; \ + if [ "$$skip" -gt 0 ]; then \ + echo " ⚠ NOT WITNESSED — these cells did not run, and each counted as PASSED:"; \ + sed -n 's/.*\(SKIP: .*\)/\1/p' "$(LOG)" | sort | uniq -c | sed 's/^/ /'; fi; \ + exit $$rc # Release build with an embedded, auditable dependency SBOM: the static binary # carries its full dependency tree, so `make audit` (or any consumer) can scan the # SHIPPED artifact against RustSec, not just Cargo.lock. build: - $(CARGO) auditable build --release --locked --target $(TARGET) + $(CARGO) auditable build --release --locked --features $(FEATURES) --target $(TARGET) test: - $(CARGO) test --locked + $(CARGO) test --features $(FEATURES) --locked # Formal proof — opt-in (Kani, minutes). See docs/VERIFICATION.md. +# +# $(ALL_TARGET_FEATURES), not $(FEATURES): the proofs use neither `bpf-dump` nor +# `bench`, but `cargo kani` builds every target in the package and both are +# `required-features` binaries, so omitting either aborts the run at 101 before a +# single proof executes. The full account, and why this list lives in one +# variable rather than at each call site, is at its definition above. verify: - $(CARGO) kani --harness x32_guard_total - $(CARGO) kani --harness abi_from_version_clamp + $(CARGO) kani --features $(ALL_TARGET_FEATURES) --harness x32_guard_total + $(CARGO) kani --features $(ALL_TARGET_FEATURES) --harness abi_from_version_clamp # Scan the shipped binary's embedded SBOM against the RustSec advisory database. audit: diff --git a/README.md b/README.md index 8d04f82..8fd2c60 100644 --- a/README.md +++ b/README.md @@ -6,83 +6,231 @@ Run an untrusted process inside a kernel-enforced cage — no root, no daemon, o static binary. `oring` restricts *itself*, then hands control to your command. It applies Landlock -(filesystem tiers, per-direction TCP, IPC scope), installs a seccomp-bpf denylist, -sets `NO_NEW_PRIVS`, builds a clean environment, and `execve()`s the target. The -child inherits every restriction irrevocably and can never widen it — so a -compromise is contained by the kernel, not by trust. +(filesystem tiers, per-direction TCP and UDP, IPC scope), installs a +seccomp-bpf denylist, sets `NO_NEW_PRIVS`, builds a clean environment, and +`execve()`s the target. The child inherits every restriction irrevocably and can +never widen it — so a compromise is contained by the kernel, not by trust. ```sh oring --profile balanced --workdir /work/repo -- cargo test # build/test; TCP :443 to any host, not a firewall -oring --profile strict --workdir /work/scratch -- ./sample # untrusted binary; no TCP egress (UDP/DNS open), W^X +oring --profile strict --workdir /work/scratch -- ./sample # untrusted binary; no egress at all (no DNS), W^X oring --policy /etc/oring/rig.json -- ./fuzzer # explicit JSON policy oring --profile balanced --workdir /work/repo --explain # preview the plan, run nothing ``` -oring is honest about what it is not: not an orchestrator, a network-policy engine, -or a VM boundary, and it does not author host MAC — those stack underneath it. The -residuals it *cannot* close are written down plainly in -[`docs/DESIGN.md`](docs/DESIGN.md) §2, not glossed over. +It is not an orchestrator, a network-policy engine, or a VM boundary, and it does not +author host MAC. Those stack underneath it. The residuals it cannot close are enumerated +in [`docs/DESIGN.md`](docs/DESIGN.md) §2, each with the loss it belongs to and whether +anything reports it at run time. ## Profiles -Two baked-in profiles, tested against the shipping binary on a real kernel. -`balanced` is for a coding agent that builds code — toolchain caches granted, -workdir `write_exec`, `:443` egress to any IP (port-only, not a domain/IP firewall). -`strict` is for an untrusted binary — nothing -granted, workdir `write_noexec` (W^X), deny-all TCP egress (UDP/DNS still open), -JIT blocked. Both block -reading outside their tiers (SSH keys, shell history), writing system paths, the -privilege-escalation syscalls, and abstract AF_UNIX `connect()`. Anything in between -is a small JSON `--policy` (schema in `docs/DESIGN.md` §7). - -**Wrapping an agent:** `balanced` grants the toolchain caches but deliberately not -an agent's own state directory (`~/.claude`, `~/.config`), and the child's environment -is an allowlist (secrets withheld unless you `--env-keep NAME` them). So `-- claude` -won't start as-is; decide the credential trade on purpose — and know the honest limits. -Grant `~/.claude` so the agent can run and the API key inside it is readable by the -(possibly prompt-injected) agent: confinement buys blast-radius reduction, not credential -protection. Two residuals oring cannot close for you: **egress** is port-only to any IP -(DNS/UDP open), so a host firewall or filtering proxy is a *required* companion control, -not optional; and the agent has full write over its **workdir** — `.git/hooks`, `Makefile`, -`build.rs`, `package.json` scripts all run on the next `git`/build/test you run *outside* -the cage, so treat a touched workdir as untrusted and run the first build inside oring. -Full story in `docs/DESIGN.md` §2/§9. +Two baked-in profiles, tested against a build of this tree on a Landlock kernel. +`balanced` is for a coding agent that builds code — toolchain caches **readable, never +writable** (`~/.cache` is not granted at all, see below), workdir `write_exec`, +`:443` egress to any IP (port-only, not a domain/IP firewall). +`strict` is for an untrusted binary: **no toolchain caches and no `$HOME` grants at +all**, workdir `write_noexec` (W^X), deny-all TCP egress, JIT blocked. It still grants the +system `read_exec` set (`/usr/bin`, `/bin`, `/lib*`, `/usr/libexec`), `/etc` and +`/usr/share` read-only, `/proc/self`, and `/tmp` write-no-exec, because something has to +be executable or nothing runs. The contrast with `balanced` is the `$HOME` grants, not the +system set. + +Both profiles block reading outside their tiers (SSH keys, shell history), writing system +paths, the privilege-escalation syscalls, and abstract AF_UNIX `connect()`. Both refuse +`socket()` in `AF_INET`/`AF_INET6` for anything ring 1 cannot mediate (ICMP, MPTCP, SCTP, +`SOCK_SEQPACKET`, `SOCK_RAW`), so in-cage `ping` does not work. +Anything in between is a small JSON `--policy` (schema in `docs/DESIGN.md` §7). + +**UDP is restricted under both profiles.** `balanced` grants outbound **:53 only**, for +name resolution and nothing else. `strict` grants no UDP at all, so under `strict` names +do not resolve. That is deliberate: it already denies every TCP egress, so +resolving a name it cannot connect to buys nothing. + +Restricting UDP takes two grants, not one: a sending socket acquires its source +port by kernel autobind, which Landlock checks against a synthetic **port 0**, so +`egress_udp` without `0` in `ingress_udp` sends nothing at all. oring warns on that +pair, and for the built-in profiles it is a build-time invariant. + +A run still tells you which rights it left open — the seal line carries +`unhandled=`, and a policy restricting *part* of the network warns rather than +letting you read `egress_tcp` as "no egress". Under both profiles that field now +reads `-`, and `--require-full-coverage` turns any gap into exit 120 instead of a +warning. + +**Wrapping an agent.** `balanced` grants the toolchain caches but not an agent's own +state directory (`~/.claude`, `~/.config`), and the child's environment is an allowlist, +so secrets are withheld unless you `--env-keep NAME` them. `-- claude` will not start +as-is. Grant `~/.claude` and the agent runs, and the API key inside it is readable by a +possibly prompt-injected agent: confinement buys blast-radius reduction, not credential +protection. Decide that trade deliberately. + +Two residuals oring cannot close for you: + +- **Egress is port-only, to any IP.** Landlock keys its network rules on the port and + never on the address, so a host firewall or filtering proxy is a required companion + control rather than an optional one. That applies to the `balanced` DNS grant too, + which reaches :53 at any address. +- **The agent has full write over its workdir.** `.git/hooks`, `Makefile`, `build.rs` and + `package.json` scripts all run on the next `git`, build or test you run *outside* the + cage, so treat a touched workdir as untrusted input. + +**Toolchain caches are readable, never writable.** `~/.cargo/registry`, `~/.cargo/git` +and `~/.npm` stay readable (`~/go/pkg` via the `~/go` rule); nothing shipped grants write +on them, and a unit test asserts the absence of all six. `~/.cache` is not granted at +all: it is an XDG namespace third parties join after a policy is written, and it holds +`~/.cache/huggingface/token`. Granting a cache back in a hand-written `--policy` reopens +the class, because a later unconfined build reads what the confined child wrote there. + +What that costs. A warm offline `cargo build` inside the cage succeeds; fetching a +dependency that is not already cached fails loudly, naming the crate. `go build` fails +under `balanced` with an error naming GOROOT rather than the policy, because `GOCACHE` +defaults to `$XDG_CACHE_HOME/go-build` and must be **writable**, which the cargo +demotion's read-only compromise cannot supply. The fix needs only shipped flags, since +the workdir is already `write_exec`: + +```sh +GOCACHE=/work/repo/.gocache oring --profile balanced --workdir /work/repo \ + --env-keep GOCACHE -- go build ./... +``` + +Measured: rc=0. Restoring write on `~/.cache/go-build` instead is not an option, because +Go's build cache validates by size rather than by hash on the path serving compiled +objects and cached executables. Expect `cargo miri`, `dune`, `ccache`/`sccache` and +anything else caching under `~/.cache` to need an explicit `--policy` grant; pre-warm +outside the cage, or grant a specific path. **The `~/.cache` decision rests on a security +argument and has not been measured for usability** (`DECISIONS.md` D1d — a handle in the +maintainer's decision log, which is not in this repository and resolves to nothing you can +open; the reasoning it points at is in `docs/DESIGN.md` §2/§9, which does ship). Anything +under `~/.cache` other than the paths named here is unmeasured. ## How it works -- **Ring 1 — Landlock:** four filesystem tiers, per-direction TCP allowlists, IPC - scoping. Hard floor kernel **6.15 / ABI V7** — below it oring refuses rather than - run a partial cage. The V9 `RESOLVE_UNIX` right (walls `connect()` to a pathname - AF_UNIX socket like `docker.sock` or ssh-agent) is optional hardening behind - `--features resolve-unix`; without it that residual is reported loudly at seal. +- **Ring 1 — Landlock:** four filesystem tiers, per-direction TCP **and UDP** + allowlists, IPC scoping. Hard floor kernel **7.2 / ABI V10** — below it oring + refuses rather than run a partial cage. Two rights are in the floor. `RESOLVE_UNIX` + (V9) walls `connect()` to a pathname AF_UNIX socket like `docker.sock` or + ssh-agent: Landlock has no hook on that connect below V9, so beneath it no + filesystem tier can gate it. The UDP rights (V10) are what let the profiles + restrict DNS instead of disclosing it as unhandled. That is a steep floor — it + admits 7.2 and nothing earlier — and it is deliberate; see Build. - **Ring 2 — seccomp:** a denylist removing the known escape launchpads (io_uring, userfaultfd, bpf, ptrace, the mount and namespace family), with a mandatory - x32-ABI guard so the filter can't be re-entered through the compat ABI. + x32-ABI guard so the filter can't be re-entered through the compat ABI. It also + carries the axes ring 1 cannot see: `socket()`/`socketpair()` are constrained by + family, **type** and **protocol**, because the kernel picks the transport from + any of the three and Landlock's TCP rights apply only to real TCP. + +Two things a run tells you about *itself*, because "enforced" is not the same as +"enforced completely": + +- **`unhandled=`** on the seal line names every restrictable right this run left + open, derived by set difference against the kernel rights the linked crate can + name — so a right oring does not yet plumb reports itself rather than waiting to + be noticed. It is not suppressible by `ORING_QUIET`. +- **Refusal on a policy that does not describe its own cage.** Landlock ORs an + ancestor's rule with a descendant's, so `read_exec` over `write_noexec` silently + becomes `write_exec` — a tier neither line names. oring refuses that rather than + sealing a cage wider than the policy reads. When a denial leaves you with a bare `EACCES`, `--explain` shows the effective policy and `--denials ` reconstructs what the kernel blocked. +**Reach for `--denials` when a tool fails in a way that sounds nothing like a +sandbox.** A denied path is still *visible* under Landlock — `stat()` and +`access()` keep answering from ordinary file permissions, and only the `open()` +is refused. That combination cannot happen without a sandbox, so a tool that +checks `access()` first and then opens is told the file is readable and then +fails anyway, landing in an error path meant for disk corruption. Measured: +without a grant on `~/.config/git/config`, **every** git command fails with +`fatal: unknown error occurred while reading the configuration files` — which +names neither the file nor oring. `--denials ` names it directly: + +```console +$ oring --denials 2970284 +denied: + fs.read_file /home/u/.config/git/config (x3) +``` + +The pid is on the `SEALED` line. The built-in profiles grant cargo's and git's config +paths for exactly this reason. They do not grant `~/.npmrc`, `~/.config/pip/pip.conf`, or +Go's `~/.config/go/env`; `balanced` grants `~/go` and `~/go/bin`, which are Go's module +and binary directories rather than its config. No profile can enumerate every tool, so +when something behaves oddly under oring and not outside it, `--denials` is the first +thing to run. Go also has a larger breakage under the profiles: see `GOCACHE` above. + +Not every tool aborts. Measured on the same setup, `npm` reads an unreachable `~/.npmrc`, +silently ignores it, and returns the default value. It does the same under ordinary file +permissions, so that one is npm's behaviour rather than oring's. + +**Scratch directories are named, not inherited.** oring does not read `$TMPDIR` +from the environment — a caller-supplied value used to widen a profile's writable +tiers. Use `--tmpdir ` (no-exec) or `--exec-tmp ` (write+exec, for +toolchains that link then run); oring sets the child's `TMPDIR` to whatever it +actually granted. + ## Build -Linux **≥ 6.15** with Landlock enabled (`grep landlock /sys/kernel/security/lsm`), -x86_64. +Linux **≥ 7.2** with Landlock enabled (`grep landlock /sys/kernel/security/lsm`), +x86_64. The floor is ABI V10; below it oring refuses at seal rather than running a +partial cage. ```sh git clone https://github.com/hexproofdev/oring && cd oring make build # static-musl binary with an embedded auditable SBOM -make check # the gate: fmt + clippy + test (make help lists the rest) +make check # the gate: fmt + clippy + deny + test + smt (make help lists the rest) ``` -oring depends on the published [`landlock`](https://crates.io/crates/landlock) -crate — nothing vendored. The optional V9 `resolve-unix` hardening builds against an -out-of-tree fork (upstream -[`rust-landlock#135`](https://github.com/landlock-lsm/rust-landlock/pull/135)); -see [`.cargo/config.toml.example`](.cargo/config.toml.example). +Two costs of that floor, up front rather than discovered: + +- **Kernel.** The Landlock ABI is a property of the *running kernel* — no + userspace package raises it. On anything below 7.2 oring refuses at seal, by + design. **7.2 is not released** — it is at rc4 as of 2026-08-02 — so today + that means a self-built mainline kernel or a VM booting one. + **Containment is witnessed by `make check` on a >=7.2 host and nowhere else.** + No hosted runner is at the floor, so CI checks format, lints, dependency + policy, the static build, the SMT gate and the fail-closed refusal — and + witnesses no containment cell at all. Do not read a green CI badge as evidence + that the cage holds. +- **Dependency.** The V10 UDP rights (`BindUdp` / `ConnectSendUdp`) are in **no + published [`landlock`](https://crates.io/crates/landlock) release**, so the + build uses the maintainer's fork, pinned by revision — upstream v0.4.7 plus + the unreleased `abi-v10` work (`eslerm/rust-landlock@d7bb0c1`). It is a `rev` + rather than a branch so the input cannot move under a build. `AccessFs::ResolveUnix` is *not* what the fork is + for: upstream released it, with ABI V9, in + [v0.4.7](https://github.com/landlock-lsm/rust-landlock/pull/135), and that is + the release vendored here. + + Three consequences, and the first is the one a packager needs: + + - **oring cannot be published to crates.io as it stands.** The `.crate` + drops `[patch.crates-io]` on publish (and rejects git dependencies), so + the packaged tree compiles against the registry `landlock` and fails — + measured 2026-08-01, `cargo package` stops with 5 errors on the absent + `AccessNet` variants. Install from this repository. + - `cargo install oring` does **not** build, for the same reason. + - `landlock` is not the clean registry dependency RustSec / `cargo deny` fully + cover; the shipped binary still carries an auditable SBOM (`make audit`). + + When upstream releases V10 all three close together: bump the version, delete + the `[patch.crates-io]` block and the `allow-git` entry in `deny.toml`, no + behaviour change. + + **Done 2026-08-02.** The patch moved from an in-tree copy to the fork it + already tracked. The pinned input is now a commit SHA in `Cargo.lock` rather + than an unchecksummed path, at the cost of the offline clone — a git + dependency needs the network at build time, where committed sources needed + nothing. + + The vendored fork covers the V10 *access rights* only. ABI 10 also adds + `LANDLOCK_ADD_RULE_QUIET` and the `quiet_access_*` ruleset-attr fields, which + would need regenerated uapi bindings and struct-size handling against older + kernels; those are deliberately not implemented, and oring does not claim them. ## Alternatives -For a batteries-included agent sandbox, reach for one of these — they do things -oring deliberately doesn't: +For a batteries-included agent sandbox, use one of these. They do things oring +deliberately does not: - [sandbox-runtime](https://github.com/anthropic-experimental/sandbox-runtime) (Anthropic) — cross-platform (macOS/Linux/Windows), domain-level network egress @@ -92,19 +240,25 @@ oring deliberately doesn't: credential-brokering auth proxy and per-API egress. Needs a container runtime and a privileged sidecar. -oring is the minimal end of that spectrum: one unprivileged static binary, no -runtime, no daemon, no user namespaces (Landlock, not bubblewrap), and a -confinement small enough to formally verify. It runs where those can't — -distroless images, userns-disabled kernels, a bare CI step — and works as a -defense-in-depth layer *inside* them. +oring is the minimal end of that spectrum: one unprivileged static binary, no runtime, +no daemon, no user namespaces (Landlock, not bubblewrap), and a construction small enough +to formally verify. The x32 guard and the ABI clamp are Kani-proven and the emitted filter +is SMT-checked for truncation invariance. Read that precisely: what is proven is that +oring **builds the filter it intends**. Whether the cage holds is the kernel's to enforce +and is explicitly not verified; [`docs/VERIFICATION.md`](docs/VERIFICATION.md) lists what +is out of scope as prominently as what is in it. oring runs where the tools above cannot, +including distroless images, userns-disabled kernels and a bare CI step, and works as a +defence-in-depth layer inside them. ## Status -`v0.1.0`. Design and threat model: [`docs/DESIGN.md`](docs/DESIGN.md). How it's +`v0.2.0`. Design, residuals and non-goals: [`docs/DESIGN.md`](docs/DESIGN.md). +Is a behaviour an oring bug: [`THREAT_MODEL.md`](THREAT_MODEL.md). What oring +claims to enforce, and what enforces it: [`CONSTRAINTS.md`](CONSTRAINTS.md). How it's proven: [`docs/VERIFICATION.md`](docs/VERIFICATION.md). Attack catalogue: [`docs/ATTACKS.md`](docs/ATTACKS.md). Release history: [`CHANGELOG.md`](CHANGELOG.md). Contributors and agents start with -[`CLAUDE.md`](CLAUDE.md). Found a way past the cage? See -[`SECURITY.md`](SECURITY.md) — that's exactly what we want to hear. +[`CLAUDE.md`](CLAUDE.md). Found a way past the cage? [`SECURITY.md`](SECURITY.md) is the +route, and a report is worth sending even if you are unsure. BSD-3-Clause — see [`LICENSE`](LICENSE). diff --git a/SECURITY.md b/SECURITY.md index 21ea2a3..77aea71 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,20 +4,73 @@ ## Reporting a Vulnerability -Found a way past the cage? Report it privately via GitHub — the repository's +Report a suspected vulnerability privately via GitHub — the repository's **Security** tab → **Report a vulnerability** (this drafts a private advisory). Email **security@hexproof.dev** works too. Please include a description, a reproducer or proof-of-concept, and the affected version or commit. -You can expect acknowledgment within a few days, a fix for valid issues, and credit -if you'd like it. Please give us time to patch before disclosing publicly. +## What to expect + +- **Acknowledgement within 3 business days.** If you have not heard back by + then, assume the message did not arrive and send it again. +- **Disclosure deadline: 90 days** from the report, or when a fix is available, + whichever comes first. If a fix will take longer, we will say so before day 90 + and agree an extension with you rather than let the date pass in silence. +- **Credit if you want it**, in the advisory and in `CHANGELOG.md`. Say how you + would like to be named, or say you would rather not be. +- **The advisory identifier**: we request it and it publishes through GitHub's + advisory database. If you have already requested one, say so and we will use + yours. + +## Safe harbour + +We will not pursue or support legal action against anyone who reports a +vulnerability in good faith under this policy — meaning you stayed within your +own systems or a test environment you control, did not access, modify or exfil +data that is not yours, did not degrade a service anyone else relies on, and gave +us the time above before publishing. + +If you are unsure whether something is in scope, ask first. A question costs +nothing and this paragraph is meant to be relied on. + +## Supported versions + +The most recent tagged release only. oring is a single static binary with no +runtime and no update channel, so a fix ships as a new tag and there is no +backport branch. If you need a fix against an older tag for a reason we have not +thought of, say so in the report. ## Scope -A *bypass of the cage* — escaping the Landlock or seccomp confinement oring applies -to itself — is a vulnerability. A **documented residual is not**: the pathname-socket -reach without `--features resolve-unix`, UDP/DNS egress, peer `/proc/` reads -(`environ`/`maps` under `--proc full` — use `--proc self` or a runtime PID-ns), -"not a VM boundary", and the workdir-poisoning class are all by design and enumerated -in the threat model — see [`docs/DESIGN.md`](docs/DESIGN.md) §2. Those are the model working -as intended, not defects. +[`THREAT_MODEL.md`](THREAT_MODEL.md) decides whether a behaviour is an oring security bug: +which party acted, which level it sits at, and what makes it a bypass rather than a grant +or a documented residual. It is the authority, and this file does not summarise it. +[`docs/DESIGN.md`](docs/DESIGN.md) §2 enumerates what oring does *not* stop, and +[`CONSTRAINTS.md`](CONSTRAINTS.md) what it claims to enforce. + +**If you are unsure, send it anyway.** We would rather reject a report than miss one. + + diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md new file mode 100644 index 0000000..3999d90 --- /dev/null +++ b/THREAT_MODEL.md @@ -0,0 +1,260 @@ + +# THREAT MODEL — is this an oring bug, and how bad is it? + +This file classifies a behaviour. It enumerates no residuals: `docs/DESIGN.md` §2 holds +those with the loss each belongs to, and `CONSTRAINTS.md` holds what oring claims to +enforce and what enforces it. **With a behaviour in hand, start at §4.** + +`tests/threat_model.rs` checks that every id written in backticks below resolves in +`CONSTRAINTS.md`, that this file carries none of the shapes that hold evidence in the +other two, and that `SECURITY.md` and `README.md` still link it. It does not check that +any rule here is correct; §10 says what this file does not establish. + +Modelled on Mickaël Salaün's *"landlock: Document the threat model"* (2026-07-07, +`Documentation/security/landlock.rst`). Three points of departure are marked +**DIVERGES FROM LANDLOCK** below. + +--- + +## 1. The parties + +Four. §4 starts by asking which one acted, because the answer changes the +classification. + +- **The child, after the seal.** `restrict_self` + `execve` have both happened, every + ring is in force and irrevocable. Assume it holds arbitrary native code at uid 1000 + (prompt-injection → shellcode is the motivating path). **This is the adversary**, and + containment breach is the primary loss. Nothing in the level structure below demotes + that; if a taxonomy reading and the adversary reading ever conflict, the adversary wins + and the taxonomy is wrong. +- **The caller, before the seal.** Trusted to author a policy; not trusted to widen one + ambiently. Authored sources are `--policy`, `--profile`, `--tmpdir`, + `--exec-tmp`. Ambient sources are `ORING_POLICY`, `$TMPDIR`, `$HOME`. Where an ambient + source would override or widen an authored one, oring refuses rather than picking a + winner. This is `CON-6`, and it is a **narrower** trust grant than "the caller is + trusted": three shipped guards refuse ambient widening rather than resolve it. +- **The operator, after the run.** Holds a workdir the child could write, and executes it + later at their own uid, outside the cage. Their process model is supplied by oring's + documentation, which makes **the documentation a control action and the operator the + controller** — the relation `CON-7` names. +- **oring itself.** There is no process boundary between sealer and sealed: `execve` + preserves the process, so oring *is* the child. Every claim below assumes oring's own + machinery is intact, and `docs/DESIGN.md` §2's *what could be wrong with ORING ITSELF* + residuals are where that assumption is examined rather than asserted. + +## 2. What oring guarantees + +One sentence, and it is `CON-1`: **every capability the child obtains is named by the +policy, or is named on the seal line as unrestricted.** One measured case sits on the +boundary of that sentence and is not settled: a path the policy withholds, handed to the +child by the caller on fd 0. §6 states it, says why it is open, and says to report it. + +Two consequences worth stating because reports turn on them: + +- **A grant is not a residual.** If `--explain` prints the tier, the access is the + policy's decision, not oring's defect. The workdir being writable under `balanced` is a + grant. +- **An unhandled right is disclosed on stderr, not silently dropped.** `unhandled=` on + the seal line names what the run left unrestricted, and `--require-full-coverage` + converts it into refusal code 120 before `execve`. That flag is opt-in; without it, a + caller who redirects stderr removes the disclosure. `CON-5` records this as a live + limitation with the measurement behind it. + +## 3. Three levels, and why they are not one ranking + +`CONSTRAINTS.md` defines these ids; this file only cites them. + +- **Losses** (`BREACH`, `LAUNDER`, `DIVERGE`) — what the operator actually suffers. + Ordering them is **not a technical output**: in STAMP terms losses are + stakeholder-defined, so the ranking belongs to whoever bears them and is recorded in + the maintainer's decision log, not derived here. +- **Hazards** (`HZ-REACHABLE`, `HZ-DIVERGENCE`, `HZ-INSTRUCTED`) — system *states* that + lead to a loss. "Behaves differently confined than unconfined" is a state, not a loss. +- **Feedback defects** (`MISREPORT`, `OPAQUE`) — defects in what oring *reports*. These + are **multipliers, not ranks**: they act on the operator's ability to detect and + correct every loss above them, so the question to ask is never "is `MISREPORT` worse + than `BREACH`" but "which losses does this one stop anybody noticing". + +**Items from different levels cannot be linearly ordered**, and retracted inferences in +this project's history were exactly that error, among them choosing between *two +mitigations of one loss* using a loss rank. If a report or a review compares two items, +check they are on the same level first. + +## 4. Is it an oring security bug? Check in order + +1. Which party performed the action? If the caller handed the child a capability — + an fd redirected onto stdin, a policy granting the path — see §6 first: that is + ordinarily *not* a bypass, and one measured case sits on that boundary unruled. +2. Was the capability named by the policy, or named on the seal line as unrestricted? + If yes, it is a grant or a disclosed gap. Not a security bug; §8 says which + non-security reports are still worth sending. `--explain` and `unhandled=` are how you + check. +3. Does a named residual in `docs/DESIGN.md` §2 already cover it? If yes, it is a + documented residual and not a security bug — **but check the residual's own scope + before closing the report.** A residual that is *wider than the sentence recording it* + is a bug in the record, and this tree has a measured instance. `docs/ATTACKS.md` + recorded delivery over an inherited descriptor in its peer-procfs form, and the + measurement found the same delivery working for **any** withheld path under both + shipped profiles. The record was narrower than the mechanism. What matters is whether + the mechanism, rather than the example, is the one written down. +4. Did the child obtain a capability the policy did not grant? That is `BREACH`, and + it is a security bug. Report it. +5. Does an instrument assert a state a same-run measurement contradicts? A seal field + that reads clean while the thing it names is false is `MISREPORT`, and **it is a + security bug even though nothing escaped.** A forged or misleading audit record is + worse than no record: missing feedback stops a controller, corrupted feedback steers + it, and a forged seal line has been injected through argv alone with no policy file + involved (`docs/DESIGN.md` §2). +6. Did the run leave the system with a broader or longer-lived exposure than it found? + That is `LAUNDER`, and it is in scope **with nobody attacking**. See §7. +7. Did oring deny something the policy granted? That is over-enforcement — a + *compatibility* bug (`CON-8`), not a security bug. Still report it; the fix relaxes + rather than tightens, and the class matters because every tightening oring ships is a + candidate violation of it. + +If none of the above fires, the behaviour is out of scope, and §8 says which kind of out +of scope. + +## 5. Under-enforcement, over-enforcement, and why oring is not best-effort + +- **Enforcing less than the policy names is under-enforcement: a security bug.** The fix + may make a deployed policy stricter, but only by finally enforcing what it already + requested. +- **Enforcing more than the policy names is over-enforcement: a compatibility bug + (`CON-8`).** + +**DIVERGES FROM LANDLOCK — oring is not best-effort.** Landlock's model is explicitly +best-effort: it enforces what the running kernel supports rather than refusing to sandbox +at all, because a program and its kernel are built independently. oring inverts that at +the substrate boundary. Below the Landlock ABI floor it **refuses to seal** rather than +installing a partial cage (`CON-9`), and a `RulesetStatus` of `NotEnforced` or +`PartiallyEnforced` is a hard refusal. So *"oring ran and enforced less than the policy +named because the kernel could not"* is not a limitation to be tolerated here — it is +either a refusal you should have seen, or a bug. The one place the best-effort pattern +survives is `unhandled=`: a right the running kernel cannot handle is disclosed and the +run proceeds, and that is exactly the degraded-path-with-a-warning shape this project +rejects elsewhere. It is recorded as an open product question in `CON-5`, not defended. + +## 6. Resources the caller hands in — and the one case that is still open + +**Landlock's rule, quoted because oring inherits most of it:** *"receiving or inheriting +a file descriptor (or similar) is governed by the capability model, not Landlock … a +possible confused deputy, not a bypass."* A domain restricts operations initiated under +it, not capabilities the process already holds. oring `execve`s a fresh image and passes +no sockets, so what crosses the seal is what the **caller** put there. + +**DIVERGES FROM LANDLOCK, and this is the open question.** Landlock's caller is an +unsandboxed process that willingly passes a resource in. oring's caller is the party +`CON-6` says may author a policy but may **not** widen one ambiently — the tree ships +three guards that refuse rather than resolve that ambiguity. Importing the confused-deputy +clause wholesale would license, by construction, exactly the widening those guards refuse. + +The measured instance that decides it: **any path the policy withholds is deliverable to +the confined child on fd 0**, under both shipped profiles, with a seal line byte-identical +to the run that denied it (`docs/DESIGN.md` §2, the *resource the domain did not create* +residual). Under *"the child obtains a capability the policy did not +grant"* that is `BREACH`; under *"the child escaped"* it is not. **The definition is +ambiguous, the ambiguity is load-bearing, and it is the maintainer's to rule on** — this +file states the question rather than settling it by quoting a document written for a +different system. Open and unruled as of 2026-07-30. Until it is ruled: **report it, and +it will be triaged, not rejected.** + +What is *not* open: fds 0–2 are deliberately preserved (a child without stdio is broken) +and fd 3 and above are scrubbed. That is a design decision with a stated reason, not an +oversight, though no fixture covers the scrub at either end (`CON-1`). + +## 7. Blast radius + +Two dimensions, and one exception that is oring's alone. + +- **Within a run.** A defect in a domain-wide mechanism — the seal order, the ABI floor, + credential handling — can drop every restriction the run installed. A missing rule for + one right affects only policies that named that right and leaves the rest intact. +- **Across runs.** A policy is self-imposed per invocation, not system-wide. A defect + weakens only the runs that requested the affected restriction. There is no persistent + component, no daemon, and nothing survives the child's exit. +- **DIVERGES FROM LANDLOCK — the exception, and it is the reason `LAUNDER` exists.** + Landlock's mechanism only *adds* restrictions on top of existing access controls, so an + enforcement bug can at most undo Landlock's own. oring is a mechanism plus a set of + profiles plus documented operator procedure, and the last two can leave the machine + worse than not running oring at all. Both are measured: a profile that granted write on + a shared package cache turned a bounded single-project hazard into a persistent + cross-project one, and a documented review control fired the payload it existed to + detect, four times, at the operator's uid, outside the cage. **A run that is worse than + no run is a first-class loss here**, and it needs no attacker. `CON-2` carries it; the + enforced half is the profiles, not the procedure. + +## 8. What is NOT an oring security bug + +Out of scope as a *security* bug. Several are still worth reporting, and each entry says +where it belongs. + +- **A documented residual, reported as itself.** `docs/DESIGN.md` §2 enumerates what + oring does not stop, with the loss each belongs to and whether any instrument reports + it. Read it before reporting. A residual *wider than its record* is a different report + and is in scope (§4.3). +- **Anything oring's non-goals surrender.** It is not a VM boundary, not a kernel-0-day + defence beyond surface reduction, not a network policy engine (domain/IP egress is a + required companion control at the host layer), does not author host MAC, does not mask + procfs/sysfs, and ships no supervisor, broker, cgroup or rlimit machinery — each needs a + persistent or privileged component oring by definition is not. `docs/DESIGN.md` §2 holds + the list; this is a pointer, not a second copy. +- **Denial of service.** A compromised child can fork-bomb, fill the workdir, or exhaust + the host. Surrendered by the non-goal above, deliberately, not overlooked. +- **Information disclosure about the policy.** Which errno a denial returns, whether a + path exists, how long an operation took — these can reveal policy shape. Not a security + bug, except where the denial's *report* is false, which is `MISREPORT` (§4.5). +- **What a retained privilege buys outside oring's coverage.** oring is unprivileged and + self-applied; dropping privileges is the caller's job, and `NO_NEW_PRIVS` prevents + regaining them, not holding them. +- **The behaviour of a service the child is allowed to reach.** If the policy grants a + socket, what the service on the other end does with the child's request is that + service's authorization problem. A more-privileged service acting on a confined + client's request without checking is a confused deputy in the service. +- **A tool breaking under confinement because it pre-flights with `access()`.** Landlock + hooks the open, not the metadata check, so a denied path stays visible and a tool that + trusts the two to agree walks into a branch it never tests. This is structural — no ABI + bump changes it — and it is a diagnosability and compatibility residual, tracked as + `CON-3`. It is not a containment hole. It is worth reporting: which paths the profiles + should grant, to cut how often it is hit, is still open. + +## 9. Sandboxing is layered + +oring is the access-control layer of a sandbox, not the sandbox. Three companion controls +are required, not optional, and their absence is not an oring bug: + +- **Host-layer egress control** — Landlock's network rights are port-only, so + domain/IP-level egress needs host nftables default-deny or a filtering proxy. +- **Runtime masking and PID namespacing** — masking `/proc` and `/sys`, and hiding peer + PIDs, are the container runtime's job. oring's scope ring is defence-in-depth for the + signalling and abstract-socket half only; for peer `/proc` reads the oring-side control + is `--proc self`, which narrows the residual at a fork-compatibility cost and is not a + replacement either (`docs/DESIGN.md` §2). +- **Operator procedure after the run** — a workdir an agent touched is untrusted input. + **Do not use `git` to inspect it**: repo-local `.git/config` is read on every `git` + command and `core.fsmonitor`, `core.pager`, `core.sshCommand`, `alias.*` and + `diff.*.textconv` are each an execution trigger, so the review *is* the execution. + What survives measurement is inspecting with tools that do not interpret the tree as a + repository, or re-cloning into a throwaway checkout. `docs/DESIGN.md` §2 carries the + measurement and the two retractions that produced this sentence. + +## 10. What this file does not establish + +- **It cannot tell you a classification is correct.** It fixes the *procedure*; whether a + given behaviour was filed under the right loss is adversarial review's job and stays + with people. +- **It is a control action with no measured efficacy.** oring's documentation sets the + operator's process model (§1), and the gap is `CON-7`'s: **no fixture in this tree runs + a documented control against the residual it is named for.** That row, not this file, + carries its status. This file is another control action under it and inherits the gap. +- **The `BREACH` boundary is open** (§6). It is the one definitional question a reporter + can hit today with no rule to apply. +- **`abi=` and `errata=` are not behaviour certificates.** `errata=` is a lower bound: it + enumerates only errata the kernel declares, and two kernels agreeing on both may still + differ in behaviour. + +## 11. Reporting + +Route and expectations are in [`SECURITY.md`](SECURITY.md). **If §4 leaves you unsure, +send it anyway:** rejecting a report is cheaper than missing one, and the ambiguity in §6 +is real. diff --git a/bench/README.md b/bench/README.md new file mode 100644 index 0000000..91bfedf --- /dev/null +++ b/bench/README.md @@ -0,0 +1,135 @@ +# bench/ — what oring costs, measured + +oring shipped v1 with **one** cost claim in the whole tree (`docs/DESIGN.md` §5, +the errno-partition bullet) and no instrument behind it. This directory is the +instrument. + +The measuring lives in `src/bin/bench.rs` — in the package, not beside it, so +`make check`'s `cargo clippy --all-targets` compiles it and it cannot rot as the +code it measures moves. It calls `seccomp::dump_groups` directly, so what it +times is what `seccomp::apply` installs. `bench/run.sh` supplies what a single +measurement cannot: the pinning, the rounds, the controls, and the frame. + +```sh +bench/run.sh # everything, with controls, ~3 minutes +bench/run.sh --cpu 6 --rounds 3 # defaults +cargo run --release --features resolve-unix,bench --bin bench -- help +``` + +| question | subcommand | term it isolates | +|---|---|---| +| how long does one `oring … -- cmd` add? | `exec N EXPECTED cmd…` | fixed per-invocation cost | +| what does installing ring 2 cost? | `install` | seccomp filter install (BPF JIT) | +| ring 2 per syscall — and per *filter*? | `filters K N` | seccomp entry vs. filter count | +| what does ring 1 cost per `open()`? | `opens PATH N` | Landlock path mediation | +| what are the per-path seal costs made of? | `paths DIR N` | `canonicalize`/`metadata`/`open` | +| how far is the emitted cBPF walked? | `sim` | filter path length, no kernel needed | + +## The discipline these harnesses enforce, and why + +**Check the exit status of the thing you timed.** The first draft of this sweep +reported `--profile strict` as *faster than the bare command* — that invocation +exited 121 (`--workdir must be absolute`) with stderr on `/dev/null`, so it never +sealed and never exec'd. A clean-looking fast number over a command that did not +run. `bench exec` takes the expected status and prints **no timing at all** if any +run disagrees; a number beside a warning gets quoted without the warning. + +The same shape caught a second one: section 5 first failed with `spawn /bin/true: +Permission denied`, because the harness redirects its children to `/dev/null` and +the policy did not grant it. The C fixture this replaced had silently degraded +instead — it checked `open("/dev/null")` for failure and skipped the redirect, +so it measured a *different* thing and reported a number for it. + +**Interleave the control, and print the warm-up you discarded.** This box is +shared. Every section runs its control on the same pinned CPU in the same round, +and rounds are printed individually rather than averaged. Round 1 reads high +(measured: `/bin/true` at 175 µs in round 1 against 126 µs in rounds 2-3, a 39% +frequency ramp). If rounds 2..N disagree with each other, the box was busy and +the run is not quotable. + +**A simulation is not a profile.** `bench sim` interprets the emitted cBPF and +reports how far an allowed syscall walks. That is a model of the filter, +corroborated by `install`/`filters` but not a substitute: 215 instructions and ++4.4 ns do not reconcile arithmetically, and the reason is what the `filters` +sweep measures rather than what `sim` asserts. + +## What it measured, 2026-07-30 + +Host `7.2.0-rc4-oring`, 32 cores, `bpf_jit_enable=1`, load 0.3-0.5, `taskset -c 6`, +3 rounds, round 1 discarded as warm-up. **glibc host release build — not the +shipped static-musl artifact**; the process-start term differs there, the +kernel-side terms do not. + +Per invocation — `/bin/true` costs 127 µs; under oring it costs 758 µs (p50): + +| term | cost | scales with | +|---|---|---| +| oring's own process start | +73 µs (200 vs 127) | binary size | +| policy parse + validate + ABI probe | +38 µs (`--check` 238) | policy size | +| **ring 2: 4 seccomp filter installs** | **227 µs** | filter count × length | +| ring 1 + the rest of the seal | ~90 µs at 8 paths | path count, at 4.8 µs/path | +| the second `execve` any wrapper implies | +203 µs (`env` control) | fixed | +| **total added** | **+631 µs** | | + +The install is 72% of the ~317 µs the seal itself costs (758 − 238 − 203), and +36% of everything oring adds. Note the shape *within* it: `enosys` (100 insns) +costs 61 µs while `eopnotsupp` (105 insns) costs 22 — the first install carries a +one-time ~40 µs, and after that cost tracks program length (`eperm`, 457 insns, +120 µs). + +Steady state, inside the cage: + +| term | cost | scales with | +|---|---|---| +| per syscall | +4.4 ns (33.7 → 38.1 ns, +13%) | **seccomp entry, not filters** | +| per `open()`, warm cache | +0.175 µs (0.461 → 0.636, +38%) | rule count, but barely — see below | +| per `fork`+`execve` inside the cage | +1-2 µs on 126 (≈1%) | — filters and domain are inherited | + +The filter-count sweep is the load-bearing one (rounds 2-3): + +``` +filters=0 33.8 / 33.6 ns filters=2 37.6 / 38.3 +filters=1 38.2 / 38.4 ns filters=3 39.1 / 38.4 + filters=4 39.3 / 38.7 +``` + +Effectively all of it is the cost of *entering* the seccomp path: the step is at +k=1 and filters 2-4 add ~0.5 ns between them, inside the round-to-round spread — +even though an allowed syscall walks ~215 cBPF instructions across the stack. +**The errno partition costs nothing measurable at runtime and 227 µs at install** +— the opposite of what §5's cost sentence implied. + +Ring 1's per-`open` cost does depend on rule count, slightly and reproducibly: +0.634/0.642/0.634 µs at 8 rules against 0.647/0.645/0.646 at 43 — about +0.012 µs +for 35 extra rules (~0.3 ns/rule), i.e. **7% of the mediation cost across a 5× +larger ruleset**. Flat enough to plan around, not flat enough to state as flat. + +Two consequences worth carrying: + +- **The fixed per-invocation cost dominates; amortisation is the only lever.** A + subprocess tree of any size under one seal pays the 631 µs once. Wrapping a + per-item sweep item-by-item multiplies it by the item count; wrapping the sweep + pays it once. +- **`strict` is not slower than `balanced`.** 864 instructions against 809, 9 + more per allowed-syscall miss, no measurable runtime difference. + +## What is still unattributed + +Of the 4.8 µs per granted path, ~2.0 µs is accounted for: `metadata` 0.22 µs + +`PathFd`'s `O_PATH` open (≤0.46 µs — `bench paths` prices `File::open`, which is +an upper bound) + the ≥3 `canonicalize` passes every declared path takes at +0.44 µs each. Those passes are in `apply_proc_mode`, `check_tier_overlap`, +`proc_exposure`, and 5× per *write* path in `writable_shared_caches`, where the +canonicalise sits inside the `SHARED_CACHES` loop. + +The remaining ~60% needs a profile of oring itself, which is what +`[profile.profiling]` exists for: + +```sh +cargo build --profile profiling --features resolve-unix +perf record -F 997 -g -- +``` + +Check the root frame's sample count against rate × duration before reading +anything off it. A 758 µs process sampled at 997 Hz needs thousands of iterations +or the profile is idle and every conclusion from it is void. diff --git a/bench/run.sh b/bench/run.sh new file mode 100755 index 0000000..58d5c33 --- /dev/null +++ b/bench/run.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: BSD-3-Clause +# +# run.sh — what oring costs, with the control beside every number. +# +# bench/run.sh [--cpu N] [--iters N] [--rounds N] [--keep] +# +# The measuring is all in `src/bin/bench.rs`; this supplies what a single +# measurement cannot: the pinning, the rounds, the controls, and the FRAME — +# host, kernel, pinned CPU, iteration count, path count, filter count, printed +# before any number. A number without its frame is not a measurement, and this +# tree has paid for that lesson more than once. +# +# Every timed invocation has its exit status checked: `bench exec` takes the +# expected status and refuses to print a time if any run disagrees. The first +# draft of this sweep reported a REFUSED oring invocation as "faster than the +# bare command"; that is the failure mode this script is shaped around. +# +# Rounds are printed, never averaged away. Round 1 is frequency warm-up on most +# hosts and reads ~40% high. If rounds 2..N disagree with each other the box is +# too busy and the run is not quotable — say so rather than picking one. +set -euo pipefail + +CPU=6 +ITERS=400 +ROUNDS=3 +KEEP=0 +SYSN=3000000 # syscalls per `bench syscalls` / `bench filters` loop +FSN=200000 # opens per `bench opens` loop +PATHN=200000 # iterations per `bench paths` primitive +EXTRA=35 # extra granted paths in the second policy (isolates the per-path term) + +while [ $# -gt 0 ]; do + case "$1" in + --cpu) CPU="$2"; shift 2 ;; + --iters) ITERS="$2"; shift 2 ;; + --rounds) ROUNDS="$2"; shift 2 ;; + --keep) KEEP=1; shift ;; + -h|--help) sed -n '4,21p' "$0"; exit 0 ;; + *) echo "run.sh: unknown argument $1" >&2; exit 2 ;; + esac +done + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +# Pinning is not decoration: this box is shared and an unpinned loop migrates +# across cores mid-run. If taskset is absent we still run, but the frame says so +# — conditions left unstated are worse than no number. +if command -v taskset >/dev/null 2>&1; then + PIN=(taskset -c "$CPU"); PINNED="taskset -c $CPU" +else + PIN=(); PINNED="NOT PINNED (taskset absent) — treat every number as noisy" +fi + +TMP="$(mktemp -d "${TMPDIR:-/tmp}/oring-bench.XXXXXX")" +cleanup() { [ "$KEEP" = 1 ] || rm -rf "$TMP"; } +trap cleanup EXIT +mkdir -p "$TMP/data" +# Inherited TMPDIR makes oring print an INFO line into every measurement's +# output. Drop it AFTER mktemp has used it: the note is correct and belongs in +# real runs, it just is not part of what is being measured here. +unset TMPDIR + +echo "== building ==================================================================" +cargo build --release --features resolve-unix --locked >/dev/null +cargo build --release --features resolve-unix,bench --bin bench --locked >/dev/null +ORING="$ROOT/target/release/oring" +BENCH="$ROOT/target/release/bench" +head -c 4096 /dev/urandom > "$TMP/data/f" + +# Two policies differing ONLY in path count, so the per-path term is isolated +# rather than inferred. Nothing is writable, so the policy files cannot land +# under a write tier and oring's self-protection guard stays quiet. All four net +# fields are set, so `unhandled=` is `-` and no coverage warning muddies stderr. +BIN_DIR="$(dirname "$BENCH")" +net='"egress_tcp":[],"ingress_tcp":[],"egress_udp":[],"ingress_udp":[],"scope_ipc":true' +ro7="\"/etc\",\"/proc\",\"$TMP/data\"" +# /dev/null is granted because the harness redirects its children there, and a +# policy that cannot open it fails the exec measurement rather than slowing it — +# which is precisely the shape section 5 caught the first time this ran. +wr='"/dev/null"' +rx="\"/usr\",\"/lib64\",\"/lib\",\"$BIN_DIR\"" +printf '{"version":1,"landlock":{"read_exec":[%s],"read_only":[%s],"write_noexec":[%s],%s},"seccomp":{"profile":"balanced"}}\n' \ + "$rx" "$ro7" "$wr" "$net" > "$TMP/pol7.json" + +roN="$ro7" +for i in $(seq 1 "$EXTRA"); do + d="$TMP/paths/$i"; mkdir -p "$d"; roN="$roN,\"$d\"" +done +printf '{"version":1,"landlock":{"read_exec":[%s],"read_only":[%s],"write_noexec":[%s],%s},"seccomp":{"profile":"balanced"}}\n' \ + "$rx" "$roN" "$wr" "$net" > "$TMP/polN.json" +N7=8; NN=$((N7 + EXTRA)) + +export ORING_QUIET=1 + +cat </dev/null || echo '?') + iterations exec: $ITERS syscall: $SYSN open: $FSN path: $PATHN + policies pol7 = $N7 paths, polN = $NN paths (identical but for path count) + rounds $ROUNDS (round 1 is frequency warm-up on most hosts — printed, not dropped) +EOF + +echo +echo "== 1. per invocation: what one \`oring -- cmd\` adds ==========================" +echo " controls: the bare command; \`env cmd\` (a second execve, no confinement);" +echo " oring --version (start+exit, no policy); oring --check (parse+validate, no seal)" +for r in $(seq 1 "$ROUNDS"); do + echo " -- round $r" + printf ' %-30s ' 'bare /bin/true' + "${PIN[@]}" "$BENCH" exec "$ITERS" 0 /bin/true + printf ' %-30s ' 'env /bin/true (2 execs)' + "${PIN[@]}" "$BENCH" exec "$ITERS" 0 /usr/bin/env /bin/true + printf ' %-30s ' 'oring --version' + "${PIN[@]}" "$BENCH" exec "$ITERS" 0 "$ORING" --version + printf ' %-30s ' 'oring --check (no seal)' + "${PIN[@]}" "$BENCH" exec "$ITERS" 0 "$ORING" --policy "$TMP/pol7.json" --check -- /bin/true + printf ' %-30s ' "oring seal, $N7 paths" + "${PIN[@]}" "$BENCH" exec "$ITERS" 0 "$ORING" --policy "$TMP/pol7.json" -- /bin/true + printf ' %-30s ' "oring seal, $NN paths" + "${PIN[@]}" "$BENCH" exec "$ITERS" 0 "$ORING" --policy "$TMP/polN.json" -- /bin/true +done + +echo +echo "== 2. ring 2: installing the errno-partitioned filters ======================" +echo " the term docs/DESIGN.md §5 did not name, and the one that dominates" +for r in $(seq 1 "$ROUNDS"); do + echo " -- round $r" + "${PIN[@]}" "$BENCH" install +done + +echo +echo "== 3. ring 2: ns/syscall as a function of FILTER COUNT ======================" +echo " k=0 is the control. If the cost were the linear partition scan this would" +echo " rise with k; if it is seccomp entry it steps once at k=1 and then flattens." +for r in $(seq 1 "$ROUNDS"); do + echo " -- round $r" + for k in 0 1 2 3 4; do printf ' '; "${PIN[@]}" "$BENCH" filters "$k" "$SYSN"; done +done + +echo +echo "== 3b. the same term under a REAL seal (control for 3) ======================" +for r in $(seq 1 "$ROUNDS"); do + printf ' -- round %s unsealed: ' "$r"; "${PIN[@]}" "$BENCH" syscalls "$SYSN" + printf ' sealed: ' + "${PIN[@]}" "$ORING" --policy "$TMP/pol7.json" -- "$BENCH" syscalls "$SYSN" +done + +echo +echo "== 4. ring 1: us/open+close, and whether it scales with RULE COUNT ==========" +for r in $(seq 1 "$ROUNDS"); do + printf ' -- round %s unsealed : ' "$r"; "${PIN[@]}" "$BENCH" opens "$TMP/data/f" "$FSN" + printf ' sealed %-2s : ' "$N7" + "${PIN[@]}" "$ORING" --policy "$TMP/pol7.json" -- "$BENCH" opens "$TMP/data/f" "$FSN" + printf ' sealed %-2s : ' "$NN" + "${PIN[@]}" "$ORING" --policy "$TMP/polN.json" -- "$BENCH" opens "$TMP/data/f" "$FSN" +done + +echo +echo "== 5. fork+exec from INSIDE the cage vs outside ==============================" +echo " filters and the Landlock domain are inherited, so this measures whether a" +echo " subprocess tree RE-PAYS the seal. If these differ, section 1's cost is" +echo " per-process; if they do not, it is per-oring-invocation and amortisable." +for r in $(seq 1 "$ROUNDS"); do + printf ' -- round %s outside: ' "$r"; "${PIN[@]}" "$BENCH" exec "$ITERS" 0 /bin/true + printf ' inside: ' + "${PIN[@]}" "$ORING" --policy "$TMP/pol7.json" -- "$BENCH" exec "$ITERS" 0 /bin/true +done + +echo +echo "== 6. primitives underneath the per-path seal cost ==========================" +for r in $(seq 1 "$ROUNDS"); do + echo " -- round $r" + "${PIN[@]}" "$BENCH" paths "$TMP/data" "$PATHN" +done + +echo +echo "== 7. emitted cBPF: how far an allowed syscall walks (model, not a timing) ==" +echo " -- balanced" +"$BENCH" sim | sed 's/^/ /' +echo " -- strict" +"$BENCH" sim --strict | sed -n '1,7p' | sed 's/^/ /' + +echo +echo "== done. Rounds are printed individually on purpose: if 2..$ROUNDS disagree, the" +echo " box was busy and this run is not quotable. Numbers of record: bench/README.md." diff --git a/deny.toml b/deny.toml index cabcbd9..cc34afb 100644 --- a/deny.toml +++ b/deny.toml @@ -32,12 +32,24 @@ confidence-threshold = 0.8 # by [advisories] + the [sources] checksum lock. multiple-versions = "warn" wildcards = "warn" -# `landlock` is the published crates.io crate (Ring 1). It is a normal registry -# dependency with a `source` + `checksum` in Cargo.lock, so `[advisories]` -# (RustSec) and `[sources]` cover it like any other crate — no special handling. -# The ABI V9 / RESOLVE_UNIX hardening is an opt-in `resolve-unix` feature built -# against an out-of-tree fork override (see .cargo/config.toml.example); that -# override is a developer/CI concern and never part of the checked-in graph. +# `landlock` (Ring 1) is NOT a clean registry dependency, and this file cannot +# pretend otherwise. The **V10** floor needs the UDP net rights, which no +# published release carries, so `Cargo.toml` has a committed `[patch.crates-io]` +# pointing at the maintainer's fork, pinned by revision. `Cargo.lock` therefore +# carries a real `source` for it — better than the path patch this replaced, +# which had no `source` and no `checksum` at all — but `[advisories]` (RustSec) +# is keyed to registry packages and still covers NOTHING here. The audit surface +# for ring 1 is reading the pinned revision, plus `cargo audit bin` against the +# shipped SBOM. This is the stated +# price of the floor [D-4b]; when upstream releases **V10**, drop the patch and +# this paragraph reverts to "covered like any other crate" with no behaviour +# change. +# +# Corrected 2026-07-29: this said the floor needed `AccessFs::ResolveUnix` which +# "the published crate (V7) cannot name", and that the trigger was upstream +# releasing V9. Upstream released V9 in v0.4.7, so acting on the old trigger +# would drop the patch while the floor still requires V10 — see the longer note +# in `Cargo.toml`. deny = [] skip = [] skip-tree = [] @@ -57,4 +69,15 @@ executables = "deny" unknown-registry = "deny" unknown-git = "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"] -allow-git = [] +# DONE 2026-08-02, and it is a real widening rather than a formality. Ring 1 now +# arrives as a GIT dependency pinned by rev (`Cargo.toml`, `[patch.crates-io]`), +# so `cargo deny check sources` — which is in `make check` — needs this URL +# allowed or the gate fails on every run. +# +# What is admitted: a source `[advisories]` still cannot scan. What is gained +# over the path patch it replaces: `Cargo.lock` pins a commit SHA, where a path +# patch carried no `source` and no `checksum` at all. So this is better +# IDENTIFICATION of an input that remains unscanned — not scanning. The audit +# surface for ring 1 is still reading the fork, plus `cargo audit bin` against +# the shipped SBOM. +allow-git = ["https://github.com/eslerm/rust-landlock"] diff --git a/docs/ATTACKS.md b/docs/ATTACKS.md index 3cff8f8..4dbf03d 100644 --- a/docs/ATTACKS.md +++ b/docs/ATTACKS.md @@ -27,13 +27,16 @@ Run: `cargo test` (needs a Landlock kernel; see §11d for the CI skip contract). | 3e | `pipe2(O_NOTIFICATION_PIPE)` to construct a watch_queue — the LPE-launchpad class oring already deletes via keyctl/userfaultfd/io_uring | `EPERM` | `deny_syscall::pipe2_notification_pipe_is_blocked` | | 3f | (control) plain `pipe2(O_CLOEXEC)`, which every shell needs | allowed | `deny_syscall::plain_pipe2_still_works` | | 3g | `mount_setattr` / `quotactl_fd` / `open_tree_attr` — the variants left open beside blocked `mount`/`quotactl`/`open_tree`. `mount_setattr` can clear `MS_NOEXEC`, a W^X concern for `strict` | `EPERM` | `deny_syscall::mount_and_quota_variants_are_blocked` | +| 3h | **Egress via `IPPROTO_MPTCP`** — `sk_is_tcp()` is false for MPTCP (erratum 1 narrowed the TCP rights to real TCP *on purpose*), so ring 1's `ConnectTcp` never sees it. **Measured bypass before this rule:** under `--profile strict` (`egress_tcp: []`, "deny-all TCP egress") `IPPROTO_TCP` got `EACCES` while `socket(AF_INET, SOCK_STREAM, IPPROTO_MPTCP)` connected to a plain-TCP listener and delivered 24 bytes. Ring 2 now allows, inside `AF_INET`/`AF_INET6`, only the protocols ring 1 can mediate | `EAFNOSUPPORT` | `protocol_wall::mptcp_socket_is_refused_under_strict` | | 4 | (control) `connect()` to an allowed port | not Landlock-blocked | `deny_net::connect_to_listed_port_not_eacces` | | 5 | `ptrace` a peer to steal memory/inject | seccomp `EPERM` | `deny_syscall::ptrace_is_eperm` | | 6 | `keyctl` keyring LPE surface | seccomp `ENOSYS` | `deny_syscall::keyctl_is_enosys` | | 7 | `io_uring_setup` async-IO LPE surface | seccomp `ENOSYS` | `deny_syscall::io_uring_is_enosys` | | 8 | `unshare(CLONE_NEWUSER)` **alone** (userns LPE launchpad) | seccomp `EPERM` | `deny_syscall::unshare_newuser_alone_is_eperm` | -| 9 | Re-enter a blocked syscall via the **x32 ABI** (`nr | 0x40000000`) | seccomp `SIGSYS` kill | `deny_x32::x32_call_is_killed` | +| 9 | Re-enter a blocked syscall via the **x32 ABI** (`nr \| 0x40000000`) | seccomp `SIGSYS` kill | `seccomp::fv_tests::x32_guard_matches_its_spec_over_a_dense_table` (the mutation-backed one — 12,354 points, model-level) + `deny_x32::{x32_call_is_killed, x32_call_is_killed_at_the_bottom_of_the_numbering, x32_bare_is_not_signalled}` (the kernel anchor) + Kani `x32_guard_total` (total). **Do not cite `x32_call_is_killed` alone** — measured 2026-07-28, it *survives* the mutant that guts the guard | | 10 | `connect()` to an **abstract** AF_UNIX socket | IPC scope (V6, at floor) → `EPERM` | `containment_matrix` `unix-abstract-{balanced,strict}` | +| 11 | `connect()` to a **pathname** AF_UNIX socket (`docker.sock`, `/tmp/ssh-*/agent.*`) — and `sendmsg(2)` to one with an explicit recipient address, which the same right covers | Landlock `RESOLVE_UNIX` (ABI V9; **below oring's floor**, so guaranteed at every seal) → `EACCES` | `containment_matrix` `unix-connect-{balanced,strict}` (errno) + `denial_attribution` `unix_connect_denial_names_resolve_unix` and `addressed_sendmsg_denial_names_resolve_unix` — **both halves covered.** The latter two assert the *mechanism*, not the placement: they read the kernel's own `blockers=fs.resolve_unix` back through `oring --denials`, so a denial caused by an ungranted path cannot pass for the V9 wall | +| 12 | Read an **out-of-domain** process's `/proc//environ` under `--proc full` — one **not** sealed by this run (corrected 2026-07-27: this said *"steal a sibling's credentials"*, and a sibling is the one relationship where the wall does NOT hold — `domain_scope_le()` is ancestor-or-equal, so two children of ONE seal read each other; measured `--proc full` readable. The `--proc self` denial this cited alongside it was removed 2026-07-28: it is real but is an **FS-tier** denial of an ungranted `/proc/` path, not the domain relation, so it was never the matched half of that measurement — see the row-12 note) | `EACCES` (the hook returns `EPERM`; `mm_access()` maps it) from `hook_ptrace_access_check` — **not** bought by the Landlock floor at any version; the rule predates 5.13; see note for what actually decides it | `containment_matrix` `proc-peer-env-full` | Row 8 is the load-bearing per-flag assertion: a single combined `CLONE_NEW*` mask would only match all-bits-set, so `NEWUSER` by itself is tested explicitly. @@ -54,21 +57,53 @@ bpf, …) is bypassable on any `CONFIG_X86_X32_ABI=y` kernel. These are **not** oring bugs; they are the honest residuals from `docs/DESIGN.md` §2. Listed here so the boundary is explicit, not discovered. -- **[ ] Peer procfs read** — `/proc//{environ,maps,mem}` under `--proc full`. - These are VFS `read()`s gated only by `ptrace_may_access`, and **Landlock does - not reliably wall them at the V7 floor** (measured NotBlocked on kernel 6.15, - cell `proc-peer-env-full`); a newer kernel's `ptrace_access_check` hook was seen - to deny them on 7.1.3, but that's a version bonus, not a floor guarantee. - Control: `--proc self`, or a runtime PID-namespace + `maskedPaths`. -- **[ ] Any-IP egress on an allowed port** (443 → exfil) and **DNS/UDP** — Landlock - net is port-only. Control: host nftables default-deny + a filtering proxy. -- **[ ] `connect()` to a *pathname* AF_UNIX socket** (`docker.sock`, `ssh-agent`) — - blocked **only** when built `--features resolve-unix` on a Landlock V9 (≥7.1) - kernel (Landlock `RESOLVE_UNIX` → `EACCES`); the `containment_matrix - unix-connect-*` proof cells run only in that build and auto-skip otherwise. In - the **default shipping build it is UNMEDIATED** (DESIGN §2). Control: don't - grant a tier holding a privileged socket, build the V9 wall, or wall it - host-side. (The *abstract*-socket variant IS blocked at the V7 floor — row 10.) +- **[ ] `uprobe`/`uretprobe` are UNFILTERABLE by ring 2** — `seccomp_uprobe_exception()` + (`kernel/seccomp.c:744`, 7.2-rc4) returns true *before* the cBPF emulator runs, pinning + `__NR_uretprobe` (335) and `__NR_uprobe` (336) to always-allow in every filter's action + cache. No `RET_ERRNO`, no `RET_KILL_*`, no `RET_LOG`, and **no audit record**; + `SECCOMP_MODE_STRICT` allows them too. + **Costs oring nothing today** — neither number is in any errno group (85 syscalls + checked against the compiled filter), and the exception is range- and arch-guarded, so + the x32 guard still KILLs `0x40000000|336` (measured under seal: SIGSYS). **Not reachable + by an unprivileged confined child:** both syscalls return ENXIO/SIGILL without a + kernel-installed `[uprobes-trampoline]` VMA, which is unforgeable from userspace + (`vma_is_special_mapping` compares against a kernel `__ro_after_init` struct), carries + `VM_DONTCOPY`, and whose `.mremap` returns `-EPERM`; installing one needs `CAP_SYS_ADMIN` + on the uprobe PMU (`kernel/events/core.c:11631` — a bare `capable()`, *not* + `perfmon_capable()`, so `perf_event_paranoid` is irrelevant) or root-only tracefs, and + step 11's `execve` discards any `mm` that had one. Listed because it is a **permanent** + limit on what ring 2 can promise: a future `paranoid` profile cannot deny these, and a + bug in `sys_uprobe`/`sys_uretprobe` (a path that has already needed shadow-stack and SLS + fixes) has no ring-2 mitigation. +- **[ ] Peer procfs METADATA** — `/proc//{cmdline,stat}` under `--proc full` + is world-readable and `ptrace_may_access`-independent, so no floor closes it + (cell `proc-peer-cmd-full`). Control: `--proc self`, or a runtime PID-namespace + + `maskedPaths`. Peer `environ` is a separate case and is walled for an + out-of-domain peer — mechanism, scope and the two privilege/fd caveats are in + `docs/RESIDUALS.md`; how the account here was corrected is C-13/C-14/C-18 in + `docs/CORRECTIONS.md`. +- **[ ] Any-IP egress on an allowed port** (443 → exfil) — Landlock net is + **port-only, structurally**: the rule key is an exact-match scalar holding the + port, and the address is consulted only to reject a family mismatch, so no ABI + bump changes this without a new key type. Control: host nftables default-deny + + a filtering proxy. This one does not close. +- **[ ] DNS/UDP** — *narrowed twice, still not closed under `balanced`.* Landlock + gained UDP rights at ABI V10 and oring plumbs them (`egress_udp`/`ingress_udp`). + **Since 2026-07-29 both profiles set them** — `strict` deny-all (closed there), + `balanced` outbound **:53 only** — which retired the previous text here: *"they are + opt-in and the built-in profiles leave them unset, since a V10-only default would + refuse to seal on every kernel below 7.2."* The floor is V10, so that reason is + gone. **Why this row stays open:** the grant is still **port-only**, so `balanced` + permits :53 to *any address*, which is the DNS-tunnel channel itself — an + attacker-controlled resolver is reachable. What closed is UDP on every *other* + port. Control: the same host nftables + filtering proxy as the row above, or + `strict`, or a `--policy` with `egress_udp: []`. +- **[x] `connect()` to a *pathname* AF_UNIX socket** — **promoted out of this list + in v0.2**: it is row 11 above, proven unconditionally, because the floor moved to + V9/7.1 (D-4b). It could never be closed here — Landlock has no hook on that + connect below V9, so no FS tier gated it and withholding `/tmp` only obscured + the socket. Kept as a struck entry because the *shape* of the fix matters: the + only control was the floor. - **[ ] In-tree write-then-run** in the `write_exec` workdir — accepted for the `balanced` build-agent profile (D-8). The `strict` profile is the answer when this matters: it pairs a `write_noexec` workdir with a `mprotect(PROT_EXEC)` diff --git a/docs/CITATIONS.md b/docs/CITATIONS.md new file mode 100644 index 0000000..1068bc8 --- /dev/null +++ b/docs/CITATIONS.md @@ -0,0 +1,281 @@ +# CITATIONS.md — what oring's claims rest on + +oring's docs assert a lot and cite almost nothing. Before this file, `docs/` and +`README.md` carried five external links between them, all of them repositories. +This maps **oring's own claims to the primary sources that support or contradict +them**, so a reader can check the reasoning instead of trusting it. + +Three rules, borrowed from the corpus this was assembled from: + +- **[LOAD-BEARING]** — a design decision currently rests on this. If the source + is wrong, oring is wrong. +- **[BOUNDS]** — establishes what is or is not achievable at this layer. Shapes + scope, does not dictate implementation. +- **[CONTEXT]** — worth having read; no current decision depends on it. + +**Sources that cut against oring are listed, marked, and not softened.** A +citations file containing only supporting work would be a bibliography of our +own opinions. Where a source contradicts a claim in `DESIGN.md`, that is stated +here and the doc is left alone — reconciling the two is a separate, deliberate +edit. + +Undated URLs were reachable 2026-07-26. Mailing-list threads are cited by +Message-ID, which is stable; fetch them with +`curl https://lore.kernel.org///t.mbox.gz` (lore's HTML views are +bot-walled, its mirror paths are not). + +--- + +## 1. The two-ring architecture — Landlock for what it mediates, seccomp for what it does not + +**[LOAD-BEARING]** for `DESIGN.md` §3 and §5. oring's central structural claim +has been asserted without a source. It has one, from Landlock's author: + +> The currently non-restrictable syscall families are not blocked by Landlock +> and will be allowed to do what they do if all enabled access-control layers +> allow such access … Making Landlock aware of all access types is a lot of +> effort and will take time … **In the meantime, using seccomp to filter the +> unhandled syscalls is a good approach, even if there are drawbacks.** + +Mickaël Salaün, 2022-08-31, answering Jeff Xu (Chromium) in *"current landlock +limitations and what it means for sandboxer"*, `landlock@lists.linux.dev`. +The same message establishes that **`chroot(2)` cannot bypass Landlock** — +escaping a chroot is unrelated to Landlock's access control (relevant to +`ATTACKS.md`). + +Referenced there: Günther Noack, *"Pledge on Linux"*, +https://blog.gnoack.org/post/pledge-on-linux/ — the drawbacks of the seccomp +half, from a Landlock maintainer. **[BOUNDS]** + +## 2. Landlock's threat model + +**[LOAD-BEARING]** for `DESIGN.md` §2. Until 2026 Landlock's threat model was +scattered across list threads; it is now a first-party document. + +*"[PATCH v1] landlock: Document the threat model"*, Mickaël Salaün, 2026-07-07, +`linux-security-module`. It defines the guarantees (unprivileged +self-sandboxing, per-domain confinement) and names the boundary cases by +category. Two bear directly on oring: + +- **Resources obtained from outside the sandbox** — *"receiving or inheriting a + file descriptor (or similar) is governed by the capability model, not + Landlock … a possible confused deputy, **not a bypass**"*. So a socket + connected before `restrict_self()`, or arriving via `SCM_RIGHTS`, is an + uncapped channel **by design**. §2 should list this as a residual; it is not a + defect and not something a future ABI closes. +- **Same-process thread interactions** — *"a per-thread domain does not protect + the process; a whole-process guarantee requires confining all its threads with + the same domain"*. This is the basis for §10's single-threaded seal point. + +**This document has not yet been mapped against §2 line by line.** It is the +highest-value outstanding read for this project. + +## 3. Landlock ABI — what each version can and cannot express + +**[LOAD-BEARING]** for `DESIGN.md` §4 — the **V10 / kernel 7.2 floor**, raised from V9 +on 2026-07-29 so the UDP net rights below are guaranteed rather than disclosed as +unhandled (D-4b, as amended) — and for the abi-ahead warning in `DESIGN.md` §13, which +`DESIGN.md` records as an open consistency question against D-4b rather than a settled +decision, and which carries no decision number. + +- `include/uapi/linux/landlock.h`, mainline v7.2-rc4 — `RESOLVE_UNIX` is + `1ULL<<16`, documented as covering `connect(2)` **and `sendmsg(2)` with an + explicit recipient address**, domain-scoped, returning `EACCES`, *"available + since the ninth version of the Landlock ABI"*. +- ABI 10 (`security/landlock/syscalls.c:172`) adds + `LANDLOCK_ACCESS_NET_BIND_UDP` (`1ULL<<2`), + `LANDLOCK_ACCESS_NET_CONNECT_SEND_UDP` (`1ULL<<3`) and + `LANDLOCK_ADD_RULE_QUIET`. + **Corrected 2026-07-26 — two things previously listed here are not ABI-10 + additions.** `LANDLOCK_RESTRICT_SELF_TSYNC` is **ABI 8**: + `Documentation/userspace-api/landlock.rst:753` — *"Starting with the Landlock + ABI version 8 … using the `LANDLOCK_RESTRICT_SELF_TSYNC` flag"* — and oring's + own `LANDLOCK-V9-SCOPE.md` already said V8 — a review record that lived in this tree's + gitignored `docs-internal/` until 2026-07-29 and is now in the maintainer's corpus. It was measured on a + V10 kernel, which is not the same claim. `LANDLOCK_CREATE_RULESET_ERRATA` is + the version-independent errata probe; the errata it reports are declared at + ABI 1, 4 and 6. +- **Network rules are port-only, structurally.** The ruleset is a red-black tree + on an exact-match scalar key (`security/landlock/ruleset.c`, + `landlock_find_rule()`), and `net.c` puts the *port* in that key; the address + is consulted only to reject a family mismatch. **[BOUNDS]** — no ABI bump + changes this without a new key type. It is why §2's egress residual is + structural rather than temporary. +- **Non-TCP/UDP traffic is unmediated by ring 1 at every current ABI.** All three + net hooks `return 0` — *allow* — for a socket that is neither TCP nor UDP. So + even with a UDP grant, non-TCP/UDP IP protocols and raw sockets are not + mediated by **ring 1**. + + **They do not all reach that `return 0` the same way, and this bullet said they + did until 2026-07-27.** `hook_socket_bind` and `hook_socket_connect` dispatch + `if TCP … else if UDP … else return 0`. `hook_socket_sendmsg` does not: it + opens with a `(msg_flags & MSG_FASTOPEN) && address && sk_is_tcp` branch, then + falls into a bare `if (sk_is_udp) … else return 0` with no TCP arm at all + (`security/landlock/net.c:346-375`, read from the 7.2.0-rc4 source of the + running kernel, 2026-07-27). The conclusion is unaffected — every path still + allows what is neither TCP nor UDP — but the uniform-dispatch phrasing was not + a description of the third hook. The same wording was in `DESIGN.md` §2 and is + corrected there too. + + **Corrected 2026-07-26/27 — this bullet previously overstated on three counts, + and each correction is load-bearing:** + 1. It named **DCCP and UDP-Lite** as live channels. Both are **removed from + Linux** (`net/dccp` and `net/ipv4/udplite.c` are absent from 7.2-rc4). + Naming them was plausibility, not verification. + 2. It said **ICMP stays open**. That residual is **CLOSED by ring 2** as of + 2026-07-26: `socket()` in `AF_INET`/`AF_INET6` is restricted to the + protocols ring 1 can mediate, in **both** profiles, so `IPPROTO_ICMP` gets + `EAFNOSUPPORT` — and in-cage `ping` no longer works. "Unmediated by ring 1" + is not "reachable by the child"; this bullet conflated them. + 3. It said **"no ABI version retires it"**. That absolute is contradicted by + §5 of this same file: Ivanov's `LANDLOCK_ACCESS_SOCKET_CREATE` is a live, + reviewed RFC that names exactly this class. Say *"no net-hook ABI bump + retires it; closing it needs a new access-right family, which is proposed + and unmerged"* — which is a statement about the current tree, not about all + future Linux. + + Note also that SCTP **is** live on a stock distro kernel: a measurement to the + contrary was taken on this box's `localmodconfig`-trimmed kernel, which has no + `sctp.ko`. Protocol *availability* is a property of the kernel build. + + §2's residual is accurate and current about V10, but it was *named* "DNS/UDP + exfil" (and `README.md` said "UDP/DNS still open" until 2026-07-27), which names the part V10 + closes and omits the rest. **Not a contradiction — an under-inclusive label.** + Restating it as *non-TCP/UDP IP protocols, of which UDP is the part the V10 + grant closes* keeps the next V-bump from reading as having closed the residual. + +## 4. Why Landlock has no address matching — deferred, with a stated test + +**[BOUNDS]** for §2's egress residual and §9's host-firewall companion control. +CIDR was proposed in the original PoC as `landlock_net_cidr4_attr` (address + +prefix, citing RFC 4632) and rejected by Salaün, 2021-12-17, in *"[RFC PATCH +0/2] Landlock network PoC implementation"*, `linux-security-module`: + +> IP addresses (and subnets) should not be part of a rule, **at least for now** … +> tied either to the system architecture …, the local network or Internet, hence +> **moving targets not controlled by application developers**. Moreover … more +> complex to check and handle subnets, which are most of the time tied to the +> **Netfilter infrastructure, not suitable for Landlock because of its +> unprivileged nature** … protocols such as TCP and their associated ports are +> normalized and are tied to an application semantic. + +So the port-only design is a considered position with a reopening condition, not +an omission. Anything oring says about future address filtering should engage +these three criteria. + +## 5. In-flight upstream work that would close a published residual + +**[CONTEXT]**, becoming **[LOAD-BEARING]** if merged. Mikhail Ivanov, *"Socket +type control for Landlock"* → *"landlock: Support socket access-control"*, +`linux-security-module`: RFC v1 2024-04, v2 2024-05, v3 2024-11, v4 with review +activity 2026-05-22; 19 patches, selftests covering SCTP peeloff, `accept(2)` +and packet protocol alias. Companion RFC: *"landlock: Fix non-TCP sockets +restriction"*. + +This is the mechanism that would close the residual in §3 of this file. It bears on +oring's abi-ahead behaviour — warn above the crate ceiling rather than refuse — which +carries **no decision number**: `DESIGN.md` §13 records it as *"an open consistency +question against D-4b, not a settled design"*. Whatever settles it should distinguish a +right oring never claimed from one that closes a **published** residual. + +## 6. Ring 2 — the denylist decisions + +- **CVE-2019-7303** (snapd, reported by Jann Horn): a seccomp rule compiled to a + 64-bit compare on an argument the kernel truncates to 32 bits, so + `ioctl(fd, TIOCSTI | (1UL<<32), …)` passed the filter. **[LOAD-BEARING]** — the + defect class `tests/seccomp_smt.rs` exists to exclude, and the reason the SMT + gate proves *truncation-invariance* rather than a property of the raw + `seccomp_data`. Note the hazard is specific to **denylists with argument + matching**; an allowlist fails closed here, which is a real argument against + oring's shape and is met rather than ignored. +- **CVE-2018-15746** (QEMU): `-sandbox on` applied the filter to the calling + thread only — `Seccomp: 2` and `Seccomp: 0` in the same process. Fixed with + `SCMP_FLTATR_CTL_TSYNC`. **[LOAD-BEARING]** for §10: it *strengthens* the + single-threaded seal point rather than arguing for TSYNC, because sealing + pre-`execve` is structurally immune to the class. +- **CVE-2024-21626** (runc, Aleksa Sarai): a leaked file descriptor as an escape + vector. **[LOAD-BEARING]** — oring's `close_range` FD scrub exists for this. +- `seccomp(2)` man page and *"Seccomp filters for multi-threaded programs"*, + LWN, https://lwn.net/Articles/600250/ — seccomp's `TSYNC` **refuses** to sync a + thread whose filter is not an ancestor of the caller's. **[CONTEXT]**, and a + useful contrast: Landlock's `TSYNC` overrides instead. + +## 7. Sources that cut against oring + +Listed because a citations file that omits them is advocacy. + +- **Pailoor et al., *"Automated Policy Synthesis for System Call Sandboxing"*, + OOPSLA/PACMPL 2020.** Synthesised syscall **allowlists** prevent >95% of known + LPEs and beat hand-written policies. **[BOUNDS]** — it removes the *fallback* + argument for a hand-maintained denylist. oring's denylist has to be justified + on its own terms (no profile authoring burden, no per-application policy), not + as "an allowlist is impractical". +- **Landlock's compatibility ladder is the majority position.** clampdown, codex + and Syd all implement best-effort/per-version handling; oring's hard floor is a + minority of one. Salaün's own guidance (SSTIC 2024) is that an application + *should not* sandbox itself if it risks breaking a legitimate use case — though + he scopes that to embedded, developer-shipped sandboxing and concedes a + minimum-requirement floor is legitimate for security software. **[BOUNDS]** for + D-4. +- **Syd** (Ali Polatel), https://gitlab.exherbo.org/sydbox/sydbox — the closest + prior art: Rust, seccomp user-notification plus Landlock, ABI V1–V9, ~379k + lines. Larger, more portable, better CI, on the ladder, and it *fuzzes* + `canonicalize` while oring fuzzes nothing. **[CONTEXT]** — the two projects + made opposite bets: dynamic testing over a surface oring reasons about + statically, versus static proof over a surface Syd does not model. + +## 8. A published escape against Landlock itself + +**[CONTEXT]**, and it belongs in `ATTACKS.md` rather than being absent: +**CVE-2024-42318**, known as *"Landlock Houdini"*, with an LTP reproducer — +`landlock07`, Andrea Cervesato (SUSE), forwarded to `landlock@lists.linux.dev` +2024-08-20. §2 already says oring is not a kernel-0-day defence; this is the one +published escape against the mechanism it sits on, and a reader is entitled to +know it exists. + +## 9. Agent containment — the premise §2 assumes + +**[BOUNDS]**. §2 asserts a prompt-injection threat model without argument. + +- Trail of Bits, *"Prompt injection to RCE in AI agents"*, 2025-10-22, + https://blog.trailofbits.com/2025/10/22/prompt-injection-to-rce-in-ai-agents/ — + argument injection defeats command **allowlists** in three production agents; + their conclusion is *"the most effective defense available today is + sandboxing"*, naming Landlock on Linux by name, and *"local applications + deserve the same protection"*. The endorsement is of the approach, not of any + implementation. +- **CVE-2025-54135** (Cursor, "CurXecute") and **CVE-2025-53773** (Copilot in VS + Code): injected content directs the agent to rewrite its own permission + configuration, after which actions run without confirmation. **[LOAD-BEARING]** + for §7's `check_self_protection` — two shipped products, one root cause, and + oring's seal makes that root cause unreachable. + + **Corrected 2026-07-27: this cited `CVE-2025-43663`, which is the wrong ID** — + reserved/unpublished and unrelated. CurXecute is **CVE-2025-54135**: Aim Labs, + CVSS 8.6, coordinated disclosure to Cursor 2025-07-07, public 2025-08-01, fixed + in Cursor 1.3 (2025-07-29). Verified against the CVE record and multiple + independent write-ups, not taken from the finding that reported it. + + **And the mechanism as written is softer than reality, which strengthens the + argument rather than weakening it.** The suggested `~/.cursor/mcp.json` edit + **lands on disk and executes before the user accepts or rejects it** — the + write *is* the execution. So the root cause is not "the agent talked the user + into approving something"; it is that a config governing execution was writable + by the thing it governs. That is precisely what `check_self_protection` refuses, + and it is why the refusal is not merely defence-in-depth. +- Salaün named this class in **SSTIC 2024** §2.5 a year earlier: a sandboxed + process tampering with *"its own or others' persistent data (e.g. + configuration file, cache) and then alter behavior of next runs (e.g. disable + sandboxing)"*, and says the kernel cannot prevent it because it does not know + which data is sensitive. That is the public citation for a userspace + complement being **required**, not optional. + +## 10. Where to check any of this + +- Landlock user list: `git clone --mirror https://lore.kernel.org/landlock/0` + — 740 KB, complete. +- Development list: `git clone --mirror + https://lore.kernel.org/linux-security-module/0` — 217 MB, ~11 s. +- public-inbox stores one message per commit with the raw mail in a blob named + `m` and the Subject as the commit message, so `git log --all --format='%s'` + indexes an entire list in one command. diff --git a/docs/CORRECTIONS.md b/docs/CORRECTIONS.md new file mode 100644 index 0000000..63cc434 --- /dev/null +++ b/docs/CORRECTIONS.md @@ -0,0 +1,356 @@ + +# CORRECTIONS — what this project published and later found wrong + +Every entry is a claim oring's own documentation made, and the measurement that +refuted it. Newest first. Ids are stable: `docs/RESIDUALS.md` and +`docs/DESIGN.md` cite them as `[corrections: C-n]`, and a citation is how a +reader gets from the current wording to the reason it is worded that way. + +**Why this file exists.** Until 2026-08-01 each of these lived inline, in the +sentence it corrected — so §2 carried the current position and the argument +against two earlier ones in the same paragraph, and a reader looking up what +oring does today paid for a debate that had already ended. The corrections are +kept because a project that silently rewrites its claims cannot be audited, and +because several of these were *dangerous* wordings rather than merely wrong ones +(C-11, C-12). They are kept **here** because the residual's job is to state what +is true now. + +**What belongs in an entry.** What the text said, what measurement refuted it, +and what it says now. Not the general lesson — a lesson with no measurement +under it is an aphorism, and this file is not for those. + +**This file is not a changelog.** `CHANGELOG.md` records what the *software* +did. This records what the *documentation asserted* and got wrong, which is a +different failure with a different reader: someone deciding how much to trust +the rest of the page. + +--- + +## C-20 — 2026-07 · `docs/DESIGN.md` §5 · "family >= 17 blocks PACKET" + +**Said:** the `socket()` family gate stopped `AF_INET SOCK_PACKET` because the +rule blocks families at or above 17. + +**Refuted by:** `__sock_create` rewrites `AF_INET SOCK_PACKET` to `PF_PACKET` +*before* the family-keyed module request, so the family comparison never sees +17. What actually stopped it was `CAP_NET_RAW` — a **deployment** property oring +does not control and must not claim. + +**Now:** the type axis is gated explicitly, and the guarantee is stated on the +mechanism that holds it rather than on one that does not. + +## C-19 — 2026-07-28 · `docs/INTERNALS.md` §5 · the btrfs 0x94 justification + +**Said:** oring does not block the btrfs `0x94` ioctl magic because "its only +unprivileged member is the benign `FICLONE`/`FICLONERANGE`" and "every +destructive member is CAP_SYS_ADMIN-gated". + +**Refuted by:** reading `fs/btrfs/ioctl.c` in the running 7.2.0-rc4 tree. 63 +ioctls are defined in the magic and only 35 `capable(CAP_SYS_ADMIN)` checks exist +in the whole file. `SNAP_CREATE`/`SNAP_CREATE_V2` gate on +`inode_owner_or_capable()` — the owner qualifies, no capability involved — and +`SUBVOL_CREATE`, `SUBVOL_SETFLAGS`, `DEFRAG`, `FS_INFO`, `SPACE_INFO`, `SYNC` and +`INO_LOOKUP` are reachable unprivileged. + +**Now:** the conclusion is unchanged and the argument is different. Every one of +these acts on a path the child already holds an fd to, and Landlock gates the +`open()`, so the reach is bounded by the granted tiers rather than by the +capability set. Not measured — no btrfs is mounted on the development host — so +it is a source read. + +## C-18 — 2026-07-26 · `docs/ATTACKS.md` row 12 · three drafts, two withdrawn + +**Said:** successive drafts of the peer-`environ` note attributed the denial to +the Landlock floor, then described the rule as "may not read a non-descendant's", +then explained a contradictory 6.15 measurement as the reader "must have read +something inside its own domain". + +**Refuted by:** the floor buys none of it — a ruleset handling only +`LANDLOCK_ACCESS_FS_EXECUTE` (ABI V1) with **zero rules** takes peer `environ` +from readable to `EACCES`. "Non-descendant" is false in its second half: +`domain_scope_le()` is ancestor-**or-equal**, so siblings under one seal read +each other. And the 6.15 result is **unexplained** — no artefact of the run +survives, `Sentinel::spawn()` is byte-identical across the commits either side, +so a placement error is not the explanation. The domain-relation story was +asserted with more confidence than the evidence carried and is withdrawn. + +**Now:** privilege is a demonstrated mechanism that reproduces *both* historical +results on one kernel — the same binary against the same harness-spawned +sentinel gives `Deny(EACCES)` as an ordinary user and `NotBlocked` as root. That +is recorded in `docs/RESIDUALS.md` as a live caveat rather than as history, since +`sudo oring` loses the wall today. See also C-13, C-14. + +## C-17 — 2026-07-29 · `docs/RESIDUALS.md`, peer procfs · instrument, and two symlink defeats + +**Said:** the peer-procfs residual was disclosed as `documented-only` with +`--proc self` as the offered control, and the seal line carried no field +distinguishing the modes — `grants=` read identically whichever applied. + +**Refuted by:** two independent defeats of the mode guard, both measured with +`__probe proc-cmdline 1`. (a) The guard compared paths **lexically** while +`add_tier` opens with `PathFd::new` and follows symlinks, so a policy naming a +symlink to `/proc` kept its grant: `/proc` named directly gave `EACCES` under +both `--proc self` and `--proc none`; a symlink to it gave **0, READABLE**, under +both. (b) The guard filtered only `read_only`, so a **direct** `/proc` grant in +`read_exec` or `write_noexec` survived `--proc none` outright, with no symlink +involved. + +**Now:** every tier is canonicalised through `policy::canonical_or_literal`, +pinned by `policy::tests::proc_mode_treats_a_symlink_exactly_like_its_target_in_every_tier` +over 4 tiers × 3 modes × 2 spellings. The seal line carries `proc=` — +`none` / `self` / `full` / `custom:` — **derived from the installed tiers, +never echoed from the flag**. Measured: a `--policy` granting `/proc/cpuinfo` +with no `--proc` flag reports `proc=custom:1`, which a flag-echo could not show. + +## C-16 — 2026-07-28 · peer procfs · "a pattern no host MAC can produce" + +**Said:** the readable-sibling / denied-peer differential was a signature only +Landlock could produce. + +**Refuted by:** the universal is false. A peer that has called +`PR_SET_DUMPABLE 0` has a root-owned `/proc/`, so **pure DAC with no MAC and +no oring produces the same differential** — measured: non-dumpable peer denied, +ordinary peer readable, same reader, same uid, same security context. The +original argument also controlled the *reader's* context, which is constant by +construction, when the variable that matters is the *targets'*. + +**Now:** the claim is dropped. What establishes that the denier consults no +handled access right is a single-variable test and is stronger: with **no oring +at all**, a process calling `landlock_restrict_self()` handling **only** +`LANDLOCK_ACCESS_FS_MAKE_SYM` — with **zero rules added** — flips a pre-existing +peer's `environ` from readable to `EACCES` while its own still reads. Entering +any domain does it. + +## C-15 — 2026-07-28 · peer procfs · two mechanisms presented as one controlled pair + +**Said:** "`--proc full` readable / `--proc self` denied", published as the two +arms of one experiment varying the domain relation. + +**Refuted by:** the `--proc self` denial is the **Landlock FS tier**, not the +domain relation. `/proc/` is not a granted path, so the open fails +before `ptrace_may_access` is consulted and the domain relation — which still +*permits* the read — never gets a say. Proof, one variable: `cmdline`, `stat` and +`comm` are **not** ptrace-gated (readable bare and under `--proc full`) and are +**also** denied under `--proc self`; only a path-layer denial does that. + +**Now:** stated as two mechanisms. The operator conclusion is unchanged — +`--proc self` does buy sibling-`environ` privacy — but it buys it by making the +path unreachable, which is why it also costs fork compatibility (`DESIGN.md` §8). + +## C-14 — 2026-07-27 · peer procfs · "non-descendant" was backwards + +**Said:** peer `environ` is closed for a **non-descendant** process. + +**Refuted by:** `domain_scope_le()` is ancestor-**or-equal**, and `fork(2)` +creates no new domain, so every process under one oring seal shares one domain +and reads every other's `environ`. A sibling *is* a non-descendant and *is* +readable. Measured 2026-07-27 on 7.2.0-rc4, one seal, true siblings. + +**Now:** the axis is **in-domain vs out-of-domain**, not descent. Confining an +agent does not isolate its subprocesses' environments from each other. + +## C-13 — 2026-07-26 · peer procfs · three claims about the floor, all wrong + +**Said:** Landlock "does not reliably wall them at the V7 floor" (measured +NotBlocked on 6.15); the 7.1.3 denial was "a version-dependent bonus, not a floor +guarantee"; and the V9 floor closed it. + +**Refuted by:** the denier consults **no handled access right** — it compares +domain hierarchies via `domain_scope_le()` — and the rule predates the 5.13 +merge, so the floor bought none of it. The outcome turns on where the *target* +sits: a landlocked reader may read a target in its own domain or a sub-domain and +may not read one outside it. Measured, one kernel, one seal, one variable: a peer +forked *before* the seal is denied, a child forked *after* it is readable. The +6.15 result was therefore never evidence of a kernel change. + +**Now:** stated as a domain-relation property, floor-independent. + +## C-12 — 2026-07-26 · protocol reach · mediation confused with reachability + +**Said:** the non-TCP/UDP channels Landlock declines to mediate were listed as +things a confined child could reach, naming ICMP, DCCP and UDP-Lite. + +**Refuted by:** what Landlock declines to mediate and what an unprivileged child +can actually reach are different questions. ICMP *was* reachable and measured +sending under a valid `strict` seal — now **closed by ring 2**: `socket()` in +`AF_INET`/`AF_INET6` is restricted to the protocols ring 1 can mediate, so +`IPPROTO_ICMP` gets `EAFNOSUPPORT` and in-cage `ping` does not work. `AF_PACKET` +and raw sockets need `CAP_NET_RAW`. **DCCP and UDP-Lite no longer exist in +Linux** (`net/dccp` and `net/ipv4/udplite.c` are absent from 7.2-rc4), so naming +them as channels was wrong. + +**Now:** SCTP is named as the live case, and a prior measurement to the contrary +is recorded as having been taken on a `localmodconfig` kernel with no `sctp.ko` — +protocol *availability* is a property of the kernel build, not of Landlock. + +## C-11 — 2026-07-28 · workdir poisoning · the review control fired the payload + +**Said:** treat a touched workdir as untrusted and review it — with `git status` +and `git diff`. + +**Refuted by:** measured, reproduced twice independently. A child writes +`.git/fsm.sh` and sets `core.fsmonitor`, both within what a workdir grant +permits. The operator's review returns **empty output at exit 0 from both +commands** — `.git/` is untracked, so nothing surfaces — while the payload +**fires four times during the review itself**, at the operator's uid, outside the +cage, because `core.fsmonitor` executes on index refresh. + +**Now:** inspect with tools that do not interpret the tree as a repository — +`command grep -nE 'fsmonitor|pager|sshCommand|textconv|hooksPath|askpass|^\s*\[alias\]' .git/config` +and `find .git/hooks -type f ! -name '*.sample'`, which detected the same +poisoning with **zero** firings — or re-clone into a throwaway checkout. There is +no safer `git` invocation: `core.pager`, `core.sshCommand`, `alias.*` and +`diff.*.textconv` are separate triggers on separate commands, and repo-local +`.git/config` is read on every one of them. + +## C-10 — 2026-07-28 · workdir poisoning · "run the first build inside the cage" + +**Said:** run the first build or test after an agent session inside the cage +rather than outside it. + +**Refuted by:** under both built-in profiles as they then stood, that build ran +the agent's `build.rs` with write access to `~/.cargo/registry`, `~/.cargo/git`, +`~/.npm` and `~/.cache` — laundering a bounded single-project hazard into a +persistent cross-project one. + +**Now:** run it outside, in a throwaway checkout, or not at all. The advice was +retracted as actively harmful; the cache grants it depended on were themselves +removed (C-9a below). + +## C-9 — 2026-07-29 · UDP defaults · the reason for leaving them unset had expired + +**Said:** the UDP rights are opt-in and the built-in profiles leave them unset, +"because a V10-only default would refuse to seal on every kernel below 7.2, so +under the profiles this residual is still LIVE". + +**Refuted by:** true only while the floor was V9. The floor moved to V10, which +refuses those kernels regardless, so the stated reason no longer existed. + +**Now:** `balanced` grants outbound **:53 only** (with `ingress_udp: [0]` for +autobind); `strict` grants no UDP at all. Both report `unhandled=-`. Measured +2026-07-29 on 7.2.0-rc4 via bash `/dev/udp` with an unsealed control. **Not a +closure:** Landlock net is port-only, so `balanced` still permits :53 to any +address, which is the DNS-tunnel channel itself. + +## C-9a — 2026-07-28 · shared package cache · "both profiles" + +**Said:** both built-in profiles granted `write_noexec` on the shared package +caches. + +**Refuted by:** `strict` never did. + +**Now:** the grants are removed from `balanced` too; the caches remain readable. +The upstream defect is unchanged and is not oring's to fix — cargo verifies a +tarball once at download and never verifies anything on disk again, and a payload +appended to a `build.rs` under `~/.cargo/registry/src` with `.cargo-ok` untouched +executes at the operator's uid in an unrelated project. + +## C-8 — 2026-07-27 · net hooks · one dispatch shape claimed for three hooks + +**Said:** all three Landlock net hooks reach their `return 0` by the same +`if TCP … else if UDP … else return 0` dispatch. + +**Refuted by:** `hook_socket_sendmsg` (`security/landlock/net.c:346-375`) opens +with a `(msg_flags & MSG_FASTOPEN) && address && sk_is_tcp` branch and then falls +into a bare `if (sk_is_udp) … else return 0` carrying **no TCP arm**. + +**Now:** the conclusion is unchanged — anything that is not TCP or UDP is +unmediated at every ABI — and the dispatch is described per hook. + +## C-7 — 2026-07-27 · net hooks · a design position described as a limitation + +**Said:** Landlock's silence on non-TCP/UDP sockets was a hook-signature +limitation. + +**Refuted by:** the hooks are registered and they run — +`LSM_HOOK_INIT(socket_sendmsg, hook_socket_sendmsg)` (`net.c:380`, 7.2-rc4) — and +the body *chooses* to return 0 for anything `sk_is_tcp()`/`sk_is_udp()` rejects. +Landlock can see it and declines to act. + +**Now:** stated as a deliberate design position. No net-hook ABI bump retires it; +closing it needs a new access-right family, which Ivanov's *"Support socket +access-control"* RFC proposes and which is unmerged — so "no ABI closes it" is a +claim about today's tree, not about all future Linux. + +## C-6 — 2026-07-2x · pre-seal descriptors · "outside the seal's reach" + +**Said:** a descriptor held before `restrict_self()` is outside the seal's reach. + +**Refuted by:** an addressed `sendto(2)` on that very fd **is** denied. What +survives is the address-less *path* — `hook_socket_sendmsg` only checks when +`msg->msg_name != NULL`, and the autobind check passes because the socket is +already bound. + +**Now:** the residual is "a connected socket keeps its established peer", not +"pre-seal descriptors are unmediated". Descriptors arriving over `SCM_RIGHTS` +share the distinction and have **not** been separately measured. + +## C-5 — 2026-07-27 · FD scrub · an unqualified guarantee + +**Said:** "Inherited leaked FDs" — unqualified, in the irrevocable-guarantees +list. + +**Refuted by:** fds 0–2 are deliberately preserved and are **not** scrubbed, so +an fd the caller redirects onto stdin/stdout/stderr crosses the seal intact. +Measured 2026-07-27, one variable: an inherited fd 0 is readable inside the cage, +an inherited fd 7 is gone. + +**Now:** qualified as **fd 3 and above**. It is the caller's own redirect rather +than a child-initiated escape — Landlock's threat model calls that a confused +deputy, not a bypass — but the guarantee as written was broader than the +mechanism. + +## C-4 — 2026-07-27 · namespace rules · "7 rules each" + +**Said:** 7 arg-mask rules each on `clone` and `unshare`. + +**Refuted by:** it is 7 on `clone` and **8** on `unshare`, and the asymmetry is +deliberate: `unshare` carries an extra `CLONE_NEWTIME` (0x80) rule and `clone` +must not, because in legacy `clone(2)` the low byte is `CSIGNAL` and a 0x80 rule +there would false-match an exit signal. + +**Now:** both counts stated, with the reason. Saying "each" hid a deliberate +asymmetry behind a symmetry that was not in the subject matter. + +## C-3 — 2026-07-29 · attacker model · the caller's role + +**Said:** wording that implied the caller is trusted. + +**Refuted by:** three shipped guards say otherwise. `--profile` beside +`ORING_POLICY` is a hard refusal; `$TMPDIR` is no longer read at all, after it +was measured granting write on `~/.ssh/authorized_keys`; and +`check_profile_grants` refuses a profile whose tier would contain `$HOME`. + +**Now:** the caller is trusted to **author** a policy explicitly; the caller's +**ambient state is not trusted to widen or override what was authored**. Where an +ambient source would override an authored one, oring refuses rather than picking a +winner. This is `CONSTRAINTS.md` **CON-6**, which stated the rule before this +document did. + +## C-2 — 2026-07-29 · constraint register · a pointer into a private corpus + +**Said:** the loss/hazard/constraint structure lived in a file in the +maintainer's private research corpus. + +**Refuted by:** a release could then ship with a constraint register its readers +could not see, and gates that never ran against it. + +**Now:** `CONSTRAINTS.md` is in this repository and gated in-tree by +`tests/constraint_register.rs` and `tests/design_residuals.rs`, so it runs on +every `cargo test` and in `make check`. + +## C-1 — 2026-07-29 · §2 scope · an adversary-scoped section holding non-adversary losses + +**Said:** the section was titled *Threat model & non-goals* and was scoped +entirely to what an attacker can do. + +**Refuted by:** it was measured holding two items with **no adversary in them** — +the `access()`/`open()` divergence, filed as "not a containment hole", and the +workdir-poisoning advice, retracted twice in two days. Those are losses that +occur with nobody attacking, and an adversary-scoped section has no slot for +them, so each was mis-ranked until a measurement forced it. + +**Now:** the loss structure (`BREACH` / `LAUNDER` / `DIVERGE`) is what residuals +are tagged against. **What did not change:** the compromised agent is still the +primary adversary and containment breach the primary loss. If the two readings +ever conflict, the adversary wins and the taxonomy is wrong. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 67d38dc..988accf 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -2,7 +2,52 @@ # oring — Design Document v1.0 -Design and threat model. Built on rust-landlock 0.4.5 and seccompiler 0.5.x; the FS-tier and seccomp designs are derived from first principles (kernel Landlock/seccomp semantics and known LPE-primitive classes), see §4/§5. Resolved decisions are in the Decision Log (§14). +The architecture, the deployment and packaging story, and the map to everything +else. Built on rust-landlock 0.4.5 and seccompiler 0.5.x; the FS-tier and seccomp +designs are derived from first principles (kernel Landlock/seccomp semantics and +known LPE-primitive classes), see §4/§5. + +## Where each section lives + +**Split on 2026-08-01 by what the reader came to do. Section numbers did not +change** — a `DESIGN §n` citation resolves through this table, and there are +enough of them in `src/`, `tests/` and the other documents that renumbering would +have cost a great deal and bought a reader nothing. + +| § | Subject | File | +|---|---|---| +| 1 | Overview | here | +| **2** | **Threat model, what oring stops, every residual, non-goals** | [`RESIDUALS.md`](RESIDUALS.md) | +| 3 | Architecture — rings and host layering | here | +| 4 | Landlock design (ring 1) | [`INTERNALS.md`](INTERNALS.md) | +| 5 | Seccomp design (ring 2) | [`INTERNALS.md`](INTERNALS.md) | +| 6 | Pre-exec sequence — **the seal order** | [`INTERNALS.md`](INTERNALS.md) | +| 7 | Policy schema + profile | [`REFERENCE.md`](REFERENCE.md) | +| 8 | CLI / UX | [`REFERENCE.md`](REFERENCE.md) | +| 9 | Deployment — container entrypoint + standalone | here | +| 10 | Rust crate structure + deps | here | +| 11 | Testing strategy | here | +| 12 | Build / packaging (static musl) | here | +| 13 | Implementation status | here | +| 14 | Decision Log (`D-n`) | [`RATIONALE.md`](RATIONALE.md) | + +Not numbered, and not part of the `§n` space: +[`CORRECTIONS.md`](CORRECTIONS.md) (what this project published and later found +wrong, cited as `[corrections: C-n]`), [`ATTACKS.md`](ATTACKS.md), +[`VERIFICATION.md`](VERIFICATION.md), [`CITATIONS.md`](CITATIONS.md), and +`CONSTRAINTS.md` (`CON-n`) in the repository root. + +**If you are changing confinement code, §6 is the section to read first.** The +seal order is load-bearing: a wrong order is a confinement bypass, not a style +nit. + +### Two id namespaces, and one of them does not resolve here + +**`D-1` … `D-32` are this document's own decisions** and are defined in the Decision Log (§14). **`D1`, `D3`, `D9` — no hyphen — are not.** They belong to the maintainer's decision log, which is not part of this repository. Two namespaces separated by a hyphen is a bad design and it is named here rather than left for a reader to trip over; the constraint register (`CONSTRAINTS.md`) uses the deliberately unmistakable `CON-n` for the same reason. + +**`BUILD-QUEUE.md T` is a work-item handle, not a link.** That queue is the maintainer's and is not in this repository, so **a T-number here resolves to nothing you can open** — it is a stable identifier for a known gap, and the gap itself is always described in the surrounding sentence. **16 such citations exist, and they are not confined to the prose: 3 in `docs/`, 7 in `src/`, 6 in `tests/`** — all of which ship in the crate, so a reader who greps the source meets the convention there too. (This sentence read "Nine such citations exist (eight here, one in `VERIFICATION.md`)" until 2026-08-01, which was the count for `docs/` alone presented as the count for the tree, and understated it by twelve. **Those four numbers are gated** by `tests/citation_handles.rs`, which counts the tree and fails on drift.) Read them as "this is tracked", never as "see the ticket". `CONSTRAINTS.md` deliberately carries **no** T-numbers and states its gaps in prose instead. + +**The same applies to any `deep_research/…` path. 4 citations in `docs/`, 0 in `src/`, and 0 in `tests/`** name notes in the maintainer's research corpus, which is not published. This clause said "three comments in `src/`" until 2026-08-01; `grep -r deep_research src/` returns **zero**, and had done since those comments moved to `tests/`. Gated by `tests/citation_handles.rs` along with the T-handle counts above, and `src/` is asserted at 0 rather than left unstated, because that is the number that rotted. In both places they attribute evidence rather than offer a link, and **the claim they support is always stated where it is cited**, so nothing here depends on reading them. They are listed as a known wart, not defended — a shipping crate that cites unreachable paths is a defect, and the fix is to inline whatever a reader actually needs. --- @@ -18,44 +63,21 @@ Not an orchestrator. Not a MAC authoring tool. Not a network policy engine. Not --- -## 2. Threat model & non-goals - -**Attacker:** a fully-compromised agent (prompt-injection → arbitrary native code) running as uid 1000 *after* `restrict_self`+`execve`. All rings already in effect and irrevocable. The attacker is the CHILD; policy is chosen by the trusted PARENT before the first exec. - -### What oring stops (kernel-enforced, irrevocable) -- FS access outside policy tiers — host creds (`~/.ssh`, `~/.aws`), *other* projects' `.git/config`, host config. **Strongest guarantee (Landlock V1–V3, hard floor).** Read this narrowly: it is `.git/config` of repos **outside** the granted tiers. The **workdir's own** `.git/` is inside `write_exec` and is fully writable by the child — see the in-tree residual in §2. -- W^X: no exec from writable scratch (`write_noexec` tiers). -- Privilege gain via SUID/setcap/file-caps (`NO_NEW_PRIVS`) — neutralizes the CVE-2023-0386 OverlayFS-SUID payoff for the confined child (its mount/userns half is separately closed by the seccomp `mount`/`unshare(CLONE_NEWUSER)` denials, §5). -- Namespace creation — per-flag arg-masks on `clone`/`unshare` `CLONE_NEW*` (7 rules each), `clone3`→ENOSYS, `mount`/`setns`/`pivot_root` blocked. -- LPE launchpads: io_uring, eBPF, ptrace, userfaultfd, keyctl, perf_event_open, memfd_create, module ops, `open_by_handle_at`, Dirty-Pipe (`splice`/`vmsplice`), StackRot (`MAP_GROWSDOWN`), TTY-injection ioctls, and **x32-ABI re-entry of all of the above** (§5). -- **AF_UNIX MSG_OOB UAF (CVE-2025-38236)** — arg-masked on send/recv `MSG_OOB` + `SIOCATMARK` (fix, was wrongly deferred). -- Inherited leaked FDs (FD-scrub via `close_range`). - -### Also stopped — guaranteed at the V7 floor (these ABIs sit below it) -- **Connect/bind on non-allowed TCP ports** — Landlock net (ABI V4 / kernel 6.7), always present at the floor. Port-only, and dependent on the ring-2 `MSG_FASTOPEN` block for the wall to hold (§4/§5). -- **Signalling / abstract-socket peers outside domain** — Landlock scope (ABI V6 / kernel 6.12), always present at the floor → `EPERM`. In the container path this is also provided by the runtime's netns/pidns; oring's scope ring is defense-in-depth there. - -### Residual — what a compromised agent CAN still do (honest) -- Full control inside granted FS tiers (the workdir is `write_exec` under `balanced` — an in-tree write-then-run payload is possible; documented, not fixed). -- **Poisoning the workdir so the OPERATOR executes it later, unconfined.** The child can write `.git/hooks/*` and `.git/config` (`core.fsmonitor`, `core.pager`, `core.sshCommand`, `alias.*`, `diff.*.textconv` all run commands), and equally `Makefile`, `build.rs`, `package.json` scripts, `conftest.py`. Each fires on the next `git`/build/test the operator runs **outside** the cage, at their uid. This is not closeable at this layer and is **not** the same class as the `~/.cargo/bin` `$PATH` over-grant that was fixed on: that was write access the agent never needed, whereas this is the tree the agent exists to edit. Landlock has no deny rule — access is the union of every rule on the path — so a subpath cannot be carved out of a granted tier; withholding `.git/` would mean never granting the repo root, which blocks creating any new top-level file. **The control is review-before-execute:** treat a workdir an agent has touched as untrusted input, and run the first build/test after an agent session inside the cage, not outside it. -- Outbound TCP to **any IP** on an allowed port (443 → any HTTPS endpoint → exfil). Landlock net is port-only, not domain/IP. -- **DNS/UDP exfil** — Landlock has no UDP control through V7. -- Read its own env → steal any credential injected into the ring. **Narrowed:** the child's env is now an allowlist by default (`BASE_ENV_KEEP` — POSIX/locale basics only), so a credential reaches the child only when the operator names it with `--env-keep` or opts out wholesale with `--env-inherit`. The residual is now "steal any credential the operator explicitly admitted," which is a decision rather than an accident. -- **Reach the operator's `ssh-agent` — unless built with the V9 wall.** `AccessFs::ResolveUnix` walls pathname-socket `connect()`, but only at ABI 9 and only when compiled `--features resolve-unix`. Without that (the default build, or any kernel < 7.1), there is no right gating it: both profiles grant `/tmp` (`src/profile.rs:44`, `:183`), OpenSSH's socket sits at `/tmp/ssh-XXXXXX/agent.NNNN`, and a confined child could sign as the operator. The env allowlist removes the trivial path (`SSH_AUTH_SOCK` is not passed) but not the hole — `/tmp` carries `ReadDir`, so the socket is findable by scan. Mitigation is not available at the Landlock layer (no deny rule; a granted `/tmp` cannot have a subpath carved out), so oring reports it loudly at seal and `warn_on_sockets` warns (a two-level scan that DOES reach the ssh-agent depth). Build `--features resolve-unix` on kernel 7.1+ to make it structurally absent. -- **Peer procfs read**: `/proc//{environ,maps,mem}` — a compromised child can read a peer uid-1000 process's environment/memory. These are VFS `read()`s gated only by `ptrace_may_access`, not by a blocked syscall, and **Landlock does not reliably wall them at the V7 floor** (measured NotBlocked on kernel 6.15, containment-matrix cell `proc-peer-env-full`). A newer kernel's `ptrace_access_check` hook was observed to deny them on 7.1.3, but that is a version-dependent bonus, **not a floor guarantee** — so the honest posture is that this is a residual. Metadata (`cmdline`/`stat`) is world-readable regardless. Controls: `--proc self`, or runtime PID-ns + `maskedPaths` (§9). -- **Connect to a pathname AF_UNIX socket — walled only with the V9 build.** With `--features resolve-unix` on kernel ≥ 7.1, Landlock **`RESOLVE_UNIX`** gates `connect()` to a filesystem-path socket (handled + granted in no tier → `EACCES`); abstract sockets are covered by scope (`EPERM`) at the V7 floor regardless. Without the feature (or below 7.1), pathname-socket connect is **unmediated** — the docker.sock/podman.sock/journald and ssh-agent classes are reachable. `warn_on_sockets` flags a granted tier holding a live socket (two-level scan), and oring prints the residual at seal; neither is a control, so the operator's real defense is: don't grant a tier holding a privileged socket, or build the V9 wall. -- Unfilterable kernel bug classes: `futex` UAF, the AF_UNIX **SCM_RIGHTS GC race** (genuinely no arg to mask — distinct from the now-filtered MSG_OOB path), Dirty-COW-via-`/proc/self/mem`. Required by threaded workloads. -- Read unmasked `/proc`/`/sys` unless the runtime masked them. - -### Non-goals -1. **Not a VM/hypervisor boundary.** Shared host kernel. Kernel-isolation-critical workloads (Xen fuzzing rig) need a VM underneath. -2. **Not a kernel-0-day defense** beyond surface reduction. -3. **Not a network policy engine.** Domain/IP egress = host nftables default-deny or a filtering proxy — **required companion control** (§9), stated loudly. -4. **Does not author/load host MAC.** SELinux/AppArmor stay the host's job and stack underneath (intersection). -5. **Does not mask procfs/sysfs.** Runtime `maskedPaths`/mount-view job. (Pathname unix-socket connect IS now mediated at V9 via `RESOLVE_UNIX` — see §4.) -6. **No supervisor / seccomp-notify / OCI-hook validation / egress firewall / credential broker / cgroups / rlimits.** Each needs a persistent or privileged component oring by definition isn't. +## 2. Threat model, losses, and non-goals + +**Moved to [`RESIDUALS.md`](RESIDUALS.md) on 2026-08-01.** The section number is +unchanged and every `DESIGN §2` citation still resolves. They are spread across +`src/`, `tests/` and the other documents here, and renumbering them would have +bought a reader nothing. + +It holds the attacker model, the kernel-enforced guarantees, every residual with +its loss and disclosure tag, and the non-goals. It is the longest thing here and +the one to read before relying on oring for anything, which is why it is now a +document a reader can open on its own rather than a third of this one. + +Correction history for those residuals is in +[`CORRECTIONS.md`](CORRECTIONS.md), cited from each entry as `[corrections: C-n]`. ---- ## 3. Architecture — rings and host layering @@ -84,328 +106,29 @@ Consequences: oring is **additive and safe when the runtime already confines** ( ## 4. Landlock design (ring 1) -Single ruleset, single `restrict_self`. - -### FS access tiers — DERIVED from the kernel right vocabulary, not enumerated - -| Tier | dir set | use | -|------|---------|-----| -| `read_only` | `ReadFile\|ReadDir` (+`Refer`) | config, read-only mounts | -| `read_exec` | `read_only\|Execute` (+`Refer`) | `/usr`,`/lib`,`/usr/lib`,`/usr/libexec`, interpreters | -| `write_noexec` | `read_only\|WriteFile\|MakeReg\|MakeDir\|RemoveFile\|RemoveDir\|MakeSock\|MakeFifo\|MakeSym\|Truncate\|IoctlDev` (+`Refer`) | `/tmp`, scratch — W^X | -| `write_exec` | `write_noexec\|Execute` | the workdir | - -**FS decisions:** -- **Build unions explicitly; do NOT use `AccessFs::from_write(abi)`** — that helper includes `MakeChar\|MakeBlock`. Device-node rights appear in no tier; combined with `handle_access(AccessFs::from_all(abi))` for FS they are handled-but-never-granted → globally denied (defense-in-depth with seccomp `mknod`/`mknodat`→ERRNO). -- **Dir-vs-file split** (`RightScope` in `landlock.rs`): the kernel rejects dir-only rights (`ReadDir`,`Make*`,`Remove*`,`Refer`) on a non-dir FD (`EINVAL`). Each `AccessFs` right is tagged `Both` or `DirOnly`; a file mask keeps only `Both` rights. Route each path by `metadata().is_dir()` (follows symlinks, matching `PathFd`). Missing paths → dir bucket. -- **Manual `IgnoreIfMissing`**: `PathFd::new` → `Err` on ENOENT → skip; any other error (EACCES) → **fail closed**. Because a silently-skipped path yields a downstream EACCES that looks like an oring bug, emit a startup diagnostic listing every skipped path (§8, §11). -- **Refer** in dir sets only. Safe on `read_only`: it is only the inert endpoint of a rename; unlinking the source still needs `RemoveFile`/`RemoveDir`. No write leaks. -- **List real usrmerge targets, not symlinks.** Do not rely on `/lib`→`/usr/lib`; list `/usr/lib` and `/usr/libexec` directly (node bundle / libexec live there). - -### FS ABI strategy — hard floor V7, `RESOLVE_UNIX` (V9) opt-in -Query `ABI::new_current()`. **Hard floor V7 / kernel 6.15** (`FLOOR_ABI = 7`, `src/landlock.rs`), hardcoded, refuse below it. V7 is the coherent-cage minimum: FS core + `Refer`/`Truncate` (V1–V3), TCP net (V4), `IoctlDev` (V5), IPC scope (V6), and audit logging (V7) are all *guaranteed* there, so there are **no degraded/backcompat branches** — running a partial cage on an old kernel is worse than refusing (D-4). Below V7 oring exits with one clear message; there is no `--allow-net-degrade`, no scope-skip-below-V6, no audit-skip-below-V7. Mask the FS core to detected ABI (`from_all(abi)`) so a healthy kernel returns `FullyEnforced`. - -One right beyond the floor, **`RESOLVE_UNIX` (V9 / kernel 7.1)**, walls `connect()` to a pathname AF_UNIX socket (docker.sock / ssh-agent). It is **additive hardening applied best-effort**: active only when built `--features resolve-unix` AND the kernel is ≥ V9 (`resolve_unix_active`), and granted in **no** tier so an out-of-domain pathname connect returns `EACCES`. When inactive, pathname-socket connect is a residual (§2), reported loudly at seal and by the `warn_on_sockets` lint. The degradation is fail-safe — the missing right only ever removes a *restriction*, never widens a grant, and its absence is never silent. `abi_from_version` clamps to the crate ceiling (V7 without the feature, V9 with it); `abi_from_version_clamp` (Kani, `make verify`) proves that clamp never over-claims. A hard V9 floor was rejected: it would make oring refuse on nearly every machine (7.1 is recent) and force a vendored fork, where the graceful design keeps the checked-in dependency the published crate (§10). - -### Net — per-direction, ABI-gated, no silent downgrade -rust-landlock chooses per-direction via `handle_access` (go-landlock's connect+bind coupling trap does not apply — one ruleset). -- `egress_tcp`/`ingress_tcp` each modeled `Option>`. `None` ⇒ don't handle that direction (unrestricted). `Some(ports)` ⇒ handle + one `NetPort::new(port, access)` per port. `Some(vec![])` ⇒ handled, zero rules ⇒ **explicit deny-all**. -- **Do NOT mask net to detected ABI.** For any requested net restriction (connect or bind, including the empty-vec deny-all), set `CompatLevel::HardRequirement` before `handle_access` — never let deny-all silently become allow-all, never launder a dropped net ring into `FullyEnforced`. TCP net is V4 and always present at the V7 floor, so this never degrades; there is no opt-in escape. -- **Landlock net gates only the `connect(2)`/`bind(2)` hook**, and only by *port* — not IP, not protocol. Two consequences the operator must know: an allowed port reaches *any* IP (port-only, §2), and `sendto(…, MSG_FASTOPEN, …)` performs an implicit connect inside the send path that never reaches `security_socket_connect`. The TCP port wall therefore holds only because **ring 2** blocks `MSG_FASTOPEN` (§5); Landlock alone does not close it. Domain/IP egress is a required host companion control (§9). - -### Scope (IPC) -`scope(Scope::from_all(abi))` ⇒ `AbstractUnixSocket|Signal`, always present at the V7 floor (scope is V6). An out-of-domain abstract-socket connect → `EPERM`, and signalling peers outside the domain is blocked. In the container path this containment also comes from netns/pidns; oring's scope ring is defense-in-depth there. - -### V7 audit logging — wire it -After `create()` and before `restrict_self()`, best-effort (gated ABI≥V7): `log_new_exec(true)` (LOG_NEW_EXEC_ON — log the confined child's denials) + `log_same_exec(false)` (suppress oring's own pre-exec noise). Single call covers FS+net+scope (one ruleset). Records surface to the HOST auditd/dmesg only — the confined uid-1000 child cannot and should not read them. Zero cost where the host has no audit path. - -### Status gate — reframed (§14 D-3) -Enforcement is guaranteed by **ABI-masking of the FS core** (`FullyEnforced` by construction on any non-broken V3+ kernel), not by the status check. The status gate is a **tripwire**, not the security-meaningful control: -- `NotEnforced` → **fatal**, actionable: "Landlock present but NotEnforced; kernel likely booted with `lsm=` disabling it or `CONFIG_SECURITY_LANDLOCK` off — refusing." -- `PartiallyEnforced` → **fatal** tripwire for a broken rust-landlock/kernel invariant (unreachable under FS-only masking); message prints detected ABI + the handled-access set oring itself built. -- `no_new_privs == false` → **fatal** (internal bug — oring set it in step 4). - -Additionally emit a **per-ring effective report** (fs=enforced, net=enforced|refused, scope=on|dropped(needs V6, host Vn)) so oring never prints green when a requested ring was dropped. - -### Rust sketch -```rust -let abi = ABI::new_current(); -if abi < ABI::V7 { refuse(120, "Landlock ABI < V7 (need kernel 6.15+); detected {abi:?}"); } - -// FS core: masked to detected ABI (>= V7 by the floor above). -let mut rs = Ruleset::default() - .set_compatibility(CompatLevel::BestEffort) - .handle_access(AccessFs::from_all(abi))?; - -// Net: HARD requirement — TCP (V4) is always present at the V7 floor. -if policy.net_requested() { - rs = rs.set_compatibility(CompatLevel::HardRequirement); - if let Some(_) = policy.egress_tcp { rs = rs.handle_access(AccessNet::ConnectTcp)?; } - if let Some(_) = policy.ingress_tcp { rs = rs.handle_access(AccessNet::BindTcp)?; } - rs = rs.set_compatibility(CompatLevel::BestEffort); -} -if policy.scope_ipc { rs = rs.scope(Scope::from_all(abi))?; } // scope (V6) present at floor - -let created = rs.create()?; -created.set_log_new_exec(true).set_log_same_exec(false); // best-effort V7 -// ... add_rules(fs tiers, net ports) ... -let status = created.restrict_self()?; -gate(status); // tripwire + per-ring report -``` - ---- +**Moved to [`INTERNALS.md`](INTERNALS.md) on 2026-08-01**, with §5 and §6. Section +number unchanged; every `DESIGN §4` citation still resolves. ## 5. Seccomp design (ring 2) -**Denylist, decisively** — `default = Allow` with targeted ERRNO blocks. oring wraps arbitrary dev toolchains (cargo/npm/pip/go/node/git/gcc/clang/python); an allowlist has to track the moving union of every runtime's syscalls — one miss = `SIGSYS` mid-build. This is why Docker's own default is a denylist. Job: delete the well-known LPE launchpads. A denylist can't stop a 0-day in an *allowed* syscall — Landlock is the containment backstop. - -**Errno, never blanket-kill** (per-syscall action, chosen by what the denial should look like to the caller): -- `ENOSYS(38)` where a userspace fallback exists: `io_uring*`, `userfaultfd`, `clone3`, `splice`/`tee`/`vmsplice`, `memfd_create`, `execveat`, `pidfd_open`, `keyctl`/`add_key`/`request_key`. -- `EPERM(1)` where there's no fallback: `mount`/`ptrace`/`bpf`/`kexec`/`reboot`/etc. -- `EAFNOSUPPORT(97)` for the `socket` family filter. -- `EOPNOTSUPP(95)` for the MSG_OOB / SIOCATMARK filters. -- `ENOTTY(25)` for the file-ioctl defense-in-depth set (deferred to `strict`/v2 — not in shipped `balanced`). - -`KillProcess` reserved for a future `paranoid` profile (turns capability probes into crashed builds). `Notify`/user-notif out of scope (reintroduces a supervisor). - -### x32 ABI guard — MANDATORY v1 prepend (fixes the central false claim) -seccompiler's auto arch-prologue compares only `seccomp_data.arch == AUDIT_ARCH_X86_64`. It kills i386/int-0x80 (arch differs) but **does NOT close x32**: x32 runs in long mode with arch == AUDIT_ARCH_X86_64, distinguished by `__X32_SYSCALL_BIT (0x40000000)` OR'd into `seccomp_data.nr`. An x32-numbered `ptrace`/`bpf`/`io_uring`/`mount`/`unshare` passes the arch guard, matches no number-keyed deny rule, and falls through to `default=Allow` — reopening every blocked launchpad on any `CONFIG_X86_X32_ABI=y` kernel (stock Ubuntu/Debian generic). seccompiler's exact-`u32`-nr rule model cannot express a masked-bit test. - -**Fix:** install a hand-assembled 7-instruction cBPF guard as a **separate `SET_MODE_FILTER` call installed FIRST** (filters stack; a KILL in any filter wins): -``` -LD seccomp_data.arch -JEQ AUDIT_ARCH_X86_64 -> next ; else RET KILL_PROCESS (kills i386 + foreign arch) -LD seccomp_data.nr -JSET 0x40000000 -> RET KILL_PROCESS (any x32-bit call) -RET ALLOW ; defer to the real denylist -``` -Whole-ABI kill is safe (a cap-drop=ALL uid-1000 dev toolchain never issues x32). This is a small audited splice, not a hand-rolled filter — the insta rule-snapshot (§11) makes it reviewable. The x32 re-entry (`nr | 0x40000000`) shares AUDIT_ARCH_X86_64, so a number-keyed deny is bypassable without it; the guard KILLs foreign/compat arches outright. - -### v1 `balanced` block set -The denylist is the known kernel escape surface, selected from first principles: each entry is an LPE launchpad, a kernel-attack-surface syscall, a named-CVE mitigation, or device-node creation — a functional set a `cap-drop=ALL`+NNP uid-1000 dev toolchain never legitimately needs. Grouped by errno (§ mechanism below); every row carries its rationale. This table is the representative core; the authoritative, exhaustive set is the compiled-in `&[SyscallRule]` in `src/seccomp.rs` (snapshot-tested, §11) — read it, not this table, when reasoning about coverage. - -| Syscall(s) | Match | Action | Rationale | -|---|---|---|---| -| `io_uring_setup/enter/register` | all | ENOSYS | async-IO LPE surface; runtimes fall back | -| `userfaultfd` | all | ENOSYS | UAF-exploit primitive | -| `memfd_create` | all | ENOSYS | fileless-exec staging | -| `clone3` | all | ENOSYS | struct args unreachable to cBPF → glibc/Go fall back to `clone` | -| `execveat` | all | ENOSYS | fexecve/memfd exec vector (`execve` stays ALLOWED) | -| `pidfd_open` | all | ENOSYS | pidfd LPE surface | -| `splice`/`tee`/`vmsplice` | all | ENOSYS | Dirty-Pipe (CVE-2022-0847) | -| `keyctl`/`add_key`/`request_key` | all | ENOSYS | keyring LPE | -| `bpf` | all | EPERM | eBPF LPE surface | -| `perf_event_open` | all | EPERM | perf LPE surface | -| `ptrace`/`process_vm_readv`/`process_vm_writev`/`kcmp`/`process_madvise`/`pidfd_getfd` | all | EPERM | cross-process (does NOT cover /proc/pid/mem — §2) | -| `mount`/`umount2`/`open_tree`/`move_mount`/`fsopen`/`fsconfig`/`fsmount`/`fspick`/`pivot_root`/`chroot`/`setns` | all | EPERM | mount/ns manipulation | -| `mknod`/`mknodat` | all | EPERM | device-node creation (belt w/ Landlock) | -| `unshare` | 7 × MaskedEq, one per `CLONE_NEW*` bit | EPERM | **per-flag** (§ below) | -| `clone` | 7 × MaskedEq, one per `CLONE_NEW*` bit | EPERM | **per-flag**; blanket block would break threads | -| `kexec_load`/`kexec_file_load`/`init_module`/`finit_module`/`delete_module` | all | EPERM | kernel image/module | -| `reboot`/`swapon`/`swapoff`/`acct`/`quotactl`/`vhangup`/`syslog` | all | EPERM | admin ops | -| `ioperm`/`iopl`/`pciconfig_*` | all | EPERM | port IO | -| `settimeofday`/`clock_settime`/`adjtimex`/`clock_adjtime`/`sethostname`/`setdomainname` | all | EPERM | global mutation | -| `fanotify_init`/`fanotify_mark` | all | EPERM | fs-wide notify | -| `move_pages`/`migrate_pages`/`modify_ldt` | all | EPERM | niche LPE surface | -| `seccomp` | all | EPERM | self-defense | -| `socket`/`socketpair` | `Ge`(family, 17) | EAFNOSUPPORT | block PACKET/TIPC/ALG/VSOCK; allow UNIX/INET/INET6/NETLINK (`socketpair` walks the same family gate) | -| `send`/`sendto`/`recv`/`recvfrom` | MaskedEq(arg3 & MSG_OOB) | EOPNOTSUPP | **CVE-2025-38236 AF_UNIX MSG_OOB UAF** | -| `sendmsg`/`recvmsg` | MaskedEq(arg2 & MSG_OOB) | EOPNOTSUPP | same — flags is arg2 for the msg variants | -| `sendmmsg`/`recvmmsg`/`recvmmsg_time64` | MaskedEq(arg3 & MSG_OOB) | EOPNOTSUPP | same — flags is arg3 for the mmsg variants | -| `ioctl` | Eq(arg1 == SIOCATMARK 0x8905) | EOPNOTSUPP | MSG_OOB pair | -| `sendto`/`sendmmsg` | MaskedEq(flags & MSG_FASTOPEN) | EOPNOTSUPP | implicit-connect egress that never reaches Landlock's `connect()` hook — the only reason the TCP port wall holds (ATTACKS §3a) | -| `open_by_handle_at`/`name_to_handle_at` | all | EPERM | fd-from-handle reopen that sidesteps path-based Landlock | -| `ioctl` | Eq(arg1 ∈ {TIOCSTI, TIOCLINUX, TIOCSETD}) | EPERM | TTY injection | -| `ioctl` | Eq(arg1 ∈ file-ioctl set below) | ENOTTY | file-fd defense-in-depth — **deferred to `strict`/v2**, not in shipped `balanced` | -| `mmap`/`mmap2` | MaskedEq(arg3 & MAP_GROWSDOWN) | EPERM | StackRot (CVE-2023-3269) | -| `prctl` | MaskedEq(arg0 == PR_SET_DUMPABLE & arg1 > 0), (arg0 == PR_SET_PTRACER) | EPERM | re-dumpable / ptracer | - -**File-ioctl defense-in-depth set — deferred to `strict`/v2, NOT shipped in `balanced`** (exact-cmd `Eq`→ENOTTY, same mechanism as TIOCSTI; encoding a wrong ioctl number breaks benign ioctls, so it waits for the tighter profile): `FS_IOC_ENABLE_VERITY` (irreversible in-place immutability), `FS_IOC_FIEMAP` (physical-layout leak), `FS_IOC_ADD_ENCRYPTION_KEY`/`FS_IOC_REMOVE_ENCRYPTION_KEY`, `XFS_IOC_FSBULKSTAT`/`XFS_IOC_BULKSTAT`/`XFS_IOC_FSGEOMETRY` (cross-tier inode metadata on ext4/xfs), `EXT4_IOC_ALLOC_DA_BLKS`. These are metadata-leak / own-tier DoS, **not** confinement escape — they tighten the already-conceded writable-tier residual. They matter mainly on a bare-metal host with real ext4/xfs; largely moot on a container tmpfs/overlay. Tools fall back cleanly on ENOTTY (verified: `cp`/`rsync` do). - -**Deliberately NOT in v1:** -- `personality` — **cut from `balanced`.** With `mprotect` unrestricted (mandatory for JIT/GC runtimes), blocking `READ_IMPLIES_EXEC` buys no W^X margin an attacker with code-exec can't already get via `mprotect(PROT_EXEC)`, and `ADDR_NO_RANDOMIZE` self-disable is negligible post-compromise; blanket-blocking in `balanced` only risks breaking `setarch`/ASAN-re-exec/emulators. **`strict` DOES block `personality(READ_IMPLIES_EXEC)`** — there `mprotect`/`pkey_mprotect(PROT_EXEC)` are filtered for true W^X (§7), so `personality(RIE)` is a real escape route that arg-filtering must also close (`seccomp.rs`). -- **Block-device / loop / dm ioctl wall** (`BLK*`, loop 0x4c, dm 0xfd) — dead under cap-drop=ALL + Landlock device-node denial + no block-dev nodes. → `strict` later. -- **btrfs 0x94 magic block** — its only unprivileged member is the benign `FICLONE`/`FICLONERANGE` (reflink copy); every destructive member is CAP_SYS_ADMIN-gated. An order-dependent "ALLOW FICLONE first, then ERRNO the 0x94 magic" carve-out is **impossible** with cumulative seccomp filters (ERRNO outranks ALLOW; seccompiler binds one action per filter). So oring does NOT block 0x94 at all — no protection lost. **Invariant: oring only ever uses whole-syscall or whole-magic blocks with no benign-exception; any in-mask carve-out is a libseccomp-only capability oring does not carry.** - -### `clone`/`unshare` — per-flag, not one combined mask -cBPF/seccompiler cannot do "any bit in mask set" in one compare (no masked-not-equal op). A single `MaskedEq(mask=OR-of-all-NEW*, value=OR-of-all)` matches only when ALL bits are set — trivially bypassed by `unshare(CLONE_NEWUSER)` alone (the userns-LPE launchpad). Emit **7 rules per syscall**, each `MaskedEq(mask==value==single bit)`: NEWNS 0x20000, NEWCGROUP 0x2000000, NEWUTS 0x4000000, NEWIPC 0x8000000, NEWUSER 0x10000000, NEWPID 0x20000000, NEWNET 0x40000000. The security-load-bearing bit is `CLONE_NEWUSER` (the unprivileged-userns LPE launchpad); the other six namespace flags are cheap defence-in-depth on the same syscalls. - -### Mechanism, action, arch -- **seccompiler 0.5.x** (Firecracker, pure Rust — `libc` bindings + optional serde, no `-sys`, no C link). Only option that keeps the musl-static entrypoint AND gives `SeccompCmpOp::MaskedEq`. Reject libseccomp-rs (links `libseccomp.so`). Reject hand-rolled cBPF except the audited x32 guard splice. -- **Per-syscall errno mixing = N cumulative sub-filters, one per DISTINCT errno.** Not "two." Partition the deny set so each blocked syscall matches in exactly one sub-filter (`mismatch_action=Allow`, `match_action=Errno(e)`). v1 `balanced` = **4 sub-filters** (EPERM, ENOSYS, EAFNOSUPPORT, EOPNOTSUPP) + the x32 KILL guard installed first (the file-ioctl `ENOTTY` set is deferred to `strict`/v2, above). Filters stack; ERRNO outranks ALLOW; no ties. Cost is an N-filter stack of linear partition scans + N−1 arch prologues, not a blanket "double" — quantify against actual partition sizes. The errno partition is a property of each syscall's denial semantics (pretend-absent → ENOSYS, denied → EPERM/EACCES, unsupported family → EAFNOSUPPORT), so the filter count follows the profile automatically. -- **Arch: x86_64 only + kill-on-mismatch + the x32 guard.** aarch64 is a compile-time target only, added when a native arm64 Linux host becomes a security target (landlock 0.4.5 + seccompiler 0.5 both support it). -- **Representation:** blocklist as a compiled-in Rust `&[SyscallRule]` — no JSON-at-runtime on the security-critical path, type-checked, uses `libc::SYS_*` constants (no syscall-name table). - ---- +**Moved to [`INTERNALS.md`](INTERNALS.md) on 2026-08-01**, with §4 and §6. Section +number unchanged; every `DESIGN §5` citation still resolves. ## 6. Pre-exec sequence (definitive order — uniform fail-closed) -`pipeline::seal()` runs this exact sequence; `main` then execs. **Every step is HARD (`?`)** — there is no SOFT category in v1 (both former "soft" steps are effectively infallible for a valid unprivileged caller; promoting them makes "every step fail-closed" literally true). `panic="abort"` ⇒ no partial-seal-then-exec. Note `panic="abort"` is set on the **release** profile only (§12); debug/test builds are `panic="unwind"`. The seal is structurally safe under either: `seal() -> Result`, its `execve` is the last statement, so a panic anywhere unwinds *past* (never reaching) the exec — a future dev must not add a `catch_unwind` around the seal assuming abort semantics hold in every profile. - -``` - 1. parse argv split at "--"; require a target after it (else exit 2) - 2. load policy --policy | ORING_POLICY env | --profile balanced (none → refuse, exit 120) - 3. resolve target PATH lookup → absolute STRING; fstat: reject non-regular/non-exec; - assert target under a read_exec/write_exec rule (not found → exit 122) - 4. NO_NEW_PRIVS prctl(PR_SET_NO_NEW_PRIVS,1) (HARD: before seccomp) - 5. DUMPABLE=0 prctl(PR_SET_DUMPABLE,0) (HARD; oring-window only) - 6. LANDLOCK ring 1; core hard-fail (HARD: before seccomp) - 7. signal-mask reset sigprocmask(SIG_SETMASK, empty) (HARD) - 8. FD scrub close_range(3, ~0, CLOSE_RANGE_CLOEXEC) - 9. build argv/env strip ORING_POLICY (pure memory) -10. SECCOMP x32 guard filter, then N errno sub-filters (LAST restriction) -11. attest + execve emit seal line to stderr, then execve resolved path, clean env -``` - -**Rationale:** -- **Resolve target (3) before Landlock (6).** Resolve to an **absolute string** first → clean `exit(122)` on missing binary, rather than a post-seal `EACCES` that looks like a malfunction. `fstat` once to reject non-regular/non-executable and enforce the read_exec-coverage check (§8), then close that fd. **No O_PATH fd is held** — TOCTOU closure would require `fexecve`/`execveat`, which oring deliberately blocks (memfd fileless-exec; cBPF can't safely arg-filter it), and the race has no attacker in either deployment (read-only rootfs in-container; single-threaded, no child, no local writer standalone). -- **NNP (4) before seccomp (10).** Unprivileged `SET_MODE_FILTER` returns EACCES without NNP. Set it explicitly to decouple from Landlock ordering. -- **`DUMPABLE=0` (5)** — one syscall; protects oring's own pre-exec window only (blocks same-uid `/proc/self/mem`/ptrace-attach while policy sits in memory; suppresses a policy-leaking core dump). **NOT inherited across execve** — the kernel resets the child to dumpable. Child-side ptrace/mem protection comes from the seccomp `ptrace`/`process_vm_*` denials, NOT this step (and does NOT cover peer `/proc//mem` — §2). -- **Landlock (6) before seccomp (10)** so `landlock_*` setup syscalls aren't blocked. -- **Signal-mask reset (7)** — execve inherits the mask unchanged; a parent with signals blocked silently hands the child a bad mask. Correctness fix a Go-managed sealer doesn't hit. -- **Disposition normalization (11a)** — execve resets *caught* handlers but **preserves `SIG_IGN`**, a separate inheritance channel from the mask. Rust installs `SIGPIPE=SIG_IGN` at startup, so an un-normalized child sees `EPIPE` where it should die (`producer | head -1` hangs; `$PIPESTATUS` reads 0, not 141). Every ignored signal except `SIGKILL`/`SIGSTOP` is restored to `SIG_DFL` immediately before execve. Placement is after the seal-line write for ordering hygiene only — **not**, as first documented, because oring's ignored `SIGPIPE` protects that write: `eprintln!` panics on failure and `panic=abort` converts that to an abort, so a closed stderr prevents the exec under either ordering. POSIX hygiene, not a confinement control. Known regression: this also clears a *deliberately* inherited `SIG_IGN`, so `nohup oring -- cmd` no longer hands the child an ignored `SIGHUP`. -- **FD scrub (8) = `close_range(3, RawFd::MAX, CLOSE_RANGE_CLOEXEC)`** — **not** the `/proc/self/fd` walk (that walk needs `READ_DIR` on a `/proc` rule the lean profiles don't grant → EACCES → the pipeline would brick every invocation; granting `/proc` to fix it reintroduces the peer-procfs residual §2 disclaims). `close_range` needs no path, is indifferent to Landlock ordering, preserves CLOEXEC-not-close semantics, and (critically) does NOT close the exec path since we hold no target fd. Base `close_range` is 5.9+, `CLOSE_RANGE_CLOEXEC` 5.11+ — both well below the V7/6.15 floor. **No async anywhere** (tokio would spawn the epoll/eventfd/pipe FDs this defeats). Sync-only, hard architectural constraint. -- **Env-build (9)** — the child's env is an **allowlist** (`BASE_ENV_KEEP`, §8), built fresh, not an inheritance minus `ORING_POLICY`. The old claim that "env is not a security boundary here" was wrong in one specific way: the child being sandboxed does not stop it *reading* a credential it was handed, and a credential in the env is exactly the exfil half SECURITY.md owns. `LD_PRELOAD` into the *child* is not a bypass; `LD_*` into *oring* would be catastrophic but oring is static-musl with no dynamic linker → `LD_*` ignored (a security reason for the musl constraint). -- **Attest + Seccomp + execve (10–11).** **Cross-ring contract: the filter MUST allow `execve`, `exit_group`, and `write`** (for the seal line), or oring bricks itself. `execve` allowed; only `execveat`→ENOSYS. - ---- +**Moved to [`INTERNALS.md`](INTERNALS.md) on 2026-08-01**, with §4 and §5. Section +number unchanged; every `DESIGN §6` citation still resolves. **The order is +load-bearing** — a wrong order is a confinement bypass, not a style nit. ## 7. Policy schema + profile -The policy is `version` + six FS/net fields (`read_only`, `read_exec`, `write_noexec`, `write_exec`, `egress_tcp`, `ingress_tcp`) + a minimal `seccomp` selector. - -```json -{ - "version": 1, - "landlock": { - "read_only": ["/etc","/usr/share"], - "read_exec": ["/usr/bin","/lib","/lib64","/usr/lib","/usr/libexec"], - "write_noexec": ["/run","/var/log","/tmp"], - "write_exec": ["/work/repo"], - "egress_tcp": [443], - "ingress_tcp": [], - "scope_ipc": true - }, - "seccomp": { "profile": "balanced" } -} -``` - -```rust -#[derive(Deserialize)] #[serde(deny_unknown_fields)] -struct Policy { #[serde(default="one")] version: u32, - #[serde(default)] landlock: Landlock, - #[serde(default)] seccomp: Seccomp } - -#[derive(Deserialize, Default)] #[serde(deny_unknown_fields)] -struct Landlock { - #[serde(default)] read_only: Vec, #[serde(default)] read_exec: Vec, - #[serde(default)] write_noexec: Vec, #[serde(default)] write_exec: Vec, - #[serde(default)] egress_tcp: Option>,// None=unrestricted, Some([])=deny-all - #[serde(default)] ingress_tcp: Option>, - #[serde(default="yes")] scope_ipc: bool } - -#[derive(Deserialize, Default)] #[serde(deny_unknown_fields)] -struct Seccomp { #[serde(default)] profile: SeccompProfile } // v1: only Balanced -``` - -`deny_extra`/`allow_extra` are **cut from v1** — no v1 consumer, and validating names would drag a syscall-name→number arch table onto the (sole) config-parse path. Deferred to §13-later. - -**Validation (parse-time, before any restriction):** `version==1`; `deny_unknown_fields` (a typo'd `read_execc` errors, never silently grants nothing); absolute paths only; port ≤65535 (serde-free); **target binary must be under some `read_exec` rule** (else specific error, not a cryptic `execve: EACCES`); **error on a missing `--workdir`** (typo-confines-to-nothing footgun); warn on missing system dirs; loud warn on `egress_tcp` containing 53 (UDP DNS not covered) and on any granted tier containing/ancestoring a live socket inode (`docker.sock`/`podman.sock`/containerd/`/run` — §2 escape). Every message prefixed `oring:`, stderr, non-zero exit. - -**Self-protection:** `Policy::check_self_protection` (run after `validate`, once every tier is final) hard-refuses when **oring's own binary** or the **policy file** resolves under any write tier — the `--policy ./oring.json --workdir .` shape, where the child could rewrite the policy governing its own next run. An unresolvable `current_exe()` WARNs rather than refuses (an unreadable `/proc/self/exe` is an environment defect, not a policy input). - -**Deny-by-default — three states:** no policy resolves → refuse (exit 120); policy grants nothing → refuse with the target-not-covered message; policy with grants → apply. Landlock < V7 floor → refuse (§4); seccomp load failure → refuse. - -### Profile: `balanced` (the only v1 profile) -Compiled-in constant; auto-includes standard read-exec system dirs so the common case is "name your workdir." Agent edits a repo + HTTPS: -- read_exec: `/usr/bin /usr/local/bin /bin /lib /lib64 /usr/lib /usr/libexec /opt` -- read_only: `/etc /usr/share` + `/proc` (read-only, so `git`/`node`/`python` can read `/proc/self/*`; peer *secret* reads stay walled by Landlock's ptrace hook, §2) -- write_exec: workdir (build agent compiles+runs in-tree — the in-tree W^X residual is documented in §2) -- write_noexec: `/tmp /var/log /var/cache /var/tmp` + the standard character devices -- HOME toolchain caches: `~/.rustup` read_exec; `~/.gitconfig` read_only; `~/.npm ~/.cache` write_noexec; and the split below -- `egress_tcp:[443]`; `ingress_tcp:[]` (**deny-all TCP bind** — a fixed-port dev server or test listener, e.g. `python -m http.server 8000`, gets `EACCES`; widen with a `--policy` naming the port); `scope_ipc:true`; seccomp `balanced`. - -**`~/.cargo` and `~/go` are split, and the split is load-bearing.** `bin/` under each is on the -operator's `$PATH`, so granting write there is an escape that needs no kernel bug: the child drops a -binary, the operator runs it later *unconfined* at their own uid. The tiers are therefore - -| path | tier | why | -|---|---|---| -| `~/.cargo`, `~/go` | read_only | cargo reads `config.toml`/`env`; these are **parents of `bin/`** so they must not carry write | -| `~/.cargo/bin`, `~/go/bin` | read_exec | run the shims; never writable | -| `~/.cargo/registry`, `~/.cargo/git`, `~/.cargo/.package-cache`, `~/go/pkg` | write_noexec | where the toolchains actually write | - -The shape is forced by Landlock's combination rule: *"One policy layer grants access to a file path -if at least one of its rules **encountered on the path** grants the access"* -(`Documentation/userspace-api/landlock.rst`). Rules **union along the path walk** — a narrower rule -does not override a wider one. A `write` grant on `~/.cargo` would therefore confer write on -`~/.cargo/bin` regardless of any rule placed there, so the write grants go on sibling subtrees and -never on a parent of `bin/`. Nothing executes from these caches (cargo builds scripts and proc-macros -into `target/`; `go build` links into `$TMPDIR`), so no-exec costs nothing. - -*Compat, stated plainly:* `cargo install` and `go install` do not work inside the cage — they write -`bin/`. `~/.cargo/.package-cache` is cargo's top-level lock **file**, granted as a file rule so it -does not widen `~/.cargo`; on a `$HOME` that has never run cargo it does not exist yet, is skipped -like any missing path, and cargo cannot create it under a read-only `~/.cargo`. Run cargo once -unconfined first, or grant it via `--policy`. - -`strict` **ships alongside `balanced`** (`by_name` resolves both; `src/profile.rs`): untrusted-binary posture — nothing granted, workdir `write_noexec` for true W^X, deny-all egress, and a seccomp-blocked `pkey_mprotect(PROT_EXEC)` so the W^X wall can't be reopened. `--profile` accepts `balanced` (default) or `strict`. A `dev` (interactive, wide) profile is still deferred until a wired consumer exists; anything between or beyond the two profiles is expressed via `--policy`. - -**`--workdir `** injects one path into `balanced`'s `write_exec`. oring asserts the resolved target binary is already under a `read_exec`/`write_exec` tier and **refuses (exit 122)** if it is not — it does NOT auto-grant the target. No mount-scraping, no `$PATH`/`$HOME` globbing (predictable > clever). - ---- +**Moved to [`REFERENCE.md`](REFERENCE.md) on 2026-08-01**, with §8. Section number +unchanged; every `DESIGN §7` citation still resolves. ## 8. CLI / UX -``` -oring [OPTIONS] -- [args...] - - --policy policy JSON file (wins over env) - --profile baked profile: balanced (default) | strict - --workdir inject DIR into the profile's writable workdir tier - (write_exec for balanced; write_noexec for strict) - --exec-tmp create DIR (0700), grant write_exec, point child's TMPDIR at it, - for tools that write-then-exec in scratch. /tmp stays no-exec. - --proc /proc grant: full (default) | self | none (self closes peer-procfs) - --env-clear pass NO env to the child except --env-keep vars - --env-keep allowlist one more env var (repeatable). Child gets POSIX basics by - default; credentials (SSH_AUTH_SOCK/*_TOKEN/*_API_KEY/AWS_*) must be named - --env-inherit pass the FULL parent env (legacy default; unsafe — child reads every credential) - --require-all-grants refuse to seal if any intended path could not be granted (CI) - --denials report what Landlock DENIED to a sealed run, from the kernel's V7 audit - --since journal window for --denials (default: -1 hour) - --explain print the effective plan and exit (no execve) - --check validate policy+target+ABI floor (no execve) - -v, --verbose emit the effective plan to stderr before execve - -- everything after = target argv (required) - -Policy also via ORING_POLICY (JSON env). ORING_QUIET=1 suppresses the seal line. - -DELIVERY PRECEDENCE (highest wins; refuse if none): --policy > ORING_POLICY > --profile. - (SANDBOX_POLICY and ORING_PROFILE are NOT accepted — no consumer; keeps one parser shape.) - --profile supplies baked defaults; --policy/ORING_POLICY whole-replace it (no merge). - -EXIT: 120 confinement refused (no policy / Landlock < V7 floor / seccomp load failed) - 121 policy parse/validation error - 122 target not found - 159 seccomp KILL (x32/arch guard) = 128+SIGSYS - * child's execve-preserved status -``` - -Exit codes are deliberately **out of the 125/126/127 shell+docker band and the 128+N signal band** so a fail-closed security event is never confused with a benign child `sh -c` exec failure. Every refusal emits one machine-parseable stderr line `oring: REFUSE reason= (code=120)`; a successful seal emits (§6 step 11, §11) a positive seal line (§12). - -**Diagnosis path (self-applied cages give bare errnos with no attribution):** -- `--explain` — pre-exec string dump: resolved FS tiers + path lists, connect/bind port sets, IPC scope state, detected ABI vs floor and which rings actually engaged, seccomp profile + one-line block summary, target read_exec coverage. No deps, no execve. -- `-v`/`ORING_VERBOSE=1` — emit that same one-block summary to stderr right before execve, so a failed build's log carries policy context inline. -- Documented errno→cause table in `--help`/README: EACCES on a path = Landlock FS; EACCES on `connect()` = port not in `egress_tcp` / net ABI absent; ENOSYS on io_uring/memfd/clone3 = oring seccomp; EPERM on ptrace/mount/bpf = oring seccomp. -- **Ceiling stated plainly:** in-band per-denial attribution is impossible without a supervisor (seccomp-notify is out of scope; ptrace/strace are blocked by oring's own filter). `--explain` + verbose-startup + the errno table are the intended in-band workflow; `--denials ` reads the host-side V7 audit records after the fact (operator-side, not readable by the confined child). - -### Scratch, procfs, and env flags — security notes -- **`--exec-tmp `** grants one operator-named `write_exec` scratch dir and wires the child's `TMPDIR` to it, for tools that write-then-`execve` in temp (Miri's sysroot build, `cc`/`ld`, `-sys`/bindgen build scripts, `go test`/`go run`) which `/tmp` (`write_noexec`) denies. It is created at **0700** — a world-writable scratch would let another local uid plant a binary the confined child then runs. A **pre-existing** path is re-validated, not trusted: oring `lstat`s it and refuses a symlink (else `canonicalize` would grant the link's *target* `write_exec` — a widening) or a group/other-writable directory. The grant is folded in **before** `check_self_protection`, so pointing `--exec-tmp` at the oring binary or policy still refuses. `/tmp` itself stays no-exec — this opens exactly one dir. Residual: a TOCTOU swap by an attacker who controls a writable *parent* — name the scratch under sticky `/tmp` or an operator-owned dir. Pinned by `tests/exec_tmp.rs`. -- **`--proc `** = `full` (default) | `self` | `none`. `self` grants own-pid procfs plus a curated global allowlist, closing even the peer-*metadata* leak (§2); `none` grants no `/proc`. -- **Env allowlist** — the child's env is built fresh from `BASE_ENV_KEEP` (POSIX/locale basics) plus any `--env-keep NAME`; credentials are withheld by construction, not by enumerating what to drop. `--env-clear` passes only what is named; `--env-inherit` restores the legacy pass-everything default and is documented as unsafe (a compromised child reads every credential in the shell). The rule is the §5 rubric applied to a second namespace: *enumerate what gets in, never what to drop.* Pinned by `tests/deny_env.rs`. - ---- +**Moved to [`REFERENCE.md`](REFERENCE.md) on 2026-08-01**, with §7. Section number +unchanged; every `DESIGN §8` citation still resolves. ## 9. Deployment — container entrypoint + standalone @@ -424,10 +147,10 @@ execve-only and does not reap. ### Companion controls (REQUIRED — oring is only honest with these) - **Egress:** host nftables default-deny + a credential proxy/broker. oring cannot do domain/IP egress or hide the API key from the process that legitimately holds it (§2). Shipping oring without a paired egress control gives a false sense of containment for exfil. - **Peer procfs:** runtime PID namespace + `maskedPaths` on `/proc//{mem,environ,maps,kcore}` (default docker/podman provide both). Without a PID ns (standalone), broad `/proc` read exposes every uid-1000 peer's secrets. -- **Privileged sockets:** at **V9** ring 1 now blocks `connect()` to a pathname unix socket (`RESOLVE_UNIX`, `EACCES`) and to abstract sockets (scope, `EPERM`) — see §4. Bind-mounting `docker.sock`/`podman.sock`/containerd is nonetheless discouraged (defense-in-depth; and it is still unmediated on a V7/V8 kernel). oring's extra contribution is the policy-apply-time socket lint (§7), now ABI-gated to fire only where the connect is actually unmediated. +- **Privileged sockets:** ring 1 blocks `connect()` to a pathname unix socket (`RESOLVE_UNIX`, `EACCES`) and to abstract sockets (scope, `EPERM`) on every kernel oring runs on — V10 is the floor and `RESOLVE_UNIX` (V9) sits below it, so the V7/V8 window where this was unmediated is now refused outright rather than warned about (§4, D-4b). Bind-mounting `docker.sock`/`podman.sock`/containerd is still discouraged as defense-in-depth: the wall is a Landlock right, and a right is only as good as the ruleset that carries it. The policy-apply-time socket lint (§7) is retained but inert at the floor — it is the tripwire that would start firing again if the floor were ever lowered. ### Standalone (bare metal) -Same static binary: `oring --policy /etc/oring/rig.json -- ./fuzzer ...`. All rings unprivileged, no root/MAC/pre-installed profile. Hard floor well below a modern host (e.g. Alpine on 6.18). **No IPC/signal boundary** here (shared host netns+pidns, kernel likely <6.12): a compromised child can `SIGKILL` same-uid host processes (incl. a supervising orchestrator) and reach host abstract sockets (systemd/D-Bus). Companion control: launch the target inside a PID+net namespace (`unshare`/`bwrap`/`systemd-run`) or on ≥6.12. Do NOT grant `/run` (or grant only specific non-socket subpaths). Keep `/tmp`/scratch `write_noexec` so the default profile stays W^X-clean where no `noexec` mount sits above oring. +Same static binary: `oring --policy /etc/oring/rig.json -- ./fuzzer ...`. All rings unprivileged, no root/MAC/pre-installed profile. **The V10 / kernel 7.2 floor binds here too, and it is the main siting constraint** — LTS distro kernels do not meet it, so a bare-metal rig needs 7.2+ or oring refuses at seal (exit 120, §8). IPC scope (V6) sits below that floor and is always installed, so a child cannot signal, or reach abstract sockets belonging to, processes outside its domain — that is ring 1, not the host. What standalone lacks is a PID and network namespace: peer *metadata* under `/proc` stays readable (§2) and nothing bounds egress by address. Companion control: launch the target inside a PID+net namespace (`unshare`/`bwrap`/`systemd-run`) or on ≥6.12. Do NOT grant `/run` (or grant only specific non-socket subpaths). Keep `/tmp`/scratch `write_noexec` so the default profile stays W^X-clean where no `noexec` mount sits above oring. --- @@ -449,7 +172,9 @@ tests/ deny_fs deny_net deny_syscall deny_x32 deny_env deny_strict exec_tmp ```toml [dependencies] landlock = "0.4.5" # pure Rust, no C, musl-clean (published crate; tops at V7) -seccompiler = "0.5" # pure-Rust BPF emitter; MaskedEq; no TSYNC (single-threaded) +seccompiler = "0.5" # pure-Rust BPF emitter; MaskedEq. Exposes TSYNC via + # apply_filter_all_threads(); oring uses the per-thread + # apply_filter() by choice (§10 single-threaded note) libc = "0.2" # raw syscalls (prctl/close_range/execve/sigprocmask/seccomp) + SYS_*/flag consts serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -481,50 +206,158 @@ opt-level="z" lto=true codegen-units=1 strip=true panic="abort" # fail-clo - `deny_fs` → EACCES(13) outside tiers + control write in `write_exec` temp. - `deny_net` → Landlock EACCES on bad port (ECONNREFUSED on allowed port = pass). - `deny_syscall` → EPERM ptrace, ENOSYS keyctl, **`unshare(CLONE_NEWUSER)` alone → EPERM** (authoritative per-flag assertion, not just all-bits-set), EOPNOTSUPP on MSG_OOB send. -- `deny_x32` → issue `ptrace` via inline `syscall` with `rax = 0x40000000|__NR_ptrace`, assert SIGSYS/kill; gate on detecting `CONFIG_X86_X32_ABI`, log LOUD if x32 present and blocked (never silently Allowed). +- `deny_x32` → issue an x32-numbered syscall (`rax = 0x40000000|nr`) and expect + SIGSYS. The **process-level** cells cannot close this class on their own: the + dominant leak family is a monotone threshold that always spares the numbers a + cell names, measured leaking six x32 syscalls while every cell stayed green. + What covers it is the model-level check over 12,354 points plus the Kani proof; + the file's own header forbids citing it as proof. See `VERIFICATION.md`. -**d. CI + LOUD skips (never silent-green):** the GitHub hosted `ubuntu-24.04` runner boots kernel ~6.8 — *below* oring's V7 floor — so the ring self-exec probes cannot run on the bare runner. They run inside a **virtme-ng / KVM guest** booting a ≥6.15 kernel (matrix at ABI V7/V8/V9, KVM available on the free runner via a udev rule). Runtime-probe `ABI::new_current()`; below the V7 floor → `eprintln!("SKIP: …")` **and count the skip**. Gate hard runs behind `ORING_REQUIRE_KERNEL=1` so a runner that should have the kernel can't silently skip green. Unit + rule-table snapshot + the z3 SMT gate + Kani proofs run on the bare runner; macOS dev: integration cfg-gated off, unit+snapshot compile. +**d. CI + LOUD skips (never silent-green):** no hosted GitHub runner is at oring's floor, so the ring self-exec probes never run on a bare runner. They run inside a **virtme-ng / KVM guest** booting a ≥7.1 kernel (KVM available on the free runner via a udev rule). The old V7/V8/V9 ABI matrix collapses to V9: sub-floor ABIs are now refusal cases, not degraded-run cases, so what they exercise is the floor gate itself — worth one cheap cell each asserting exit 120, not a full confinement leg. Runtime-probe `ABI::new_current()`; below the V9 floor → `eprintln!("SKIP: …")` **and count the skip**. Gate hard runs behind `ORING_REQUIRE_KERNEL=1` so a runner that should have the kernel can't silently skip green. Unit + rule-table snapshot + the z3 SMT gate + Kani proofs run on the bare runner; macOS dev: integration cfg-gated off, unit+snapshot compile. -**e. Drift alarm (roadmap, non-blocking):** track the kernel's evolving LPE-primitive surface (new syscalls in the RustSec/kernel-hardening advisory stream) against oring's `&[SyscallRule]` and a checked-in rationale for each intentional omission, so a genuinely new escape primitive forces a human decision instead of silent lag. +**e. Drift alarm (not implemented):** track the kernel's evolving LPE-primitive surface (new syscalls in the RustSec/kernel-hardening advisory stream) against oring's `&[SyscallRule]` and a checked-in rationale for each intentional omission, so a genuinely new escape primitive forces a human decision instead of silent lag. -**Fuzzing (deferred to §13-later):** the policy parser sits on a parent/config trust boundary, NOT an attacker-controlled path (the attacker is the post-execve child; nested re-wrapping only tightens). `panic="abort"` + refuse-on-error makes any parse bug fail-closed. So cargo-fuzz on `Policy::parse` is not v1 scope. `cargo-deny`/`cargo-audit` stay in CI (tiny dep tree by design). +**Fuzzing (not in the current implementation):** the policy parser sits on a parent/config trust boundary. **CORRECTED 2026-07-29: the clause that followed — "NOT an attacker-controlled path" — is false, and it was the only place in the tree asserting those bytes are trusted.** `ORING_POLICY` is an environment variable, `src/main.rs` refuses `--profile` alongside it *precisely because* an untrusted earlier step can set it, and policy-derived strings reach 13 of ~14 diagnostic print sites unsanitised — a forged `SEALED` line was injected through one of them (`BUILD-QUEUE.md` T26). Two premises of the fuzzing decision also need restating: `panic="abort"` is set on `[profile.release]` **only**, so "fails closed" is release-only and debug/test builds unwind. **The decision (no cargo-fuzz in v1) may still stand on cost grounds; the trust label that licensed it does not** (the attacker for the parsed bytes is whoever set the variable, not only the post-execve child; nested re-wrapping only tightens). `panic="abort"` + refuse-on-error makes any parse bug fail-closed. So cargo-fuzz on `Policy::parse` is not v1 scope. `cargo-deny`/`cargo-audit` stay in CI (tiny dep tree by design). --- ## 12. Build / packaging (static musl) - **`x86_64-unknown-linux-musl`, fully static, no interpreter.** Non-negotiable: (1) oring is first-exec — a dynamic oring fails on distroless/scratch/Chainguard-static; (2) single relocatable artifact drops into any image or bare host; (3) `LD_*` ignored → can't subvert oring pre-seal (security, not just packaging). -- Deps are pure-Rust/rustix-linux_raw → no C toolchain. `cargo build --release --target x86_64-unknown-linux-musl`; from macOS use `cross`. CI gates on `ldd`→"not a dynamic executable" + `file`→"statically linked". Sub-1MB expected. +- Deps are pure-Rust/rustix-linux_raw → no C toolchain. `cargo build --release --features resolve-unix --target x86_64-unknown-linux-musl` — the feature is required, not optional (`src/landlock.rs` `compile_error!`s without it, and `[features]` declares no default; `make build` supplies it); from macOS use `cross`. CI gates on `ldd`→"not a dynamic executable" + `file`→"statically linked". Sub-1MB expected. - **Distribution:** one static artifact + `.sha256`. A container consumer bundles it via multi-stage `COPY` (arch-aware so a native arm64 build can't get a bricked x86_64 entrypoint). The seccomp profile is inside the binary; standalone gets `/etc/oring/*.json` per host. - Reproducibility: committed `Cargo.lock` + `--locked`, pinned `rust-toolchain.toml`, `SOURCE_DATE_EPOCH`, `--remap-path-prefix`, `--build-id=none`; `build.rs`-captured git SHA feeds the seal line; two-host diff in CI. SLSA/cosign later. -**Seal line (v1) — a status REPORT, not an attestation:** the last action before `execve` (step 11), after `restrict_self` + seccomp apply, emit one stderr line: `oring + sealed ring1_fs=… abi=V net=on|off scope=… audit=… nnp=1`. Sourced from the actual `RestrictionStatus`, `ABI::new_current()`, and which `handle_access` calls fired. It is an **unsigned, forgeable `eprintln!`** — the sealed child inherits stderr and `write` is deliberately allowed, so the child can print an identical line. Treat it as an informational marker, **not** a trust boundary or proof of provenance; a downstream consumer must not treat the token as authoritative. Default ON (untrusted-agent model); suppress via `ORING_QUIET=1`. (The earlier `ring1=degraded`/`net=refused` sketch is stale — those states are unreachable at the V7 hard floor.) Real signed provenance (SLSA/cosign) is roadmap (§13). +**Seal line (v1) — a status REPORT, not an attestation:** the last action before `execve` (step 11), after `restrict_self` + seccomp apply, emit one stderr line: `oring: SEALED + ring1_fs=… grants=/ abi=V errata=0x net=tcp-ports|tcp+udp-ports|udp-ports|off scope=… audit=… unhandled=|- ring2=balanced|strict nnp=1 pid=`. Sourced from the actual `RestrictionStatus`, `ABI::new_current()`, the errata probe, and which `handle_access` calls fired. **`unhandled=`** names the restrictable rights this run left OPEN (`net.ConnectSendUdp`, …) or `-` for none — derived by set difference against the crate's `from_all(abi)`, never declared. `abi=` reports the interface version and `net=`/`scope=` report what was *asked for*; none of them could say what was left *unrestricted*, which is the question an auditor reading a seal line after the fact is actually asking. **`errata=`** reports which **declared** Landlock errata the running kernel carries (`?` if it did not answer): the ABI integer describes the *interface*, the errata bitmask describes which published *fixes* are present, and the two can disagree because vendor kernels are assembled by backporting. **It is a LOWER BOUND on behavioural difference, not a certificate that the interface behaves as documented** — a distinction this document overstated until 2026-07-27. The mask enumerates only errata the kernel *declares*, and 7.2-rc4 declares exactly three (1 at ABI 4, 2 at ABI 6, 3 at ABI 1) while carrying at least one behaviour change that has none: `MSG_FASTOPEN`'s implicit connect is mediated at `security/landlock/net.c:354`, and no erratum covers it. So `errata=0x7` means "carries the three declared fixes", and two kernels agreeing on `(abi, errata)` may still differ in behaviour. A floor-level erratum reported absent also raises a per-erratum WARN — it warns rather than refuses, because every such erratum predates the floor and no host has been shown that meets V10 without them (same asymmetry as the abi-ahead path). See `docs/CITATIONS.md` §3. It is an **unsigned, forgeable `eprintln!`** — the sealed child inherits stderr and `write` is deliberately allowed, so the child can print an identical line. Treat it as an informational marker, **not** a trust boundary or proof of provenance; a downstream consumer must not treat the token as authoritative. Default ON (untrusted-agent model); suppress via `ORING_QUIET=1`. (The earlier `ring1=degraded`/`net=refused` sketch is stale — those states are unreachable at the V9 hard floor.) Real signed provenance (SLSA/cosign) is not implemented. --- -## 13. LEAN phased roadmap - -### v1-MVP — the standalone binary + core rings -- [ ] Ring 1 Landlock: 4 FS tiers + dir/file split + manual IgnoreIfMissing + `handle_access(from_all)` device-node denial; **hard V7/6.15 floor** (hardcoded, no `--abi-floor`); FS masked to detected ABI; per-direction TCP with **HardRequirement** (V4 present at floor); scope on (V6 at floor); **V7 audit logging wired**; **`RESOLVE_UNIX` (V9) opt-in hardening**; status gate as tripwire + per-ring effective report. -- [ ] Ring 2 seccomp: one embedded `balanced` denylist via seccompiler; **x32 KILL guard prepend**; kill-on-arch-mismatch; **per-flag clone/unshare CLONE_NEW\* (7+7)**; **MSG_OOB/SIOCATMARK block**; **MSG_FASTOPEN implicit-connect block**; **`open_by_handle_at`/`name_to_handle_at` block**; **personality cut**; errno mixing via **N (=4) cumulative sub-filters** (file-ioctl `ENOTTY` set deferred to `strict`/v2); compiled `&[SyscallRule]`, no JSON/name-table. -- [ ] Pre-exec: NNP-first, DUMPABLE=0, signal-mask reset, **`close_range` FD-scrub** (not /proc walk), env-scrub, **absolute-string target resolve (no O_PATH fd)**, execve. Uniform HARD fail-closed (no SOFT category). -- [ ] Policy envelope (version+landlock+seccomp) via `--policy`/`ORING_POLICY`; **one profile `balanced`**; `--workdir`; full validation incl. socket-in-tier lint. -- [ ] UX: `--explain`, `--check`, `-v`/`ORING_VERBOSE`, errno→cause table; **exit codes 120/121/122** (out of shell/docker band); machine-parseable REFUSE + SEALED stderr tokens. -- [ ] Static musl x86_64 + `.sha256` + build.rs SHA; **seal line**. -- [ ] container-consumer wiring (reference): arch-pinned entrypoint; a reaper as PID 1; policy in container coordinates via `ORING_POLICY`; read_exec covering the image toolchain; a CI smoke test that the sealed entrypoint runs the workload. -- [ ] Tests: unit + rule-table snapshot + self-exec integration (fs/net/syscall/**x32**) + LOUD-skip CI. - -### v2 — when a wired consumer appears -- [ ] `strict` profile: full FS-ioctl magic wall (block-device/loop/dm class; whole-magic MaskedEq, NO in-mask carve-outs — libseccomp-only capability oring does not carry) + stricter FS/net. -- [ ] `dev` profile (interactive/trusted). -- [ ] Seccomp drift-alarm CI leg (§11e). -- [ ] LATER-scope Landlock hardening: scope procfs to own-pid subtree (`/proc/` + curated global allowlist) to close peer-procfs read within oring's own ring (brittle — tools reading other pids break; hence not v1). - -### Later — justify before building -- [ ] `seccomp.allow_extra` hole-punch (the one field that can weaken security) + additive floor guard; `deny_extra` (numeric nr only, or lift seccompiler-bin's arch table — never hand-roll). -- [ ] `paranoid` allowlist profile — only after the agent syscall set is measured. -- [ ] aarch64-unknown-linux-musl target + multi-arch OCI manifest — when a native arm64 Linux host is a real security target. -- [ ] `--abi-floor` override (purely additive, safe to defer); TOML front-end; `--dry-run` full dump; `--argv0`/`--env-clear`/`--env-keep`; auto-derive Landlock from `/proc/self/mountinfo`; write_exec-on-noexec-mount validator; over-broad-grant lint; cargo-fuzz on the config parser; SLSA provenance. +## 13. Implementation status + +What the shipped binary IS. Forward-looking work is deliberately not in this +document: a public design doc that promises future work ages badly and invites +planning against things that may never ship. + +**Current: v0.2 (unreleased) — hard Landlock floor V10 / kernel 7.2.** Released +history is in `CHANGELOG.md`; the reasoning behind each choice is the Decision +Log (§14). + +### Implemented — the standalone binary + core rings + +- **Ring 1 Landlock:** 4 FS tiers + dir/file split + manual IgnoreIfMissing + + `handle_access(from_all)` device-node denial; **hard V10/7.2 floor** (hardcoded, + no `--abi-floor`; was V7/6.15 through v0.1 — D-4b); FS masked to detected ABI; + per-direction TCP with **HardRequirement** (V4 present at floor); scope on (V6 + at floor); V7 audit logging wired; `RESOLVE_UNIX` (V9) floor-guaranteed; status + gate as tripwire + per-ring effective report. +- **Ring 2 seccomp:** embedded `balanced` and `strict` denylists via seccompiler; + **x32 KILL guard prepend**; kill-on-arch-mismatch; per-flag `clone`/`unshare` + `CLONE_NEW*` (7+7); MSG_OOB/SIOCATMARK block; MSG_FASTOPEN implicit-connect + block; `open_by_handle_at`/`name_to_handle_at` block; personality cut; errno + mixing via N (=4) cumulative sub-filters; compiled `&[SyscallRule]`, no + JSON/name-table. +- **Pre-exec:** NNP-first, `DUMPABLE=0`, signal-mask reset, `close_range` FD-scrub + (not a /proc walk), env allowlist, absolute-string target resolve (no `O_PATH` + fd), `execve`. Uniform HARD fail-closed — no SOFT category. +- **Policy:** envelope (version+landlock+seccomp) via `--policy`/`ORING_POLICY`; + profiles `balanced` and `strict`; `--workdir`; `--proc`; `--exec-tmp`; full + validation including the socket-in-tier lint. +- **UX:** `--explain`, `--check`, `--denials`, `-v`/`ORING_VERBOSE`, errno→cause + table; exit codes 120/121/122 (outside the shell/docker band); machine-parseable + REFUSE + SEALED stderr tokens. +- **Build:** static musl x86_64 + `.sha256` + build.rs SHA; seal line; embedded + auditable SBOM. + +### Verified — how each guarantee is held (§11) + +- **Proven** (Kani, `make verify`): `x32_guard_total`, `abi_from_version_clamp`. +- **Gated** (z3 SMT over the emitted cBPF): `tests/seccomp_smt.rs` — + truncation-invariance for both profiles. +- **Propertied** (proptest): the policy layer. +- **Witnessed** on a real Landlock kernel: the containment matrix and the + self-exec probe suites, `ORING_REQUIRE_KERNEL=1` so a skip is a hard failure. + Last full run, re-derived 2026-07-29: **140 tests / 24 integration binaries / 27 + suites green** with `ORING_REQUIRE_KERNEL=1`, on kernel 7.2.0-rc4 / ABI V10, plus 3 + SMT proofs under `bpf-dump` (z3 5.0.0). **This line read "81 tests / 18 binaries green + on kernel 7.1.4 / ABI V9" until then** — a claim that says *last* and was not, stale by + 59 tests and a kernel minor. Two caveats it needs: the host is **at or above** the floor, + so `require_or_skip` skips nothing here and the total is identical without + `ORING_REQUIRE_KERNEL=1` — the variable's value shows up on a sub-floor host, not this + one; and **6 of the 140 are document gates** that exercise none of oring's behaviour. +- **Catalogued:** every blocked attack maps to a runnable test in + `docs/ATTACKS.md`; residuals are enumerated in §2 rather than discovered. + +### Known gaps in the current implementation + +Stated here because §2 residuals are about what oring *cannot* close, while these +are things it has not closed *yet*: + +- Peer `/proc//{maps,mem}` have no containment-matrix cell. They share the + `ptrace_may_access` gate with `environ` (which IS walled and witnessed — by + `hook_ptrace_access_check`, which consults no handled right, so **not** by what + the V9 floor buys; the rule predates 5.13; what decides it (domain ancestry-or-equality, and privilege via CAP_PERFMON) is in the ATTACKS row-12 note), + and are expected to behave the same — but that is an expectation, + not a proof, and the claim in §2 is deliberately not widened to cover them. +- A kernel whose ABI exceeds the linked crate's ceiling leaves rights oring cannot + name unhandled; oring warns (abi-ahead) and runs. Whether that should instead be + a refusal is an open consistency question against D-4b, not a settled design. +- **Coverage is disclosed in two halves, because one predicate cannot express + it.** `abi > crate ceiling` detects rights the crate cannot NAME; it says nothing + about rights the crate names and oring does not HANDLE. Those coincided until the + crate learned V10 with no UDP field in the schema — at which point raising the + ceiling switched the abi-ahead warning off **without restricting one extra byte** + (measured 2026-07-27, one constant changed). So the named half (`unhandled=`, + plus a WARN on a partially-restricted class) is a set difference against the + crate's own `from_all(abi)`, never a hand-maintained table. Neither half is + complete alone: the derived half is bounded above by the crate ceiling, which is + exactly where the abi-ahead half starts. +- **The `socket()` wall now keys on family, type AND protocol.** `type` selects the + transport independently of `protocol` — `inet_create` dispatches on `inetsw[sock->type]`, + and with `protocol == 0` the first registrant for that type wins — so a rule reading only + arg 0 and arg 2 was **stating a guarantee it did not have**. Measured: `socket(AF_INET, + SOCK_SEQPACKET, 0)` reached SCTP's slot, and `socketpair(AF_INET, SOCK_SEQPACKET, + IPPROTO_SCTP)` ran `sock_create()` — including `request_module("net-pf-2-proto-132")` — + because `__sys_socketpair` creates before testing whether the family implements + `->socketpair`, so a sealed child could make modprobe (a usermode helper OUTSIDE the + sandbox) load `sctp.ko` on its behalf. Both are closed. The type enumeration is + **complete, not sampled**: `net/socket.c:1742` rejects any bit outside `SOCK_TYPE_MASK | + SOCK_CLOEXEC | SOCK_NONBLOCK` with EINVAL before dispatch. `AF_INET SOCK_PACKET` is a separate case and was described wrongly here until + 2026-07 [corrections: C-20]. +- **Tier overlap is refused, because the tier lattice is not closed under Landlock's + union.** Landlock has no deny rule and no unmount: rules on an ancestor and a descendant + are OR'd and the wider grant wins, so `read_exec ∪ write_noexec = write_exec` — a tier + **neither declaration names**. `--workdir /tmp` under `balanced` (which has `/tmp` in + `write_noexec`) therefore upgraded all of `/tmp` to write+exec and a script written there + executed, while `grants=` reported full success. `Policy::check_tier_overlap` computes + each path's EFFECTIVE tier as the union over every declaration covering it and requires + it to equal the declared tier, except that `read_only` may effectively be `read_exec` + (inert: nothing writable gains exec). oring cannot make the restrictive rule win, so it errors; upstream discussed the + same "permission additivity" problem in 2024 (`docs/CITATIONS.md`). Residual: paths are compared after canonicalization, so symlinks are seen but + **bind mounts and hard links are not**. +- **`$TMPDIR` is no longer read from the environment.** It used to be pushed into + `write_noexec`, letting a caller-supplied variable widen a built-in profile — and the + `$HOME` guard added to catch that tests `home.starts_with(tier)`, so narrowing by one + component walked past it (`TMPDIR=$HOME/.ssh` yielded `O_WRONLY` on `authorized_keys`). + No predicate on the path fixes it: `~/.ssh` is an existing, caller-owned, 0700 directory, + exactly the shape a good scratch dir has. The scratch path now comes from `--tmpdir` / + `--exec-tmp`, and oring SETS the child's `TMPDIR` to what it actually granted — or unsets + it when it granted none, rather than naming a directory the child cannot write. + +- **Matrix attribution.** A `Deny` cell only attributes its denial to oring if + the same operation is permitted unsealed; where DAC or a host MAC already + forbids it, the cell passes on the other layer's authority. **Computed on every + run since 2026-08-02** — every denial cell runs bare first and an + indistinguishable pair is reported `UNATTRIBUTABLE`, not `PASS`. 21 of 21 are + attributed. What is still not established is *which* layer produced an + attributed denial; see `tests/containment_matrix.rs` and `VERIFICATION.md`. + + **The 07-24 exception (`shmat-exec`) was never real** — that row has asserted + `EPERM` since the initial release while the note recorded `EACCES` bare, which + is distinguishable. The first write-up of the 07-28 re-run reported this as an + exception that had *moved over time*, and got the cell count and the + succeed-bare count wrong as well. All three are corrected in `VERIFICATION.md`, + which carries the reasoning; the per-cell table is in + `deep_research/notes/2026-07-28-t21-control-pass-vetted.md`. ### Explicitly NOT oring, ever cgroups/rlimits; MAC authoring; user-supplied seccomp JSON/BPF from untrusted input; seccomp-notify supervisor; egress firewall; credential broker; procfs masking; OCI-spec validation; any fork/supervise loop. @@ -533,34 +366,5 @@ cgroups/rlimits; MAC authoring; user-supplied seccomp JSON/BPF from untrusted in ## 14. Decision Log -| # | Decision | Chosen | Rationale | -|---|----------|--------|-----------| -| D-1 | Profile names / count | **One profile `balanced`** + `--policy` escape hatch; flag kept, only value | Only wired consumer is `--profile balanced`; strict/dev drag in the fragile full ioctl wall + a `dev` root footgun; dissolves OPEN #1/#9, collapses #5/#8 [10] | -| D-2 | Policy format | **JSON only** (env + file) | One serde front-end; TOML later if standalone authoring demands [OPEN #2] | -| D-3 | `PartiallyEnforced` gate | **Fatal, but demoted to tripwire**; enforcement guaranteed by FS ABI-masking; actionable messages | Under FS masking `FullyEnforced` is tautological, so the gate never fires on real downgrade; its real jobs are NotEnforced + no_new_privs==false [38, OPEN #3] | -| D-4 | Old-kernel floor | **Hardcode floor at V7/6.15, refuse below; remove `--abi-floor` + all sub-floor degrade** | Every deployment target runs ≥6.15, so the full FS/net/scope/audit set is guaranteed at the floor; a degrade path is a whole second untested posture — refusing below the floor is the fail-closed answer (supersedes the earlier V3/6.2 floor) | -| D-5 | Seccomp v1 content | **Trimmed `balanced`** + small file-ioctl defense-in-depth set; block-device wall + btrfs 0x94 → strict/dropped | Block-device/dm ioctls dead under cap-drop=ALL; btrfs 0x94's only unprivileged member is benign FICLONE and its carve-out is inexpressible in cumulative filters [19, 29, 35, OPEN #5] | -| D-6 | Errno mixing | **N cumulative sub-filters, one per distinct errno** (v1 = 4) | balanced needs EPERM+ENOSYS+EAFNOSUPPORT+EOPNOTSUPP — one per distinct denial semantics; the ENOTTY file-ioctl set is deferred to `strict`/v2 [30, OPEN #6] | -| D-7 | `SANDBOX_POLICY` compat | **Excluded from v1** | No consumer needs it; a second flat shape widens the sole parser surface; dissolves the deny_unknown_fields drift risk [25, 32] | -| D-8 | `--workdir` tier | **`write_exec`** for balanced; in-tree W^X residual documented | balanced's real consumer is a build agent that compiles+runs in-tree [10d, OPEN #8] | -| D-9 | x32 ABI | **Mandatory 7-instruction cBPF KILL guard prepend** | Arch guard closes only i386; x32 shares AUDIT_ARCH_X86_64 and reopens every denied syscall via nr\|0x40000000 — the guard KILLs it [1, 7, 21] | -| D-10 | MSG_OOB | **Blocked in v1** (send/recv flags + SIOCATMARK → EOPNOTSUPP) | CVE-2025-38236 AF_UNIX UAF is filterable via MaskedEq already committed; deferring it was a strict regression vs the ported reference [2] | -| D-11 | FD scrub | **`close_range(3,MAX,CLOSE_RANGE_CLOEXEC)`** | The `/proc/self/fd` walk needs a `/proc` Landlock grant the lean profiles lack → bricks every run; granting /proc reintroduces peer-procfs residual [3] | -| D-12 | clone/unshare NEW* | **7 per-flag MaskedEq rules each** | A single combined mask matches only all-bits-set; `unshare(CLONE_NEWUSER)` alone (userns LPE) would slip through [15] | -| D-13 | personality | **Cut from `balanced`; blocked in `strict`** | in balanced mprotect stays open so an RIE-block buys nothing; in strict (true W^X — mprotect/pkey_mprotect(PROT_EXEC) filtered) personality(RIE) is a real escape and is blocked [20] | -| D-14 | Landlock net ABI | **HardRequirement; no silent masking of net** (TCP V4 always present at the V7 floor) | Masking would launder a dropped port-restriction into FullyEnforced; empty-vec deny-all must not become allow-all [5, 8, 9] | -| D-15 | Landlock scope ABI | **Best-effort, warn-if- ORING_POLICY > --profile** | SANDBOX_POLICY + ORING_PROFILE have no consumer; fewer parser shapes/branches [25] | -| D-19 | deny_extra/allow_extra | **Cut from v1** | No consumer; would drag a syscall-name→number arch table onto the config-parse path [22] | -| D-20 | Fuzz + parser trust label | **Demote cargo-fuzz to later; relabel policy.rs "config edge"** | Policy is parent-supplied, pre-attacker; nested wrapping only tightens; panic=abort fails closed [24] | -| D-21 | Exit codes | **120/121/122** (out of 125-127 + 128+N bands) + REFUSE/SEALED stderr tokens | 126/127 collide with docker + the child `sh -c`; a fail-closed security event must be distinguishable [28, 36] | -| D-27 | Rule-table snapshot | **Snapshot logical `&[SyscallRule]`, not sock_filter[]** + one BPF canary | Raw-BPF churns on seccompiler bumps → blind-accept; the logical table is the reviewable artifact [34] | -| D-28 | Self-verification | **v1 seal line (status report) + `--check` predictor** | Core is already fail-closed; the gap is a positive audit trail + preflight, not fail-open detection [37, 40] | -| D-29 | SOFT pipeline steps | **Eliminated; all steps HARD** | Both former soft steps are effectively infallible; uniform fail-closed removes a self-contradictory contract [39] | -| D-30 | Peer procfs / AF_UNIX socket | **procfs = documented residual + companion controls; abstract AF_UNIX walled by scope; pathname AF_UNIX walled only with `--features resolve-unix` (V9)** | VFS-read of /proc/pid/{mem,environ,maps} unmediated (runtime PID-ns/maskedPaths are the real controls); abstract by scope (`EPERM`) at the V7 floor; pathname by `RESOLVE_UNIX` (V9) opt-in, else a loudly-reported residual + `warn_on_sockets` [4, 6] | -| D-31 | Credential exfil | **Documented required companion control (nftables + broker); do NOT auto-refuse on secret-env** | oring can't do domain/IP egress or hide a key from its holder; refusing on env-heuristics is brittle [OPEN #10] | -| D-32 | Seccomp drift | **Non-blocking CI drift-alarm vs a divergence-allowlist; reject superset gate / codegen** | blocking every unneeded syscall is not a goal (deliberate scalpel: escape primitives only); codegen breaks hermetic static build [31] | - ---- \ No newline at end of file +**Moved to [`RATIONALE.md`](RATIONALE.md) on 2026-08-01.** The numbering is +unchanged and every `D-n` citation still resolves. diff --git a/docs/INTERNALS.md b/docs/INTERNALS.md new file mode 100644 index 0000000..b2a62dd --- /dev/null +++ b/docs/INTERNALS.md @@ -0,0 +1,223 @@ + +# INTERNALS — how the two rings are built, and the order they are applied in + +**This is `DESIGN.md` §4, §5 and §6.** The section numbers are unchanged and every +`DESIGN §4` / `§5` / `§6` citation still resolves; `DESIGN.md` carries the index. + +For the reader changing oring's confinement code. **§6 is the one to read first +and the one not to change casually: the seal order is load-bearing, and a wrong +order is a confinement bypass rather than a style nit.** + +- **§4 — Landlock (ring 1).** FS tiers derived from the kernel right vocabulary, + the hard ABI floor, per-direction net, IPC scope. +- **§5 — seccomp (ring 2).** The denylist, the mandatory x32 guard, and the + argument masks — including the axes ring 1 cannot see. +- **§6 — the pre-exec sequence.** The definitive order, uniformly fail-closed. + +What each ring does *not* stop is [`RESIDUALS.md`](RESIDUALS.md); why the shape +was chosen is `DESIGN.md` §14. + +## 4. Landlock design (ring 1) + +Single ruleset, single `restrict_self`. + +### FS access tiers — DERIVED from the kernel right vocabulary, not enumerated + +| Tier | dir set | use | +|------|---------|-----| +| `read_only` | `ReadFile\|ReadDir` (+`Refer`) | config, read-only mounts | +| `read_exec` | `read_only\|Execute` (+`Refer`) | `/usr`,`/lib`,`/usr/lib`,`/usr/libexec`, interpreters | +| `write_noexec` | `read_only\|WriteFile\|MakeReg\|MakeDir\|RemoveFile\|RemoveDir\|MakeSock\|MakeFifo\|MakeSym\|Truncate\|IoctlDev` (+`Refer`) | `/tmp`, scratch — W^X | +| `write_exec` | `write_noexec\|Execute` | the workdir | + +**FS decisions:** +- **Build unions explicitly; do NOT use `AccessFs::from_write(abi)`** — that helper includes `MakeChar\|MakeBlock`. Device-node rights appear in no tier; combined with `handle_access(AccessFs::from_all(abi))` for FS they are handled-but-never-granted → globally denied (defense-in-depth with seccomp `mknod`/`mknodat`→ERRNO). +- **Dir-vs-file split** (`RightScope` in `landlock.rs`): the kernel rejects dir-only rights (`ReadDir`,`Make*`,`Remove*`,`Refer`) on a non-dir FD (`EINVAL`). Each `AccessFs` right is tagged `Both` or `DirOnly`; a file mask keeps only `Both` rights. Route each path by `metadata().is_dir()` (follows symlinks, matching `PathFd`). Missing paths → dir bucket. +- **Manual `IgnoreIfMissing`**: `PathFd::new` → `Err` on ENOENT → skip; any other error (EACCES) → **fail closed**. Because a silently-skipped path yields a downstream EACCES that looks like an oring bug, emit a startup diagnostic listing every skipped path (§8, §11). +- **Refer** in dir sets only. Safe on `read_only`: it is only the inert endpoint of a rename; unlinking the source still needs `RemoveFile`/`RemoveDir`. No write leaks. **That argument assumes Landlock erratum 3.** Without it, rename/link across *disconnected* directories (a directory moved outside the scope of a bind mount) can widen access rights past `LANDLOCK_ACCESS_FS_REFER` — the kernel documents it as potential privilege escalation. Erratum 3 is declared at **ABI 1**, so it is orthogonal to the ABI integer: a kernel can report V9 or V10 and not carry it, which is exactly why the errata bitmask exists. oring therefore probes it, reports it on the seal line (`errata=`), and WARNs per missing floor-level erratum (§12). It does **not** refuse — every such erratum predates the floor and no host has been shown that meets V10 without them. +- **List real usrmerge targets, not symlinks.** Do not rely on `/lib`→`/usr/lib`; list `/usr/lib` and `/usr/libexec` directly (node bundle / libexec live there). + +### FS ABI strategy — hard floor V10 +Query `ABI::new_current()`. **Hard floor V10 / kernel 7.2** (`FLOOR_ABI = 10`, `src/landlock.rs`), hardcoded, refuse below it — raised from V9 on 2026-07-29 so the UDP net rights are guaranteed rather than disclosed as unhandled. V10 is the coherent-cage minimum: FS core + `Refer`/`Truncate` (V1–V3), TCP net (V4), `IoctlDev` (V5), IPC scope (V6), audit logging (V7), `RESOLVE_UNIX` (V9) and the **UDP net rights** (V10) are all *guaranteed* there, so there are **no degraded/backcompat branches** — running a partial cage on an old kernel is worse than refusing (D-4). Below V10 oring exits with one clear message; there is no `--allow-net-degrade`, no scope-skip-below-V6, no audit-skip-below-V7, and no run-without-the-pathname-socket-wall. Mask the FS core to detected ABI (`from_all(abi)`) so a healthy kernel returns `FullyEnforced`. + +The V9 clause is what moved in v0.2 (D-4b). It is a *floor* rather than opt-in hardening for a structural reason: `RESOLVE_UNIX` is the only mechanism at this layer that mediates `connect()` to a pathname AF_UNIX socket. Below V9 that hook does not exist, so the class is ungated no matter how the tiers are drawn — which made the old "opt-in feature + loud residual warning" exactly the degraded-path-with-a-warning shape D-4 rejects everywhere else. Note the ABI is a property of the **running kernel**: a V9-capable crate on a 6.15 kernel still detects V7 and refuses. + +**`RESOLVE_UNIX` (V9 / kernel 7.1)** walls **pathname AF_UNIX socket resolution** (docker.sock / ssh-agent) — per the UAPI header, that is `connect(2)` **and `sendmsg(2)` with an explicit recipient address**, so the wall is wider than the `connect()`-only framing used elsewhere in this document. It is **at the floor**, not beyond it: handled and granted in **no** tier, so an out-of-domain pathname resolution returns `EACCES` on every kernel oring agrees to run on. `abi_from_version` clamps to the crate ceiling; `abi_from_version_clamp` (Kani, `make verify`) proves that clamp never over-claims. **The ceiling number is deliberately not restated here** — it lives once, as `CEILING` in `src/landlock.rs`, with `CRATE_MAX_ABI` derived from it. This sentence said "(V9)" until 2026-07-27, months after the ceiling moved to V10, which is exactly the defect restating it creates. + +Two properties of this right are easy to get wrong, and both are stated in `include/uapi/linux/landlock.h`. First, it is **domain-scoped despite being an `AccessFs` right**: it applies only to sockets created *outside* the new Landlock domain, and "newly created UNIX servers within the same Landlock domain continue to be accessible." So handling it and granting it in no tier costs no in-sandbox IPC — a confined child can still create and connect to its own sockets. Second, the kernel implements it via a **dedicated LSM hook** (`unix_find`, called from `unix_find_bsd()`), which is *why* no arrangement of FS tiers could substitute below V9: there was no hook on that path at all, not merely no right. + +*Superseded (v0.1):* this right was previously additive hardening — active only when built `--features resolve-unix` on a ≥V9 kernel, with pathname-socket connect a §2 residual otherwise, reported at seal and by the `warn_on_sockets` lint. A hard V9 floor was rejected then on the grounds that it would make oring refuse on nearly every machine and force an out-of-tree crate. Both consequences were real and are now accepted deliberately (D-4b, and `§0` of the maintainer's roadmap — a handle to an unpublished file, on the same footing as the `BUILD-QUEUE.md` T-numbers, and **not** a section of this document): the rejected reasoning weighed reach against containment, and reach is not the goal. What actually settled it is that the "fail-safe degradation" argument was wrong in one specific way — a missing *restriction* is exactly the thing the child benefits from, and no FS tier could substitute, because Landlock has no hook on that connect below V9. + +### Net — per-direction, ABI-gated, no silent downgrade +rust-landlock chooses per-direction via `handle_access` (go-landlock's connect+bind coupling trap does not apply — one ruleset). +- `egress_tcp`/`ingress_tcp` each modeled `Option>`. `None` ⇒ don't handle that direction (unrestricted). `Some(ports)` ⇒ handle + one `NetPort::new(port, access)` per port. `Some(vec![])` ⇒ handled, zero rules ⇒ **explicit deny-all**. +- **Do NOT mask net to detected ABI.** For any requested net restriction (connect or bind, including the empty-vec deny-all), set `CompatLevel::HardRequirement` before `handle_access` — never let deny-all silently become allow-all, never launder a dropped net ring into `FullyEnforced`. TCP net is V4 and always present at the V10 floor, so this never degrades; there is no opt-in escape. +- **Landlock net gates only the `connect(2)`/`bind(2)` hook**, and only by *port* — not IP, not protocol. Two consequences the operator must know: an allowed port reaches *any* IP (port-only, §2), and `sendto(…, MSG_FASTOPEN, …)` performs an implicit connect inside the send path that never reaches `security_socket_connect`. The TCP port wall therefore holds only because **ring 2** blocks `MSG_FASTOPEN` (§5); Landlock alone does not close it. Domain/IP egress is a required host companion control (§9). + +### Scope (IPC) +`scope(Scope::from_all(abi))` ⇒ `AbstractUnixSocket|Signal`, always present at the floor (scope is V6). An out-of-domain abstract-socket connect → `EPERM`, and signalling peers outside the domain is blocked. In the container path this containment also comes from netns/pidns; oring's scope ring is defense-in-depth there. + +### V7 audit logging — wire it +After `create()` and before `restrict_self()`, best-effort (gated ABI≥V7): `log_new_exec(true)` (LOG_NEW_EXEC_ON — log the confined child's denials) + `log_same_exec(false)` (suppress oring's own pre-exec noise). Single call covers FS+net+scope (one ruleset). Records surface to the HOST auditd/dmesg only — the confined uid-1000 child cannot and should not read them. Zero cost where the host has no audit path. + +### Status gate — reframed (§14 D-3) +Enforcement is guaranteed by **ABI-masking of the FS core** (`FullyEnforced` by construction on any non-broken V3+ kernel), not by the status check. The status gate is a **tripwire**, not the security-meaningful control: +- `NotEnforced` → **fatal**, actionable: "Landlock present but NotEnforced; kernel likely booted with `lsm=` disabling it or `CONFIG_SECURITY_LANDLOCK` off — refusing." +- `PartiallyEnforced` → **fatal** tripwire for a broken rust-landlock/kernel invariant (unreachable under FS-only masking); message prints detected ABI + the handled-access set oring itself built. +- `no_new_privs == false` → **fatal** (internal bug — oring set it in step 4). + +Additionally emit a **per-ring effective report** (fs=enforced, net=enforced|refused, scope=on|dropped(needs V6, host Vn)) so oring never prints green when a requested ring was dropped. + +### Rust sketch +```rust +let abi = ABI::new_current(); +if abi < ABI::V10 { refuse(120, "Landlock ABI < V10 (need kernel 7.2+); detected {abi:?}"); } + +// FS core: masked to detected ABI (>= V10 by the floor above). +let mut rs = Ruleset::default() + .set_compatibility(CompatLevel::BestEffort) + .handle_access(AccessFs::from_all(abi))?; + +// Net: HARD requirement — TCP (V4) is always present at the floor. +if policy.net_requested() { + rs = rs.set_compatibility(CompatLevel::HardRequirement); + if let Some(_) = policy.egress_tcp { rs = rs.handle_access(AccessNet::ConnectTcp)?; } + if let Some(_) = policy.ingress_tcp { rs = rs.handle_access(AccessNet::BindTcp)?; } + rs = rs.set_compatibility(CompatLevel::BestEffort); +} +if policy.scope_ipc { rs = rs.scope(Scope::from_all(abi))?; } // scope (V6) present at floor + +let created = rs.create()?; +created.set_log_new_exec(true).set_log_same_exec(false); // best-effort V7 +// ... add_rules(fs tiers, net ports) ... +let status = created.restrict_self()?; +gate(status); // tripwire + per-ring report +``` + +--- + +## 5. Seccomp design (ring 2) + +**Denylist, decisively** — `default = Allow` with targeted ERRNO blocks. oring wraps arbitrary dev toolchains (cargo/npm/pip/go/node/git/gcc/clang/python); an allowlist has to track the moving union of every runtime's syscalls — one miss = `SIGSYS` mid-build. This is why Docker's own default is a denylist. Job: delete the well-known LPE launchpads. A denylist can't stop a 0-day in an *allowed* syscall — Landlock is the containment backstop. + +**Errno, never blanket-kill** (per-syscall action, chosen by what the denial should look like to the caller): +- `ENOSYS(38)` where a userspace fallback exists: `io_uring*`, `userfaultfd`, `clone3`, `splice`/`tee`/`vmsplice`, `memfd_create`, `execveat`, `pidfd_open`, `keyctl`/`add_key`/`request_key`. +- `EPERM(1)` where there's no fallback: `mount`/`ptrace`/`bpf`/`kexec`/`reboot`/etc. +- `EAFNOSUPPORT(97)` for the `socket` family filter. +- `EOPNOTSUPP(95)` for the MSG_OOB / SIOCATMARK filters. +- `ENOTTY(25)` for the file-ioctl defense-in-depth set (deferred to `strict`/v2 — not in shipped `balanced`). + +`KillProcess` reserved for a future `paranoid` profile (turns capability probes into crashed builds). `Notify`/user-notif out of scope (reintroduces a supervisor). + +### x32 ABI guard — MANDATORY v1 prepend (fixes the central false claim) +seccompiler's auto arch-prologue compares only `seccomp_data.arch == AUDIT_ARCH_X86_64`. It kills i386/int-0x80 (arch differs) but **does NOT close x32**: x32 runs in long mode with arch == AUDIT_ARCH_X86_64, distinguished by `__X32_SYSCALL_BIT (0x40000000)` OR'd into `seccomp_data.nr`. An x32-numbered `ptrace`/`bpf`/`io_uring`/`mount`/`unshare` passes the arch guard, matches no number-keyed deny rule, and falls through to `default=Allow` — reopening every blocked launchpad on any `CONFIG_X86_X32_ABI=y` kernel (stock Ubuntu/Debian generic). seccompiler's exact-`u32`-nr rule model cannot express a masked-bit test. + +**Fix:** install a hand-assembled 7-instruction cBPF guard as a **separate `SET_MODE_FILTER` call installed FIRST** (filters stack; a KILL in any filter wins): +``` +LD seccomp_data.arch +JEQ AUDIT_ARCH_X86_64 -> next ; else RET KILL_PROCESS (kills i386 + foreign arch) +LD seccomp_data.nr +JSET 0x40000000 -> RET KILL_PROCESS (any x32-bit call) +RET ALLOW ; defer to the real denylist +``` +Whole-ABI kill is safe (a cap-drop=ALL uid-1000 dev toolchain never issues x32). This is a small audited splice, not a hand-rolled filter — the insta rule-snapshot (§11) makes it reviewable. The x32 re-entry (`nr | 0x40000000`) shares AUDIT_ARCH_X86_64, so a number-keyed deny is bypassable without it; the guard KILLs foreign/compat arches outright. + +### v1 `balanced` block set +The denylist is the known kernel escape surface, selected from first principles: each entry is an LPE launchpad, a kernel-attack-surface syscall, a named-CVE mitigation, or device-node creation — a functional set a `cap-drop=ALL`+NNP uid-1000 dev toolchain never legitimately needs. Grouped by errno (§ mechanism below); every row carries its rationale. This table is the representative core; the authoritative, exhaustive set is the compiled-in `&[SyscallRule]` in `src/seccomp.rs` (snapshot-tested, §11) — read it, not this table, when reasoning about coverage. + +| Syscall(s) | Match | Action | Rationale | +|---|---|---|---| +| `io_uring_setup/enter/register` | all | ENOSYS | async-IO LPE surface; runtimes fall back | +| `userfaultfd` | all | ENOSYS | UAF-exploit primitive | +| `memfd_create` | all | ENOSYS | fileless-exec staging | +| `clone3` | all | ENOSYS | struct args unreachable to cBPF → glibc/Go fall back to `clone` | +| `execveat` | all | ENOSYS | fexecve/memfd exec vector (`execve` stays ALLOWED) | +| `pidfd_open` | all | ENOSYS | pidfd LPE surface | +| `splice`/`tee`/`vmsplice` | all | ENOSYS | Dirty-Pipe (CVE-2022-0847) | +| `keyctl`/`add_key`/`request_key` | all | ENOSYS | keyring LPE | +| `bpf` | all | EPERM | eBPF LPE surface | +| `perf_event_open` | all | EPERM | perf LPE surface | +| `ptrace`/`process_vm_readv`/`process_vm_writev`/`kcmp`/`process_madvise`/`pidfd_getfd` | all | EPERM | cross-process (does NOT cover /proc/pid/mem — §2) | +| `mount`/`umount2`/`open_tree`/`move_mount`/`fsopen`/`fsconfig`/`fsmount`/`fspick`/`pivot_root`/`chroot`/`setns` | all | EPERM | mount/ns manipulation | +| `mknod`/`mknodat` | all | EPERM | device-node creation (belt w/ Landlock) | +| `unshare` | **8** × MaskedEq — one per `CLONE_NEW*` bit, **plus `CLONE_NEWTIME`** | EPERM | **per-flag**, see below | +| `clone` | **7** × MaskedEq, one per `CLONE_NEW*` bit | EPERM | **per-flag**; blanket block would break threads. **No `CLONE_NEWTIME` rule** — in legacy `clone(2)` the low byte is `CSIGNAL`, so a 0x80 rule would false-match an exit signal (`src/seccomp.rs`, at the `unshare_rules` push) | +| `kexec_load`/`kexec_file_load`/`init_module`/`finit_module`/`delete_module` | all | EPERM | kernel image/module | +| `reboot`/`swapon`/`swapoff`/`acct`/`quotactl`/`vhangup`/`syslog` | all | EPERM | admin ops | +| `ioperm`/`iopl`/`pciconfig_*` | all | EPERM | port IO | +| `settimeofday`/`clock_settime`/`adjtimex`/`clock_adjtime`/`sethostname`/`setdomainname` | all | EPERM | global mutation | +| `fanotify_init`/`fanotify_mark` | all | EPERM | fs-wide notify | +| `move_pages`/`migrate_pages`/`modify_ldt` | all | EPERM | niche LPE surface | +| `seccomp` | all | EPERM | self-defense | +| `socket`/`socketpair` | `Ge`(family, 17) | EAFNOSUPPORT | block PACKET/TIPC/ALG/VSOCK; allow UNIX/INET/INET6/NETLINK (`socketpair` walks the same family gate) | +| `send`/`sendto`/`recv`/`recvfrom` | MaskedEq(arg3 & MSG_OOB) | EOPNOTSUPP | **CVE-2025-38236 AF_UNIX MSG_OOB UAF** | +| `sendmsg`/`recvmsg` | MaskedEq(arg2 & MSG_OOB) | EOPNOTSUPP | same — flags is arg2 for the msg variants | +| `sendmmsg`/`recvmmsg`/`recvmmsg_time64` | MaskedEq(arg3 & MSG_OOB) | EOPNOTSUPP | same — flags is arg3 for the mmsg variants | +| `ioctl` | Eq(arg1 == SIOCATMARK 0x8905) | EOPNOTSUPP | MSG_OOB pair | +| `sendto`/`sendmmsg` | MaskedEq(flags & MSG_FASTOPEN) | EOPNOTSUPP | implicit-connect egress that never reaches Landlock's `connect()` hook — the only reason the TCP port wall holds (ATTACKS §3a) | +| `open_by_handle_at`/`name_to_handle_at` | all | EPERM | fd-from-handle reopen that sidesteps path-based Landlock | +| `ioctl` | Eq(arg1 ∈ {TIOCSTI, TIOCLINUX, TIOCSETD}) | EPERM | TTY injection | +| `ioctl` | Eq(arg1 ∈ file-ioctl set below) | ENOTTY | file-fd defense-in-depth — **deferred to `strict`/v2**, not in shipped `balanced` | +| `mmap`/`mmap2` | MaskedEq(arg3 & MAP_GROWSDOWN) | EPERM | StackRot (CVE-2023-3269) | +| `prctl` | MaskedEq(arg0 == PR_SET_DUMPABLE & arg1 > 0), (arg0 == PR_SET_PTRACER) | EPERM | re-dumpable / ptracer | + +**File-ioctl defense-in-depth set — deferred to `strict`/v2, NOT shipped in `balanced`** (exact-cmd `Eq`→ENOTTY, same mechanism as TIOCSTI; encoding a wrong ioctl number breaks benign ioctls, so it waits for the tighter profile): `FS_IOC_ENABLE_VERITY` (irreversible in-place immutability), `FS_IOC_FIEMAP` (physical-layout leak), `FS_IOC_ADD_ENCRYPTION_KEY`/`FS_IOC_REMOVE_ENCRYPTION_KEY`, `XFS_IOC_FSBULKSTAT`/`XFS_IOC_BULKSTAT`/`XFS_IOC_FSGEOMETRY` (cross-tier inode metadata on ext4/xfs), `EXT4_IOC_ALLOC_DA_BLKS`. These are metadata-leak / own-tier DoS, **not** confinement escape — they tighten the already-conceded writable-tier residual. They matter mainly on a bare-metal host with real ext4/xfs; largely moot on a container tmpfs/overlay. Tools fall back cleanly on ENOTTY (verified: `cp`/`rsync` do). + +**Deliberately NOT in v1:** +- `personality` — **cut from `balanced`.** With `mprotect` unrestricted (mandatory for JIT/GC runtimes), blocking `READ_IMPLIES_EXEC` buys no W^X margin an attacker with code-exec can't already get via `mprotect(PROT_EXEC)`, and `ADDR_NO_RANDOMIZE` self-disable is negligible post-compromise; blanket-blocking in `balanced` only risks breaking `setarch`/ASAN-re-exec/emulators. **`strict` DOES block `personality(READ_IMPLIES_EXEC)`** — there `mprotect`/`pkey_mprotect(PROT_EXEC)` are filtered for true W^X (§7), so `personality(RIE)` is a real escape route that arg-filtering must also close (`seccomp.rs`). +- **Block-device / loop / dm ioctl wall** (`BLK*`, loop 0x4c, dm 0xfd) — dead under cap-drop=ALL + Landlock device-node denial + no block-dev nodes. → `strict` later. +- **btrfs 0x94 magic block** — an order-dependent "ALLOW FICLONE first, then ERRNO the 0x94 magic" carve-out is **impossible** with cumulative seccomp filters (ERRNO outranks ALLOW; seccompiler binds one action per filter). So oring does NOT block 0x94 at all — though not for the reason first given [corrections: C-19]. Read against `fs/btrfs/ioctl.c` (7.2.0-rc4): **63 ioctls in the magic, 35 + `capable(CAP_SYS_ADMIN)` checks in the whole file** — `SNAP_CREATE` gates on + `inode_owner_or_capable()`, and `SNAP_DESTROY` with `user_subvol_rm_allowed` + deletes a non-empty subvolume `rmdir(2)` would refuse. **Why the conclusion holds:** every one of these acts on a path the child already holds an fd to, and Landlock gates the `open()`, so the reach is bounded by the granted tiers — not by the capability set. Note the bound is the *tier*, not Landlock ioctl mediation: `LANDLOCK_ACCESS_FS_IOCTL_DEV` covers **device** files only, so ioctls on regular files and directories are unmediated at every ABI. **Not measured — this box has no btrfs mounted**, so it is a source read of the running kernel's tree, and `user_subvol_rm_allowed` is not a default. Whether `strict` should wall the magic outright is a real trade (it would break `FICLONE` for `cp`/`rsync`) and is filed as a decision, not assumed here. **Invariant: oring only ever uses whole-syscall or whole-magic blocks with no benign-exception; any in-mask carve-out is a libseccomp-only capability oring does not carry.** + +### `clone`/`unshare` — per-flag, not one combined mask +cBPF/seccompiler cannot do "any bit in mask set" in one compare (no masked-not-equal op). A single `MaskedEq(mask=OR-of-all-NEW*, value=OR-of-all)` matches only when ALL bits are set — trivially bypassed by `unshare(CLONE_NEWUSER)` alone (the userns-LPE launchpad). Emit one rule per bit, each `MaskedEq(mask==value==single bit)`: NEWNS 0x20000, NEWCGROUP 0x2000000, NEWUTS 0x4000000, NEWIPC 0x8000000, NEWUSER 0x10000000, NEWPID 0x20000000, NEWNET 0x40000000 — **7 on `clone`, 8 on `unshare`**, the extra being `CLONE_NEWTIME` (0x80), which `clone` must not carry because its low byte is `CSIGNAL` and a 0x80 rule there would false-match an exit signal [corrections: C-4]. The security-load-bearing bit is `CLONE_NEWUSER` (the unprivileged-userns LPE launchpad); the other six namespace flags are cheap defence-in-depth on the same syscalls. + +### Mechanism, action, arch +- **seccompiler 0.5.x** (Firecracker, pure Rust — `libc` bindings + optional serde, no `-sys`, no C link). Only option that keeps the musl-static entrypoint AND gives `SeccompCmpOp::MaskedEq`. Reject libseccomp-rs (links `libseccomp.so`). Reject hand-rolled cBPF except the audited x32 guard splice. +- **Per-syscall errno mixing = N cumulative sub-filters, one per DISTINCT errno.** Not "two." Partition the deny set so each blocked syscall matches in exactly one sub-filter (`mismatch_action=Allow`, `match_action=Errno(e)`). v1 `balanced` = **4 sub-filters** (EPERM, ENOSYS, EAFNOSUPPORT, EOPNOTSUPP) + the x32 KILL guard installed first (the file-ioctl `ENOTTY` set is deferred to `strict`/v2, above). Filters stack; ERRNO outranks ALLOW; no ties. + + **Cost, QUANTIFIED 2026-07-30 — and the sentence this replaces pointed at the wrong term.** It read: "Cost is an N-filter stack of linear partition scans + N−1 arch prologues, not a blanket 'double' — quantify against actual partition sizes." The mechanics were right and the magnitude was not, in the direction that matters. Measured with `bench/run.sh` on 7.2.0-rc4 (glibc host build, pinned, 3 rounds): + + - **At runtime the partition costs nothing measurable.** A filter-count sweep (`bench filters k`, k=0..4, everything else held) steps once at k=1 — +4.4 ns/syscall, the cost of *entering* seccomp at all — and filters 2-4 add ~0.5 ns between them, inside the round-to-round spread. This holds even though an allowed syscall walks ~215 cBPF instructions across the stack (`bench sim`: 809 instructions total, mean 207 executed, max 265), because the kernel JITs each program and the scan is not where the time goes. So the linear scan is real, and it is not a cost. + - **At INSTALL it is the dominant term, and the old sentence did not name it.** Four `seccomp(SET_MODE_FILTER)` calls cost **227 µs** — 72% of the ~317 µs the seal itself takes and 36% of the ~631 µs oring adds to an invocation. Cost tracks program length after a one-time ~40 µs on the first install (`enosys` 100 insns / 61 µs, `eopnotsupp` 105 / 22, `eperm` 457 / 120). + - **Consequence for the design, not just the docs:** adding a fifth errno group is ~free for the child and costs the operator another ~25-120 µs per invocation depending on its size. And because filters are inherited across `fork`/`execve` (measured: fork+exec inside the cage is within 1% of outside), that cost is paid **once per oring invocation**, never per process in the child's tree — so the lever on it is amortisation, not partition shape. + + The errno partition is a property of each syscall's denial semantics (pretend-absent → ENOSYS, denied → EPERM/EACCES, unsupported family → EAFNOSUPPORT), so the filter count follows the profile automatically. +- **Arch: x86_64 only + kill-on-mismatch + the x32 guard.** aarch64 is a compile-time target only, added when a native arm64 Linux host becomes a security target (landlock 0.4.5 + seccompiler 0.5 both support it). +- **Representation:** blocklist as a compiled-in Rust `&[SyscallRule]` — no JSON-at-runtime on the security-critical path, type-checked, uses `libc::SYS_*` constants (no syscall-name table). + +--- + +## 6. Pre-exec sequence (definitive order — uniform fail-closed) + +`pipeline::seal()` runs this exact sequence; `main` then execs. **Every step is HARD (`?`)** — there is no SOFT category in v1 (both former "soft" steps are effectively infallible for a valid unprivileged caller; promoting them makes "every step fail-closed" literally true). `panic="abort"` ⇒ no partial-seal-then-exec. Note `panic="abort"` is set on the **release** profile only (§12); debug/test builds are `panic="unwind"`. The seal is structurally safe under either: `seal() -> Result`, its `execve` is the last statement, so a panic anywhere unwinds *past* (never reaching) the exec — a future dev must not add a `catch_unwind` around the seal assuming abort semantics hold in every profile. + +``` + 1. parse argv split at "--"; require a target after it (else exit 2) + 2. load policy --policy | ORING_POLICY env | --profile balanced (none → refuse, exit 120) + 3. resolve target PATH lookup → absolute STRING; fstat: reject non-regular/non-exec; + assert target under a read_exec/write_exec rule (not found → exit 122) + 4. NO_NEW_PRIVS prctl(PR_SET_NO_NEW_PRIVS,1) (HARD: before seccomp) + 5. DUMPABLE=0 prctl(PR_SET_DUMPABLE,0) (HARD; oring-window only) + 6. LANDLOCK ring 1; core hard-fail (HARD: before seccomp) + 7. signal-mask reset sigprocmask(SIG_SETMASK, empty) (HARD) + 8. FD scrub close_range(3, ~0, CLOSE_RANGE_CLOEXEC) + 9. build argv/env strip ORING_POLICY (pure memory) +10. SECCOMP x32 guard filter, then N errno sub-filters (LAST restriction) +11. attest + execve emit seal line to stderr, then execve resolved path, clean env +``` + +**Rationale:** +- **Resolve target (3) before Landlock (6).** Resolve to an **absolute string** first → clean `exit(122)` on missing binary, rather than a post-seal `EACCES` that looks like a malfunction. `fstat` once to reject non-regular/non-executable and enforce the read_exec-coverage check (§8), then close that fd. **No O_PATH fd is held** — TOCTOU closure would require `fexecve`/`execveat`, which oring deliberately blocks (memfd fileless-exec; cBPF can't safely arg-filter it), and the race has no attacker in either deployment (read-only rootfs in-container; single-threaded, no child, no local writer standalone). +- **NNP (4) before seccomp (10).** Unprivileged `SET_MODE_FILTER` returns EACCES without NNP. Set it explicitly to decouple from Landlock ordering. +- **`DUMPABLE=0` (5)** — one syscall; protects oring's own pre-exec window only (blocks same-uid `/proc/self/mem`/ptrace-attach while policy sits in memory; suppresses a policy-leaking core dump). **NOT inherited across execve** — the kernel resets the child to dumpable. Child-side ptrace/mem protection comes from the seccomp `ptrace`/`process_vm_*` denials, NOT this step (and does NOT cover peer `/proc//mem` — §2). +- **Landlock (6) before seccomp (10)** so `landlock_*` setup syscalls aren't blocked. +- **Signal-mask reset (7)** — execve inherits the mask unchanged; a parent with signals blocked silently hands the child a bad mask. Correctness fix a Go-managed sealer doesn't hit. +- **Disposition normalization (11a)** — execve resets *caught* handlers but **preserves `SIG_IGN`**, a separate inheritance channel from the mask. Rust installs `SIGPIPE=SIG_IGN` at startup, so an un-normalized child sees `EPIPE` where it should die (`producer | head -1` hangs; `$PIPESTATUS` reads 0, not 141). Every ignored signal except `SIGKILL`/`SIGSTOP` is restored to `SIG_DFL` immediately before execve. Placement is after the seal-line write for ordering hygiene only — **not**, as first documented, because oring's ignored `SIGPIPE` protects that write: `eprintln!` panics on failure and `panic=abort` converts that to an abort, so a closed stderr prevents the exec under either ordering. POSIX hygiene, not a confinement control. Known regression: this also clears a *deliberately* inherited `SIG_IGN`, so `nohup oring -- cmd` no longer hands the child an ignored `SIGHUP`. +- **FD scrub (8) = `close_range(3, RawFd::MAX, CLOSE_RANGE_CLOEXEC)`** — **not** the `/proc/self/fd` walk (that walk needs `READ_DIR` on a `/proc` rule the lean profiles don't grant → EACCES → the pipeline would brick every invocation; granting `/proc` to fix it reintroduces the peer-procfs residual §2 disclaims). `close_range` needs no path, is indifferent to Landlock ordering, preserves CLOEXEC-not-close semantics, and (critically) does NOT close the exec path since we hold no target fd. Base `close_range` is 5.9+, `CLOSE_RANGE_CLOEXEC` 5.11+ — both well below the V10/7.2 floor. **No async anywhere** (tokio would spawn the epoll/eventfd/pipe FDs this defeats). Sync-only, hard architectural constraint. +- **Env-build (9)** — the child's env is an **allowlist** (`BASE_ENV_KEEP`, §8), built fresh, not an inheritance minus `ORING_POLICY`. The old claim that "env is not a security boundary here" was wrong in one specific way: the child being sandboxed does not stop it *reading* a credential it was handed, and a credential in the env is exactly the exfil half SECURITY.md owns. `LD_PRELOAD` into the *child* is not a bypass; `LD_*` into *oring* would be catastrophic but oring is static-musl with no dynamic linker → `LD_*` ignored (a security reason for the musl constraint). +- **Attest + Seccomp + execve (10–11).** **Cross-ring contract: the filter MUST allow `execve`, `exit_group`, and `write`** (for the seal line), or oring bricks itself. `execve` allowed; only `execveat`→ENOSYS. + +--- diff --git a/docs/RATIONALE.md b/docs/RATIONALE.md new file mode 100644 index 0000000..c15397d --- /dev/null +++ b/docs/RATIONALE.md @@ -0,0 +1,49 @@ + +# RATIONALE — the decision log + +**This is `DESIGN.md` §14.** The numbering is unchanged and every `D-n` citation +still resolves; `DESIGN.md` carries the index. + +`D-1` … `D-32` are this project's decisions. **`D1`, `D3`, `D9` — no hyphen — are +not**: they belong to the maintainer's decision log, which is not in this +repository. Two namespaces separated by a hyphen is a bad design and it is named +here rather than left for a reader to trip over. + +Each row records a decision *and the argument that was rejected*. That is the +point of the file: a cut that loses its context gets re-proposed. Meet the +argument, not the absence. + +## 14. Decision Log + +| # | Decision | Chosen | Rationale | +|---|----------|--------|-----------| +| D-1 | Profile names / count | **One profile `balanced`** + `--policy` escape hatch; flag kept, only value | Only wired consumer is `--profile balanced`; strict/dev drag in the fragile full ioctl wall + a `dev` root footgun; dissolves OPEN #1/#9, collapses #5/#8 [10] | +| D-2 | Policy format | **JSON only** (env + file) | One serde front-end; TOML later if standalone authoring demands [OPEN #2] | +| D-3 | `PartiallyEnforced` gate | **Fatal, but demoted to tripwire**; enforcement guaranteed by FS ABI-masking; actionable messages | Under FS masking `FullyEnforced` is tautological, so the gate never fires on real downgrade; its real jobs are NotEnforced + no_new_privs==false [38, OPEN #3] | +| D-4 | Old-kernel floor | **Hardcode the floor, refuse below; remove `--abi-floor` + all sub-floor degrade** | A degrade path is a whole second untested posture — refusing below the floor is the fail-closed answer (supersedes the earlier V3/6.2 and V7/6.15 floors; the current number is D-4b) | +| D-4b | Floor raised to **V9/7.1** (v0.2) | **Fold `RESOLVE_UNIX` into the floor; delete the `resolve-unix` opt-in posture and its RESIDUAL warning** | Landlock has **no hook on pathname-AF_UNIX `connect()` below V9**, so that class (docker.sock, ssh-agent) is ungated beneath the floor no matter how tiers are drawn — it was never closable at the FS layer. Withholding the `/tmp` grant only obscured the socket. *(The clause that used to follow — "and even that was defeated by the peer-procfs residual handing over `SSH_AUTH_SOCK` from a peer `environ` under the default `--proc full`" — is **retracted 2026-07-26**: a landlocked reader cannot read an **out-of-domain** peer's `environ` on any kernel carrying the ptrace rule, which predates 5.13, so peer procfs was never that route. *(That retraction said "non-descendant" until 2026-07-27, which is false — siblings share a domain and do read each other. The retraction's conclusion is unaffected: the `SSH_AUTH_SOCK`-holding peer is the operator's own process, outside the seal's domain, so it is denied either way.)* `SSH_AUTH_SOCK` did reach the child, by ordinary **environment inheritance** — a real path, since narrowed by the `BASE_ENV_KEEP` allowlist (§2). **D-4b's conclusion is unaffected:** obscurity-is-not-mediation stands alone and suffices.)* An opt-in wall plus a loud warning is precisely the degraded-path-with-a-warning shape D-4 rejects. **Costs, accepted knowingly:** (1) every pre-7.1 kernel is refused, including the 7.0.13/V8 test host — the ABI is a kernel property, so no userspace package closes that gap; (2) until upstream ships V9 (rust-landlock#135), `landlock` comes from an out-of-tree crate, so `cargo install oring` does not build and the clean-registry-dependency guarantee is suspended for that one dep. Both revert to a one-line version bump on upstream release, with no behaviour change. Rejected alternatives: a `--require-unix-wall` flag (default-off reproduces the degraded posture; default-on is the floor with extra steps), and dropping the `/tmp` grant (obscurity, not mediation — see above) | +| D-5 | Seccomp v1 content | **Trimmed `balanced`** + small file-ioctl defense-in-depth set; block-device wall + btrfs 0x94 → strict/dropped | Block-device/dm ioctls dead under cap-drop=ALL; btrfs 0x94's only unprivileged member is benign FICLONE and its carve-out is inexpressible in cumulative filters [19, 29, 35, OPEN #5] | +| D-6 | Errno mixing | **N cumulative sub-filters, one per distinct errno** (v1 = 4) | balanced needs EPERM+ENOSYS+EAFNOSUPPORT+EOPNOTSUPP — one per distinct denial semantics; the ENOTTY file-ioctl set is deferred to `strict`/v2 [30, OPEN #6] | +| D-7 | `SANDBOX_POLICY` compat | **Excluded from v1** | No consumer needs it; a second flat shape widens the sole parser surface; dissolves the deny_unknown_fields drift risk [25, 32] | +| D-8 | `--workdir` tier | **`write_exec`** for balanced; in-tree W^X residual documented | balanced's real consumer is a build agent that compiles+runs in-tree [10d, OPEN #8] | +| D-9 | x32 ABI | **Mandatory 7-instruction cBPF KILL guard prepend** | Arch guard closes only i386; x32 shares AUDIT_ARCH_X86_64 and reopens every denied syscall via nr\|0x40000000 — the guard KILLs it [1, 7, 21] | +| D-10 | MSG_OOB | **Blocked in v1** (send/recv flags + SIOCATMARK → EOPNOTSUPP) | CVE-2025-38236 AF_UNIX UAF is filterable via MaskedEq already committed; deferring it was a strict regression vs the ported reference [2] | +| D-11 | FD scrub | **`close_range(3,MAX,CLOSE_RANGE_CLOEXEC)`** | The `/proc/self/fd` walk needs a `/proc` Landlock grant the lean profiles lack → bricks every run; granting /proc reintroduces peer-procfs residual [3] | +| D-12 | clone/unshare NEW* | **7 per-flag MaskedEq rules each** | A single combined mask matches only all-bits-set; `unshare(CLONE_NEWUSER)` alone (userns LPE) would slip through [15] | +| D-13 | personality | **Cut from `balanced`; blocked in `strict`** | in balanced mprotect stays open so an RIE-block buys nothing; in strict (true W^X — mprotect/pkey_mprotect(PROT_EXEC) filtered) personality(RIE) is a real escape and is blocked [20] | +| D-14 | Landlock net ABI | **HardRequirement; no silent masking of net** (TCP V4 always present at the floor) | Masking would launder a dropped port-restriction into FullyEnforced; empty-vec deny-all must not become allow-all [5, 8, 9] | +| D-15 | Landlock scope ABI | **Best-effort, warn-if- ORING_POLICY > --profile** | SANDBOX_POLICY + ORING_PROFILE have no consumer; fewer parser shapes/branches [25] | +| D-19 | deny_extra/allow_extra | **Cut from v1** | No consumer; would drag a syscall-name→number arch table onto the config-parse path [22] | +| D-20 | Fuzz + parser trust label | **Demote cargo-fuzz to later; relabel policy.rs "config edge"** | Policy is parent-supplied, pre-attacker; nested wrapping only tightens; panic=abort fails closed [24] | +| D-21 | Exit codes | **120/121/122** (out of 125-127 + 128+N bands) + REFUSE/SEALED stderr tokens | 126/127 collide with docker + the child `sh -c`; a fail-closed security event must be distinguishable [28, 36] | +| D-27 | Rule-table snapshot | **Snapshot logical `&[SyscallRule]`, not sock_filter[]** + one BPF canary | Raw-BPF churns on seccompiler bumps → blind-accept; the logical table is the reviewable artifact [34] | +| D-28 | Self-verification | **v1 seal line (status report) + `--check` predictor** | Core is already fail-closed; the gap is a positive audit trail + preflight, not fail-open detection [37, 40] | +| D-29 | SOFT pipeline steps | **Eliminated; all steps HARD** | Both former soft steps are effectively infallible; uniform fail-closed removes a self-contradictory contract [39] | +| D-30 | Peer procfs / AF_UNIX socket | **procfs: `environ` walled by `hook_ptrace_access_check`, which consults NO handled right — so not bought by the V9 rights (corrected 2026-07-26); not bought by the floor; decided by domain ancestry-or-equality AND by privilege (CAP_PERFMON bypasses it, Linux 6.15+) — see ATTACKS row 12. Metadata still residual; abstract AF_UNIX walled by scope; pathname AF_UNIX walled by `RESOLVE_UNIX` at the V9 floor** | VFS-read of /proc/pid/environ was unmediated at V7 and returns `EACCES` at 7.1.4/V9 — SELinux ruled out as denier (bare read succeeds, `deny_ptrace` off, peer-specific), so this did NOT close with the floor (corrected 2026-07-26): the denier consults no handled right and predates 5.13 — see the ATTACKS row-12 note; `maps`/`mem` share the gate but have no cell — claim not widened. `cmdline`/`stat` stay world-readable (runtime PID-ns/maskedPaths remain the control). Abstract by scope (`EPERM`); pathname by `RESOLVE_UNIX`, floor-guaranteed rather than opt-in [4, 6, D-4b] | +| D-31 | Credential exfil | **Documented required companion control (nftables + broker); do NOT auto-refuse on secret-env** | oring can't do domain/IP egress or hide a key from its holder; refusing on env-heuristics is brittle [OPEN #10] | +| D-32 | Seccomp drift | **Non-blocking CI drift-alarm vs a divergence-allowlist; reject superset gate / codegen** | blocking every unneeded syscall is not a goal (deliberate scalpel: escape primitives only); codegen breaks hermetic static build [31] | + +--- diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md new file mode 100644 index 0000000..c9002ca --- /dev/null +++ b/docs/REFERENCE.md @@ -0,0 +1,183 @@ + +# REFERENCE — policy schema and command-line interface + +**This is `DESIGN.md` §7 and §8.** The section numbers are unchanged and every +`DESIGN §7` / `§8` citation still resolves; `DESIGN.md` carries the index. + +Look-up material: the JSON policy schema with every field, and every flag with +what it does. Written to be read in fragments rather than end to end — nothing +here argues a position, and where a field has a rationale it points at +`DESIGN.md` §14 rather than restating it. + +The tier vocabulary these fields name is defined in `DESIGN.md` §4. **Landlock +unions rights along the path walk** — access is granted if at least one rule on +the path allows it, and there is no deny rule — so a subpath can never be carved +out of a grant. Read every tier below that way. + +## 7. Policy schema + profile + +The policy is `version` + eight FS/net fields (`read_only`, `read_exec`, `write_noexec`, `write_exec`, `egress_tcp`, `ingress_tcp`, `egress_udp`, `ingress_udp`) + a minimal `seccomp` selector. + +```json +{ + "version": 1, + "landlock": { + "read_only": ["/etc","/usr/share"], + "read_exec": ["/usr/bin","/lib","/lib64","/usr/lib","/usr/libexec"], + "write_noexec": ["/tmp","/var/tmp"], + "write_exec": ["/work/repo"], + "egress_tcp": [443], + "ingress_tcp": [], + "egress_udp": [53], + "ingress_udp": [0], + "scope_ipc": true + }, + "seccomp": { "profile": "balanced" } +} +``` + +```rust +#[derive(Deserialize)] #[serde(deny_unknown_fields)] +struct Policy { #[serde(default="one")] version: u32, + #[serde(default)] landlock: Landlock, + #[serde(default)] seccomp: Seccomp } + +#[derive(Deserialize, Default)] #[serde(deny_unknown_fields)] +struct Landlock { + #[serde(default)] read_only: Vec, #[serde(default)] read_exec: Vec, + #[serde(default)] write_noexec: Vec, #[serde(default)] write_exec: Vec, + #[serde(default)] egress_tcp: Option>,// None=unrestricted, Some([])=deny-all + #[serde(default)] ingress_tcp: Option>, + #[serde(default)] egress_udp: Option>, // V10+ only; REFUSED below V10 + #[serde(default)] ingress_udp: Option>,// grant port 0 to permit autobind + #[serde(default="yes")] scope_ipc: bool } + +#[derive(Deserialize, Default)] #[serde(deny_unknown_fields)] +struct Seccomp { #[serde(default)] profile: SeccompProfile } // v1: only Balanced +``` + +`deny_extra`/`allow_extra` are **cut from v1** — no v1 consumer, and validating names would drag a syscall-name→number arch table onto the (sole) config-parse path. Not in the current implementation; tracked outside this document. + +**Validation (parse-time, before any restriction):** `version==1`; `deny_unknown_fields` (a typo'd `read_execc` errors, never silently grants nothing); absolute paths only; port ≤65535 (serde-free); **target binary must be under some `read_exec` rule** (else specific error, not a cryptic `execve: EACCES`); **error on a missing `--workdir`** (typo-confines-to-nothing footgun); warn on missing system dirs; loud warn on `egress_tcp` containing 53 **while `egress_udp` is unset** (DNS/UDP not covered; the warn is conditional now that the field exists, since an unconditional one trains operators to ignore it), a loud warn when a non-empty `egress_udp` sits beside an `ingress_udp` lacking port 0 (kernel autobind of the source port is then denied and the egress grant is inert — invisible in `unhandled=`, because both rights ARE handled), and on any granted tier containing/ancestoring a live socket inode (`docker.sock`/`podman.sock`/containerd/`/run` — §2 escape). Every message prefixed `oring:`, stderr, non-zero exit. + +**Self-protection:** `Policy::check_self_protection` (run after `validate`, once every tier is final) hard-refuses when **oring's own binary** or the **policy file** resolves under any write tier — the `--policy ./oring.json --workdir .` shape, where the child could rewrite the policy governing its own next run. An unresolvable `current_exe()` WARNs rather than refuses (an unreadable `/proc/self/exe` is an environment defect, not a policy input). + +**Profile self-protection:** `Policy::check_profile_grants` refuses when a BUILT-IN profile would grant write over a tier containing `$HOME`. It applies only to profiles — an operator who writes `$HOME` into a `--policy` file means it. Note the guard is `home.starts_with(tier)`, i.e. it catches a tier that CONTAINS `$HOME` and not one *underneath* it; the escalation that motivated it (`TMPDIR=$HOME/.ssh`) is now closed at the source instead, by not reading `$TMPDIR` at all. + +**Tier overlap:** `Policy::check_tier_overlap` (run after every injection — `--workdir`, `--exec-tmp`, `--tmpdir`, `--proc`) refuses a policy whose declared tiers do not describe the cage. Landlock ORs an ancestor's rule with a descendant's and the wider grant wins, so the four-tier lattice is **not closed under that union**: `read_exec ∪ write_noexec = write_exec`, a tier neither line names. For each declared `(path, tier)` the EFFECTIVE tier is the union over every declaration covering it; gaining **write** refuses, gaining **exec while writable** refuses, gaining exec while not writable warns (inert — nothing writable gained exec). One sentence: *the effective tier must equal the declared tier, except that `read_only` may effectively be `read_exec`.* Residual: paths are compared after canonicalization, so symlinks are seen but bind mounts and hard links are not. + +**Scratch directories:** `$TMPDIR` is **not** read from the environment. It used to be pushed into `write_noexec`, which let a caller-supplied variable widen a built-in profile's tiers — and in this threat model the caller is the adversary. The path now comes from `--tmpdir` (write_noexec) or `--exec-tmp` (write_exec), both validated by `lstat` (no symlink, must be a directory, no group/other write), and oring SETS the child's `TMPDIR` to whatever it granted — or unsets it when it granted none, rather than naming a directory the child cannot write. + +**Deny-by-default — three states:** no policy resolves → refuse (exit 120); policy grants nothing → refuse with the target-not-covered message; policy with grants → apply. Landlock < V10 floor → refuse (§4); seccomp load failure → refuse. + +### Profile: `balanced` (the only v1 profile) +Compiled-in constant; auto-includes standard read-exec system dirs so the common case is "name your workdir." Agent edits a repo + HTTPS: +- read_exec: `/usr/bin /usr/local/bin /bin /lib /lib64 /usr/lib /usr/libexec /opt` +- read_only: `/etc /usr/share` + `/proc` (read-only, so `git`/`node`/`python` can read `/proc/self/*`; peer *secret* reads stay walled by Landlock's ptrace hook, §2) +- write_exec: workdir (build agent compiles+runs in-tree — the in-tree W^X residual is documented in §2) +- write_noexec: `/tmp /var/tmp` + the standard character devices. `/var/log` and `/var/cache` are deliberately NOT granted: no build writes there, and they are shared system sinks (the log destination; packages a later root process installs). DAC refuses the write on a normal host anyway, which is what made the grant look free — it was dead weight that became a widening wherever a subdirectory is group- or world-writable. +- HOME toolchain caches: `~/.rustup` read_exec; `~/.gitconfig` read_only; **no write grant on any shared package cache since 2026-07-28** (`~/.cargo/registry`, `~/.cargo/git`, `~/go/pkg`, `~/.npm`, `~/.cache` — see §2 and the `no_profile_grants_write_on_a_shared_package_cache` test); and the split below +- `egress_tcp:[443]`; `ingress_tcp:[]` (**deny-all TCP bind** — a fixed-port dev server or test listener, e.g. `python -m http.server 8000`, gets `EACCES`; widen with a `--policy` naming the port); `scope_ipc:true`; seccomp `balanced`. + +**`~/.cargo` and `~/go` are split, and the split is load-bearing.** `bin/` under each is on the +operator's `$PATH`, so granting write there is an escape that needs no kernel bug: the child drops a +binary, the operator runs it later *unconfined* at their own uid. The tiers are therefore + +| path | tier | why | +|---|---|---| +| `~/.cargo/config.toml`, `~/.cargo/config`, `~/.cargo/env` | read_only | the config cargo actually reads, granted **by name** — see the credential note below | +| `~/go` | read_only | **parent of `bin/`** so it must not carry write | +| `~/.cargo/bin`, `~/go/bin` | read_exec | run the shims; never writable | +| `~/.cargo/registry`, `~/.cargo/git`, `~/.npm` | **read_only** | build inputs. **Write removed 2026-07-28** (D1) — these are shared across projects and cargo re-verifies nothing on disk, so a write here reaches every future build on the machine. `~/go/pkg` inherits read from the `~/go` rule. `~/.cargo/.package-cache` is granted nothing: measured, cargo tolerates an unwritable lock for a warm build. | + +The shape is forced by Landlock's combination rule: *"One policy layer grants access to a file path +if at least one of its rules **encountered on the path** grants the access"* +(`Documentation/userspace-api/landlock.rst`). Rules **union along the path walk** — a narrower rule +does not override a wider one. A `write` grant on `~/.cargo` would therefore confer write on +`~/.cargo/bin` regardless of any rule placed there, so the write grants go on sibling subtrees and +never on a parent of `bin/`. Nothing executes from these caches (cargo builds scripts and proc-macros +into `target/`; `go build` links into `$TMPDIR`), so no-exec costs nothing. + +**The same union rule applies to READS, and `~/.cargo` used to get this wrong.** A `read_only` grant +on the `~/.cargo` directory unions down onto `~/.cargo/credentials.toml` — the crates.io token, in +plaintext — and onto any `[registries.*] token` in `config.toml`. That is a live credential readable +by the child under the reference profile with one unprivileged read, no kernel bug and no cooperating +peer. Note precisely what it broke: §8's "withheld by construction" is scoped to the ENV axis and +was true there — the defect was that the same principle had never been carried to the FILESYSTEM +axis, where a directory grant hands over the token the env allowlist was busy withholding. The fix is the read-side +mirror of the write-side split: grant `config.toml`, `config` and `env` **as named files**, never the +directory. A build or test never needs the token; only `cargo publish` and authenticated registry +operations do, and those are out of scope for a cage. `profile::tests::cargo_credentials_are_never_readable` +pins it. + +*Compat, stated plainly:* `cargo install` and `go install` do not work inside the cage — they write +`bin/`. `~/.cargo/.package-cache` is cargo's top-level lock **file**, granted as a file rule so it +does not widen `~/.cargo`; on a `$HOME` that has never run cargo it does not exist yet, is skipped +like any missing path, and cargo cannot create it — `~/.cargo` itself carries no rule at all, so +there is no `MakeReg` on it. Run cargo once unconfined first, or grant it via `--policy`. + +`strict` **ships alongside `balanced`** (`by_name` resolves both; `src/profile.rs`): untrusted-binary posture — nothing granted, workdir `write_noexec` for true W^X, deny-all egress, and a seccomp-blocked `pkey_mprotect(PROT_EXEC)` so the W^X wall can't be reopened. `--profile` accepts `balanced` (default) or `strict`. A `dev` (interactive, wide) profile is still deferred until a wired consumer exists; anything between or beyond the two profiles is expressed via `--policy`. + +**`--workdir `** injects one path into `balanced`'s `write_exec`. oring asserts the resolved target binary is already under a `read_exec`/`write_exec` tier and **refuses (exit 122)** if it is not — it does NOT auto-grant the target. No mount-scraping, no `$PATH`/`$HOME` globbing (predictable > clever). + +--- + +## 8. CLI / UX + +``` +oring [OPTIONS] -- [args...] + + --policy policy JSON file (wins over env) + --profile baked profile: balanced (default) | strict + --workdir inject DIR into the profile's writable workdir tier + (write_exec for balanced; write_noexec for strict) + --exec-tmp create DIR (0700), grant write_exec, point child's TMPDIR at it, + for tools that write-then-exec in scratch. /tmp stays no-exec. + --proc /proc grant: full (default) | self | none (self closes peer-procfs) + --env-clear pass NO env to the child except --env-keep vars + --env-keep allowlist one more env var (repeatable). Child gets POSIX basics by + default; credentials (SSH_AUTH_SOCK/*_TOKEN/*_API_KEY/AWS_*) must be named + --env-inherit pass the FULL parent env (legacy default; unsafe — child reads every credential) + --require-all-grants refuse to seal if any intended path could not be granted (CI) + --require-full-coverage refuse to seal if any restrictable RIGHT was left unhandled + (i.e. `unhandled=` would not be `-`). Opt-in. Both shipped + profiles PASS it as of 2026-07-29 (they set the UDP rights); + a hand-authored `--policy` is what can leave one unhandled + --denials report what Landlock DENIED to a sealed run, from the kernel's V7 audit + --since journal window for --denials (default: -1 hour) + --explain print the effective plan and exit (no execve) + --check validate policy+target+ABI floor (no execve) + -v, --verbose emit the effective plan to stderr before execve + -- everything after = target argv (required) + +Policy also via ORING_POLICY (JSON env). ORING_QUIET=1 suppresses the seal line. + +DELIVERY PRECEDENCE (highest wins; refuse if none): --policy > ORING_POLICY > --profile. + (SANDBOX_POLICY and ORING_PROFILE are NOT accepted — no consumer; keeps one parser shape.) + --profile supplies baked defaults; --policy/ORING_POLICY whole-replace it (no merge). + +EXIT: 120 confinement refused (no policy / Landlock < V10 floor / seccomp load failed) + 121 policy parse/validation error + 122 target not found + 159 seccomp KILL (x32/arch guard) = 128+SIGSYS + * child's execve-preserved status +``` + +Exit codes are deliberately **out of the 125/126/127 shell+docker band and the 128+N signal band** so a fail-closed security event is never confused with a benign child `sh -c` exec failure. Every refusal emits one machine-parseable stderr line `oring: REFUSE reason= (code=120)`; a successful seal emits (§6 step 11, §11) a positive seal line (§12). + +**Diagnosis path (self-applied cages give bare errnos with no attribution):** +- `--explain` — pre-exec string dump: resolved FS tiers + path lists, connect/bind port sets, IPC scope state, detected ABI vs floor and which rings actually engaged, seccomp profile + one-line block summary, target read_exec coverage. No deps, no execve. +- `-v`/`ORING_VERBOSE=1` — emit that same one-block summary to stderr right before execve, so a failed build's log carries policy context inline. +- Documented errno→cause table in `--help`/README: EACCES on a path = Landlock FS; EACCES on `connect()` = port not in `egress_tcp` / net ABI absent; ENOSYS on io_uring/memfd/clone3 = oring seccomp; EPERM on ptrace/mount/bpf = oring seccomp. +- **Ceiling stated plainly:** in-band per-denial attribution is impossible without a supervisor (seccomp-notify is out of scope; ptrace/strace are blocked by oring's own filter). `--explain` + verbose-startup + the errno table are the intended in-band workflow; `--denials ` reads the host-side V7 audit records after the fact (operator-side, not readable by the confined child). + +### Scratch, procfs, and env flags — security notes +- **`--exec-tmp `** grants one operator-named `write_exec` scratch dir and wires the child's `TMPDIR` to it, for tools that write-then-`execve` in temp (Miri's sysroot build, `cc`/`ld`, `-sys`/bindgen build scripts, `go test`/`go run`) which `/tmp` (`write_noexec`) denies. It is created at **0700** — a world-writable scratch would let another local uid plant a binary the confined child then runs. A **pre-existing** path is re-validated, not trusted: oring `lstat`s it and refuses a symlink (else `canonicalize` would grant the link's *target* `write_exec` — a widening) or a group/other-writable directory. The grant is folded in **before** `check_self_protection`, so pointing `--exec-tmp` at the oring binary or policy still refuses. `/tmp` itself stays no-exec — this opens exactly one dir. Residual: a TOCTOU swap by an attacker who controls a writable *parent* — name the scratch under sticky `/tmp` or an operator-owned dir. Pinned by `tests/exec_tmp.rs`. +- **`--proc `** = `full` (default) | `self` | `none`. `self` grants **the sealing pid's** procfs plus a curated global allowlist, closing even the peer-*metadata* leak (§2); `none` grants no `/proc`. + + **"own pid" means the SEALING pid, and that is a sharper limit than it sounds.** `PROC_SELF_READONLY` lists the literal path `/proc/self`, and tier setup opens it with `PathFd::new`, which follows the symlink — so the rule lands on `/proc/`. `execve` preserves that pid and `fork(2)` does not, so a forked child's `/proc/self` resolves to a directory that was never granted. **Measured 2026-07-27** (bare: parent+child OK; `--proc full`: parent+child OK; `--proc self` and `strict`: parent OK, forked child `EACCES`). The blast radius is wider than "forked children": in `sh -c 'head /proc/self/status'`, `head` is itself a fork+exec, so **every external command** the target spawns loses access to its own procfs — only the single exec'd target keeps it. + + This is a Landlock **expressiveness limit**, not a defect: rules are path-based and `/proc/` is per-pid, so "the caller's own procfs" cannot be expressed. It is documented here because the cost is asymmetric with the benefit — peer `environ`, the credential-bearing read, is **already walled at the V9 floor in every `--proc` mode** (§2), so `self` buys peer *metadata* privacy (`cmdline`/`stat`) and pays for it with fork compatibility across the whole child process tree. An operator hitting `EACCES` on `/proc/self/maps` from a subprocess has no other way to connect it to this flag. **CORRECTED 2026-07-27: this paragraph said peer `environ` is already walled at the V9 floor in EVERY `--proc` mode, so `self` bought only metadata privacy. That is false in the case that matters.** Landlock's `domain_scope_le()` is ancestor-**or-equal**, so two children of one seal share a domain and read each other's `environ` freely. Measured, one seal, true siblings: `--proc full` → sibling `environ` **readable**; `--proc self` → **denied**. The V9 floor walls only **non-descendant, out-of-domain** peers. So `self` buys credential privacy *between the child's own subprocesses* — the normal shape for an agent spawning tools — and pays for it with fork compatibility across the tree. **Corrected again 2026-07-28: the two halves of that pair are not one experiment.** The `--proc self` denial is the **same path-layer effect this paragraph opens with** — `/proc/` was never granted — and not the domain relation, which still permits the read. `cmdline`/`stat`/`comm`, which no ptrace hook gates, are denied under `--proc self` too, which only a path denial explains. The operator advice stands unchanged and the *cost* stated above is now visibly the same cost: `self` buys sibling-`environ` privacy **by** making sibling procfs unreachable, which is precisely why it breaks forked children's `/proc/self`. One mechanism, one price — not two findings. +- **Env allowlist** — the child's env is built fresh from `BASE_ENV_KEEP` (POSIX/locale basics) plus any `--env-keep NAME`; credentials are withheld by construction, not by enumerating what to drop. `--env-clear` passes only what is named; `--env-inherit` restores the legacy pass-everything default and is documented as unsafe (a compromised child reads every credential in the shell). The rule is the §5 rubric applied to a second namespace: *enumerate what gets in, never what to drop.* Pinned by `tests/deny_env.rs`. **This claim is scoped to the environment.** The filesystem axis is a separate obligation with its own history — see the credential note in §7; withholding `ANTHROPIC_API_KEY` from the env buys nothing if a tier grant makes `~/.cargo/credentials.toml` readable. + +--- diff --git a/docs/RESIDUALS.md b/docs/RESIDUALS.md new file mode 100644 index 0000000..bbc621e --- /dev/null +++ b/docs/RESIDUALS.md @@ -0,0 +1,700 @@ + +# RESIDUALS — threat model, what oring stops, and what it does not + +**This is `DESIGN.md` §2.** The number is unchanged and every existing +`DESIGN §2` citation still resolves; `docs/DESIGN.md` carries the index. + +Read this before relying on oring for anything. It states the adversary, the +guarantees that are kernel-enforced and irrevocable, and — at greater length, +because this is the part that decides whether oring fits your problem — every +capability a compromised child retains anyway. + +**Gated in-tree.** `tests/design_residuals.rs` checks every residual below +against `CONSTRAINTS.md`, in both directions: a residual naming a loss that does +not exist fails, and a loss no residual claims fails too. It runs on every +`cargo test` and in `make check`. **`make check` on a >=7.2 host is the only +place containment is witnessed** — no hosted runner is at oring's floor, so CI +checks format, lints, dependency policy, the static build and the SMT gate, and +nothing else. + +**Correction history is not in this file.** Where a residual's wording was +published wrong and later refuted by measurement, the entry carries +`[corrections: C-n]` and the account lives in +[`CORRECTIONS.md`](CORRECTIONS.md). Several of these were dangerous wordings +rather than merely wrong ones, so they are kept — but a reader looking up what +oring does today should not have to read a debate that has ended. + +## How to read a residual + +Each carries a tag: the **loss** it belongs to, and whether anything reports it +at run time. + +| disclosure | meaning | +|---|---| +| `stated` | documented, and an instrument reports it during a run | +| `documented-only` | written down; nothing in a run reports it | +| `silent` | neither | + +**A residual whose disclosure is `silent` is one an operator cannot discover from +a run**, which is a different and worse thing than one they can look up. The +losses (`BREACH`, `LAUNDER`, `DIVERGE`) and hazards are defined in +[`CONSTRAINTS.md`](../CONSTRAINTS.md), which also carries the current enforced +fraction. + +## Attacker + +A fully-compromised agent — prompt-injection to arbitrary native code — running +as uid 1000 *after* `restrict_self` + `execve`, with all rings already in effect +and irrevocable. **The attacker is the child.** + +**The caller's role is narrower than "trusted"** [corrections: C-3]. The caller +is trusted to **author** a policy explicitly; the caller's **ambient state is not +trusted to widen or override what was authored**. Explicit sources (`--policy`, +`--profile`, `--tmpdir`, `--exec-tmp`) are authored. Ambient sources +(`ORING_POLICY`, `$TMPDIR`, `$HOME`) are not, and where an ambient source would +override or widen an authored one, **oring refuses rather than picking a +winner**: `--profile` beside `ORING_POLICY` is a hard refusal (`src/main.rs`); +`$TMPDIR` is no longer read at all, after it was measured granting write on +`~/.ssh/authorized_keys`; `check_profile_grants` refuses a profile whose tier +would contain `$HOME`. This is `CONSTRAINTS.md` **CON-6**, which stated the rule +before this document did. It also predicted a measured defect rather than merely +describing one: `$HOME` is ambient, `writable_shared_caches` read it and nothing +else, and a decoyed `HOME` made the seal line report +`shared_cache_write=none` over a policy granting write on the real +`~/.cargo/registry`. + +**Fixed 2026-08-02.** That function now takes the union of the **passwd** +home — which is not ambient — and `$HOME`, so a decoy can only ever *add* a +place to look and can no longer remove the true one. `$HOME` is kept as an +additional root deliberately: a relocated home, a container with no passwd +entry, and a harness pointing `HOME` at a fixture are all real. Pinned by +`tests/decoy_home.rs`, which decoys `HOME`, unsets it, and carries both a +negative control (a policy granting no cache still reports `none`) and a +positive one (an honest `HOME` reports the grant), so "still reported" cannot +pass over a decoy that never took effect. Reverting to `$HOME` alone turns it +red. **The confinement was never affected** — this was `MISREPORT`, the +operator's view of the cage rather than the cage. + +## What oring stops — kernel-enforced, irrevocable + +- **FS access outside policy tiers** — host creds (`~/.ssh`, `~/.aws`), *other* + projects' `.git/config`, host config. **Strongest guarantee (Landlock V1–V3, + hard floor).** Read it narrowly: it is `.git/config` of repos **outside** the + granted tiers. The **workdir's own** `.git/` is inside `write_exec` and is + fully writable by the child — see *Poisoning the workdir* below. + **Measured** 2026-07-27 against the shipped `balanced` profile, with controls + in the same run: `~/.ssh/{id_ed25519,authorized_keys,known_hosts}`, `~/.bashrc` + and two out-of-tree `.git/config` files all read bare and return `EACCES` + sealed, while `~/.rustup/settings.toml` and `/etc/hostname` — deliberately + granted — stay readable. + **The mechanism and the named paths are now tethered separately.** + `read_outside_tiers_is_eacces` builds its own policy, so it proves only that + Landlock denies an out-of-tier read — it says nothing about the shipped + profiles. What closes the other half is + `profile::tests::no_profile_grants_any_access_to_a_credential_store`: an + ∀-over-profiles invariant asserting that no tier of either baked profile + reaches `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.netrc`, `~/.kube`, + `~/.docker/config.json`, `~/.config/gh`, or the shell histories. It checks all four tiers and tests ancestry both ways, so a grant on `~/.ssh` + and one on `~/.ssh/known_hosts` are each caught. +- **W^X** — no exec from writable scratch (`write_noexec` tiers). +- **Privilege gain via SUID/setcap/file-caps** (`NO_NEW_PRIVS`) — neutralises the + CVE-2023-0386 OverlayFS-SUID payoff for the confined child; its mount/userns + half is separately closed by the seccomp `mount`/`unshare(CLONE_NEWUSER)` + denials (`DESIGN.md` §5). **Measured** 2026-07-27: `NoNewPrivs` `0`→`1` and + `Seccomp` `0`→`2` in the child's `/proc/self/status`. **`NO_NEW_PRIVS` appears + zero times in `tests/`** — fixture is T15. +- **Namespace creation** — per-flag arg-masks on `clone`/`unshare` `CLONE_NEW*`, + `clone3`→`ENOSYS`, `mount`/`setns`/`pivot_root` blocked. **7 rules on `clone`, + 8 on `unshare`** [corrections: C-4]; the extra one is `CLONE_NEWTIME` (0x80), + which `clone` must not carry because its low byte is `CSIGNAL` and a 0x80 rule + there would false-match an exit signal. + **Coverage, measured** 2026-07-27: only `CLONE_NEWUSER` has a cell, and that is + not arbitrary — it is the **only** flag whose denial is attributable to oring + without extra machinery, since unprivileged the other seven already return + `EPERM` bare. Inside a plain user namespace all eight succeed bare and all + eight then seal `EPERM`, so the userns is what makes them testable; + `tests/common/mod.rs::run_probe_jailed` already builds one. `setns` is + attributable directly (`EBADF` bare → `EPERM` sealed) and has **no cell**. + `mount` and `pivot_root` are `EPERM` at every level and are **not** attributable + by errno at all — a permanent limit, not a missing fixture. + **`setns` now has a cell** (`deny_syscall.rs`): `EBADF` bare, `EPERM` sealed. + **And the flags are covered where runtime testing cannot reach them** — + `tests/seccomp_smt.rs::no_clone_new_flag_combination_is_allowed` proves over + *all* argument values that no `CLONE_NEW*` combination is allowed by the + emitted filter, per flag and in combination, with a control asserting + `CLONE_NEWTIME` on `clone` *is* allowed so the query cannot pass vacuously. +- **LPE launchpads** — io_uring, eBPF, ptrace, userfaultfd, keyctl, + perf_event_open, memfd_create, module ops, `open_by_handle_at`, Dirty-Pipe + (`splice`/`vmsplice`), StackRot (`MAP_GROWSDOWN`), TTY-injection ioctls, and + **x32-ABI re-entry of all of the above** (`DESIGN.md` §5). + **All thirteen rules are emitted; twelve are measured denying.** Measured + bare-vs-sealed, one variable, 2026-07-27: `MAP_GROWSDOWN` OK→`EPERM`, + `memfd_create` OK→`ENOSYS`, `splice`/`vmsplice` OK→`ENOSYS`, `TIOCSTI` + `ENOTTY`→`EPERM`, `userfaultfd` `EPERM`→`ENOSYS`, `perf_event_open` + `EACCES`→`EPERM`, `bpf` `EINVAL`→`EPERM`, `open_by_handle_at` `EFAULT`→`EPERM`. + The two **arg-masked** rules — the class where an off-by-one denies nothing and + reads green — have controls proving the masks are narrow: plain `mmap` and + `TIOCGWINSZ` on the same fd are unaffected by the seal. + **`module ops` is the one name measurement cannot attribute:** `init_module` is + `EPERM` bare (no `CAP_SYS_MODULE`) and `EPERM` sealed, so the two causes are + indistinguishable — the rule is real, the *evidence* for it is not. Fixture is + `BUILD-QUEUE.md` T13. +- **AF_UNIX MSG_OOB UAF (CVE-2025-38236)** — arg-masked on send/recv `MSG_OOB` + + `SIOCATMARK`. +- **Inherited leaked FDs at fd 3 and above** — FD-scrub via + `close_range(3.., CLOSE_RANGE_CLOEXEC)`, `src/preexec.rs`. **fds 0–2 are + deliberately preserved and are NOT scrubbed** [corrections: C-5] — a child + without stdio is broken — so an fd the caller redirects onto stdin/stdout/stderr + crosses the seal intact. **Measured** 2026-07-27, one variable: an inherited + fd 0 is readable inside the cage, an inherited fd 7 is gone. + **Both ends are now pinned** by `tests/fd_scrub.rs`, which plants a readable + descriptor with `dup2` (clearing `FD_CLOEXEC`, so it survives `execve` the way + a caller's leak does) and asserts it readable bare, `EBADF` sealed, and fd 0 + readable in both. The bare arm is the load-bearing one: without it, "fd 7 is + gone" is what a harness that plants nothing reports. Red-tested both ways — + removing `preexec::scrub_fds()` and disabling the plant each turn it red. + +### Also stopped — guaranteed at the V10 floor + +- **Connect/bind on non-allowed TCP ports** — Landlock net (ABI V4 / kernel 6.7). + Port-only, and dependent on the ring-2 `MSG_FASTOPEN` block for the wall to + hold (`DESIGN.md` §4/§5). +- **Signalling and abstract-socket peers outside the domain** — Landlock scope + (ABI V6 / kernel 6.12) → `EPERM`. In the container path the runtime's + netns/pidns also provides this; oring's scope ring is defence-in-depth there. + +--- + +## Residual — full control inside granted FS tiers + +**Claim.** The workdir is `write_exec` under `balanced`, so an in-tree +write-then-run payload is possible. + +**Mechanism.** This is the grant, not a gap: the policy named it. + +**[loss: NONE — the policy named it · disclosure: stated (`--explain` prints the tiers)]** + +## Residual — poisoning the workdir so the operator executes it later, unconfined + +**Claim.** The child can write `.git/hooks/*` and `.git/config` +(`core.fsmonitor`, `core.pager`, `core.sshCommand`, `alias.*`, `diff.*.textconv` +all run commands), and equally `Makefile`, `build.rs`, `package.json` scripts, +`conftest.py`. Each fires on the next `git`, build or test the operator runs +**outside** the cage, at their uid. + +**Mechanism.** Not closeable at this layer. Landlock has no deny rule — access is +the union of every rule on the path — so a subpath cannot be carved out of a +granted tier. Withholding `.git/` would mean never granting the repo root, which +blocks creating any new top-level file. This is **not** the same class as the +`~/.cargo/bin` `$PATH` over-grant that was fixed: that was write access the agent +never needed, whereas this is the tree the agent exists to edit. + +**Control.** Inspect the tree with tools that do not interpret it as a +repository: +`command grep -nE 'fsmonitor|pager|sshCommand|textconv|hooksPath|askpass|^\s*\[alias\]' .git/config` +and `find .git/hooks -type f ! -name '*.sample'`. Or re-clone into a throwaway +checkout. **Do not review with `git`** and do not run the first build inside the +cage — both were published as controls here and both were measured making things +worse [corrections: C-11, C-10]. + +**This control is demonstrated**, which for a control on this page is the +exception rather than the rule (CON-7). +`tests/control_efficacy.rs::the_documented_workdir_review_detects_the_poisoning_without_firing_it` +plants the `core.fsmonitor` poisoning and runs both arms in one test: the +retracted `git` review **fires the payload twice and never names it**, and the +recipe above **detects it and leaves the payload unfired**. The retracted arm is +the vacuity control — without it, a passing result would be indistinguishable +from an inert payload. Red-tested three ways: blinding the recipe, making the +payload inert, and drifting the recipe in this document away from the fixture +each turn it red. + +**Not established.** No instrument reports this at run time. The fixture covers +the seven `.git/config` keys and non-sample `.git/hooks` files — **not** +`Makefile`, `build.rs`, `package.json` scripts or `conftest.py`, which are named +above and are ordinary tracked files, so the answer there is review-the-diff and +no fixture demonstrates it. + +**[loss: LAUNDER + hazard HZ-INSTRUCTED · disclosure: documented-only — no instrument reports it, and a named control was measured FIRING the payload (T30)]** + +## Residual — poisoning a shared package cache + +**Claim.** ~~Every future project on the machine executes it.~~ **Closed +2026-07-28 for the built-in profiles** by removing the write grants +(`DECISIONS.md` D1); the caches remain readable. Kept because the *class* is +closed only for those profiles — a `--policy` that grants a cache reopens it — +and because the upstream defect is unchanged. + +**Mechanism.** **cargo verifies a tarball once at download and never verifies +anything on disk again.** `.cargo-ok` is a version tag with no hash, the +`Source::verify` hook exists and `RegistrySource` does not implement it, and a +cache-hit on a `.crate` checks only that the file is non-empty. + +**Measured** 2026-07-28, end to end: a payload appended to a `build.rs` under +`~/.cargo/registry/src`, with `.cargo-ok` untouched, executes at the operator's +uid on an ordinary `cargo build` — or on `cargo check` alone, which is what an +editor runs — **in a completely unrelated project**. Cross-project, persistent +across sessions, and it leaves nothing in either repo's git history. The trigger +needs a cold `target/` for that crate, which is the normal case for a *different* +project. W^X buys nothing: the chain plants **source** that a later toolchain +compiles. + +**Not established.** The upstream defect — cargo holds a sha256 and declines to +check it — is not oring's to fix. [corrections: C-9a] + +## Residual — outbound TCP to any IP on an allowed port + +**Claim.** 443 reaches any HTTPS endpoint, so an allowed port is an exfil +channel. + +**Mechanism.** Landlock net is port-only, never address-keyed. + +**Control.** Host firewall or filtering proxy — a **required** companion control +(`DESIGN.md` §9), not an optional one. + +**[loss: BREACH by intent — the policy granted the port · disclosure: stated (`net=` and `egress_tcp`)]** + +## Residual — DNS and UDP exfil + +**Claim.** Narrowed 2026-07-29 by making UDP a profile default, and deliberately +**not** called closed. `strict` grants no UDP at all, so for that profile it *is* +closed. `balanced` grants outbound **:53 only** — but Landlock net is port-only, +so **:53 at any address is exactly the DNS-tunnel channel**, reachable via an +attacker-controlled resolver. The residual survives under `balanced` in its +most-used form, narrowed from every UDP port to one. + +**Mechanism.** ABI V10 ships `LANDLOCK_ACCESS_NET_BIND_UDP` and +`LANDLOCK_ACCESS_NET_CONNECT_SEND_UDP` (mainline 7.2), the latter explicitly +covering "send datagrams to the given remote port ignoring any destination +pre-set on a socket" — the UDP analogue of the `MSG_FASTOPEN` implicit-connect +gap ring 2 closes. + +**Measured** 2026-07-29 on 7.2.0-rc4 via bash `/dev/udp` under each profile with +an unsealed control (both ports reachable bare): `balanced` permits :53 and denies +:9, `strict` denies both, `getent hosts` resolves under `balanced` (exit 0) and +fails under `strict` (exit 2). Pinned by +`profile::tests::the_shipped_udp_posture_is_dns_only_or_deny_all` plus two +∀-over-profiles invariants: no net right left `None`, and a UDP egress grant +implies port 0 for autobind. + +**Control.** The `DESIGN.md` §9 companion control is what closes it. +Do not read "UDP is restricted" as "DNS exfil is closed". [corrections: C-9] + +**[loss: BREACH by intent — the profile granted :53 · disclosure: stated (`net=` names the UDP posture and `unhandled=` reports any right left open)]** + +## Residual — anything that is not TCP or UDP is unmediated, at every ABI + +**Claim.** `egress_tcp: []` and `egress_udp: []` together still do not mean "no +network". Read `egress_tcp: []` as "no TCP", never as "no egress". + +**Mechanism.** All three Landlock net hooks `return 0` — *allow* — for a socket +that is neither TCP nor UDP (`security/landlock/net.c`). This is a **deliberate +design position, not a hook-signature limitation** [corrections: C-7]: the hooks +are registered and they run, and the body chooses to return 0 for anything +`sk_is_tcp()`/`sk_is_udp()` rejects. The three do not reach it by one dispatch +shape [corrections: C-8]. No net-hook ABI bump retires this; closing it needs a +new access-right family, which Ivanov's *"Support socket access-control"* RFC +proposes and which is unmerged — so "no ABI closes it" is a claim about today's +tree, not about all future Linux. + +**What is actually reachable** is a different question from what Landlock +declines to mediate [corrections: C-12]. ICMP is **closed by ring 2**: `socket()` +in `AF_INET`/`AF_INET6` is restricted to the protocols ring 1 can mediate, in +both profiles, so `IPPROTO_ICMP` gets `EAFNOSUPPORT` and **in-cage `ping` does +not work**. `AF_PACKET` and raw sockets need `CAP_NET_RAW`. DCCP and UDP-Lite no +longer exist in Linux. **SCTP is live on a stock distro kernel** — a measurement +to the contrary was taken on a `localmodconfig` kernel with no `sctp.ko`, a +standing reminder that protocol *availability* is a property of the kernel build, +not of Landlock. + +**[loss: BREACH by intent · disclosure: stated — `unhandled=` names it and `ORING_QUIET` cannot suppress that (pinned by `tests/udp_wall.rs`)]** + +## Residual — the child reads its own environment + +**Claim.** Any credential injected into the ring is readable by the child. + +**Mechanism.** Narrowed: the child's env is an allowlist by default +(`BASE_ENV_KEEP` — POSIX/locale basics only), so a credential reaches the child +only when the operator names it with `--env-keep` or opts out wholesale with +`--env-inherit`. What remains is "steal any credential the operator explicitly +admitted", which is a decision rather than an accident. + +**[loss: NONE — an explicitly admitted credential is a decision · disclosure: documented-only, and the ENV side is silent: 38 of 46 vars dropped, one narrated (T27)]** + +## Residual — reach the operator's ssh-agent + +**Claim.** ~~A compromised child reaches `SSH_AUTH_SOCK`.~~ **Closed in v0.2 by +the V9 floor** [D-4b], as a special case of pathname-AF_UNIX connect below. + +**Mechanism, kept because it is the instructive part.** It was not closable at +the FS-tier layer at all. Both profiles grant `/tmp` and OpenSSH's socket sits at +`/tmp/ssh-XXXXXX/agent.NNNN`, yet no arrangement of tiers ever gated it — +Landlock has no deny rule and a granted `/tmp` cannot have a subpath carved out, +so withholding the grant would have obscured the socket (a `mkdtemp` name), not +denied it. The env allowlist removes the trivial path but never closed the hole. +`warn_on_sockets` is retained as the tripwire that would start warning again if +the floor were lowered. + +## Residual — connect to a pathname AF_UNIX socket + +**Claim.** ~~docker.sock, podman.sock, journald.~~ **Closed in v0.2 by the V9 +floor** [D-4b]. `RESOLVE_UNIX` gates `connect()` to a filesystem-path socket +(handled, granted in no tier → `EACCES`); abstract sockets are covered by scope +(`EPERM`) at the floor regardless. + +**Mechanism, kept because it is the instructive part.** Landlock has **no hook on +AF_UNIX `connect()` below V9**, so no arrangement of tiers gated it — and the +obscurity that withholding `/tmp` bought was itself defeated by the peer-procfs +residual below, which hands over `SSH_AUTH_SOCK` verbatim from a peer's `environ` +under the default `--proc full`. **Two residuals composed to defeat the +mitigation between them**; raising the floor was the only real fix. + +## Residual — peer procfs read + +**Claim.** A compromised child can read a peer uid-1000 process's +`/proc//{environ,maps,mem}`. + +**Mechanism.** These are VFS `read()`s gated only by `ptrace_may_access`, not by +a blocked syscall, and Landlock walls them through `hook_ptrace_access_check`. +The denier consults **no handled access right** — it compares domain hierarchies +via `domain_scope_le()`, which is ancestor-**or-equal** — and the rule predates +the 5.13 merge, so the floor buys none of it [corrections: C-13]. The axis is +**in-domain vs out-of-domain, not descent** [corrections: C-14]: `fork(2)` +creates no new domain, so every process under one oring seal shares one domain +and reads every other's `environ`. + +**Measured.** Out-of-domain peer `environ` returns `EACCES` (Fedora 44, kernel +7.1.4, ABI V9); SELinux was ruled out rather than presumed — the same read +succeeds bare under an identical context, `deny_ptrace` is off, and the denial is +peer-specific. Siblings under one seal: `environ` is **readable** under +`--proc full` (7.2.0-rc4, 2026-07-27). Under `--proc self` the sibling read is +denied, but by the **FS tier**, not the domain relation [corrections: C-15] — +`cmdline`, `stat` and `comm` are not ptrace-gated and are also denied there, +which only a path-layer denial can do. + +**Control.** `--proc self`, or runtime PID-ns + `maskedPaths` (`DESIGN.md` §9). +`--proc self` buys sibling-`environ` privacy by making the path unreachable, +which is why it also costs the fork compatibility documented in `DESIGN.md` §8. + +**`--proc self` is demonstrated** (CON-7) by the matched pair +`proc-indomain-env-full` / `proc-indomain-env-self` in +`tests/containment_matrix.rs`: one seal, two processes, a planted +`ORING_DECOY` in the sibling's environment. Under `--proc full` the read +**succeeds** — that arm is the vacuity control, and it is the residual itself — +and under `--proc self` it returns `EACCES`. The runtime PID-ns control is +**not** demonstrated here and cannot be: it needs a container runtime. + +**The wall is lost under privilege, and oring does not check for it.** +`may_access_mm()` (`kernel/fork.c`) returns true on +`(mode & PTRACE_MODE_READ) && perfmon_capable()` **after** `ptrace_may_access` +fails, so a reader holding `CAP_PERFMON` or `CAP_SYS_ADMIN` in the initial +userns reads a non-descendant's `environ` and `maps` regardless of any Landlock +domain. `mem` still denies — it uses `PTRACE_MODE_ATTACH`. **oring performs no +euid or capability check, so `sudo oring …` silently loses this wall.** The +bypass arrived in Linux 6.15 (`0c555a3c1bc9`, "procfs: allow read-only remote mm +access under CAP_PERFMON"). + +**The check is open-time only.** A descriptor opened before `restrict_self` +keeps reading afterwards while a fresh open of the same path returns `EACCES`. +`scrub_fds` closes that for the child, but fds 0–2 are exempt, so +`oring … < /proc//environ` survives — the same shape as the pre-seal +descriptor residual below. + +**Not established.** `environ` is what was witnessed. `maps` and `mem` share the +`ptrace_may_access` gate and are expected to follow but have **no cell yet** — do +not widen the claim without one. Peer metadata (`cmdline`/`stat`) is +world-readable regardless of the floor and remains readable under `--proc full`. +The seal line's `proc=` field reports the tiers oring **asked** Landlock for, and +`add_tier` skips a path that does not exist, so a granted-but-absent `/proc` path +still counts. + +**[loss: BREACH · disclosure: documented-only — `--proc self` is the offered control; the instrument half closed 2026-07-29 when the seal line gained `proc=`, derived from the installed tiers rather than echoed from the flag (corrections: C-17)]** + +## Residual — unfilterable kernel bug classes + +**Claim.** `futex` UAF, the AF_UNIX **SCM_RIGHTS GC race** (genuinely no argument +to mask — distinct from the now-filtered MSG_OOB path), and +Dirty-COW-via-`/proc/self/mem`. + +**Mechanism.** Required by threaded workloads; there is no argument to filter on. + +**[loss: BREACH · disclosure: documented-only — nothing in a run reports it; this is non-goal 2 restated]** + +## Residual — unmasked /proc and /sys + +**Claim.** Readable unless the runtime masked them. + +**Mechanism.** Masking is a mount-view job and belongs to the runtime. + +**[loss: BREACH by intent — the runtime is the controller · disclosure: documented-only; this is non-goal 5 restated and carries no measurement]** + +## Residual — a resource the domain did not create + +**Claim.** A Landlock domain restricts *operations initiated under it*, not +capabilities the process already holds. A UDP socket `connect()`ed **before** +`restrict_self()` keeps sending, via address-less `send(2)`, after a zero-rule +seal, while a freshly created socket in the same process is denied `EACCES`. + +**Mechanism.** Narrower than it first looks [corrections: C-6]: an addressed +`sendto(2)` on that very fd **is** denied. What survives is the address-less +*path* — `hook_socket_sendmsg` only checks when `msg->msg_name != NULL`, and the +autobind check passes because the socket is already bound. So the residual is "a +connected socket keeps its established peer", not "pre-seal descriptors are +unmediated". Upstream states this as design: *"receiving or inheriting a file +descriptor (or similar) is governed by the capability model, not Landlock … a +possible confused deputy, **not a bypass**"* (`landlock: Document the threat +model`, 2026-07-07). Not closeable by an ABI bump; covered, if at all, by the +`DESIGN.md` §9 host layer. + +**Measured** 2026-07-28, one variable (the redirect), both shipped profiles: a +fresh `open()` of a withheld path is denied while the same path delivered on +**fd 0** reads in full, with a seal line **byte-identical** between the two runs. +Attributed to the scrub by fd 3 returning `Bad file descriptor` on the same run, +and reproduced on a planted sentinel outside every granted tier as well as on +peer procfs — so the mechanism is **any withheld path**, not a procfs special +case. + +**Not established.** Descriptors arriving over `SCM_RIGHTS` are subject to the +same distinction and have **not** been separately measured. oring `execve`s a +fresh image and passes no sockets, so the exposure is what the **caller** hands +in. + +**[loss: BREACH · disclosure: **silent** — `--denials` is structurally blind here, because the open happens outside the domain and the kernel is never asked, so no denial exists to report (T10)]** + +## Residual — a denied path stays visible, and `access()` pre-flights abort + +**Claim.** Landlock hooks the open; it does not hook the metadata permission +check. Measured on one path, one binary, one variable (the seal): +`access(R_OK)` → `True`, `stat()` → succeeds, `exists` → `True`, +`open()` → `EACCES`. **That combination cannot occur under DAC**, where +`chmod 000` fails both together, so a program that checks `access()` and then +opens is walked into a branch reachable on a normal system only through a race or +corruption. + +**Mechanism.** Structural, not a version artifact: `security/landlock/fs.c` +registers `file_open` and the `path_*`/`sb_*` families and **neither +`inode_permission` nor `inode_getattr`**, which is what `access`/`stat` resolve +through. + +**Measured on git**, two denial layers × two DAC modes, identical Landlock denial +in both sealed rows: `sealed`+mode 644 → **rc=128, fatal**; `sealed`+mode 000 → +rc=0, warning only. The only variable is whether `access()` agreed. Under the +divergence `~/.config/git/config` is fatal with *"unknown error occurred while +reading the configuration files"* — **naming neither the file nor the sandbox** — +and `~/.config/git/ignore` is fatal naming the file; `attributes` degrades +cleanly. Three failure modes are known (abort, silent config loss, correct +handling) and **only git is measured as aborting**: `npm` with an unreachable +`~/.npmrc` returns rc=0 and the default value, and does the same under +`chmod 000`, so that is npm's behaviour rather than a sandbox effect. + +**Control.** `oring --explain`. Nothing in the failure suggests it, which is the +residual. + +**Demonstrated** (CON-7) by +`tests/control_efficacy.rs::explain_discriminates_the_withheld_path_and_runs_nothing`, +in three arms: the divergence is live under the policy used (`access()` readable, +`open()` denied); `--explain` **names the granted tier and not the withheld +path**, so an operator can see which side of the boundary theirs fell on; and it +**does not execute the target** — checked against a positive control that runs +the same command without `--explain` and confirms it does create the sentinel, +so "ran nothing" cannot pass over a command that could never have run. + +**Not established.** Not closeable at this layer: making a path read as *absent* +is a mount-namespace capability (measured — the same file withheld by a tmpfs +mount in a user namespace gives git rc=0), and Landlock's only vocabulary is +deny. Which config paths the built-in profiles should grant to reduce how often +this is hit is open (`BUILD-QUEUE.md` T23). Full measurement: +`deep_research/notes/2026-07-28-landlock-access-open-divergence.md`. + +**[loss: OPAQUE (and DIVERGE for the redirection fix) · disclosure: documented-only — the operator's route back is `oring --explain` and nothing in the failure suggests it]** + +--- + +## What could be wrong with oring itself + +Every residual above assumes the seal works and asks what a child can still +reach. This section asks the other question: **what if the adversary is already +inside oring's own machinery?** Each is a dependency of correctness that oring +neither enforces nor checks, and the pattern is why they were invisible — a +residual list scoped to *what the child can do* has no slot for *the wall might +not be where this document says it is*. + +## Residual — oring IS the child; there is no process boundary + +**Claim.** Sealer and sealed are one process. + +**Measured** 2026-07-29: the seal line reports `pid=511037` and the child prints +`$$ = 511037`. + +**Mechanism.** `execve` preserves the process, so everything oring holds at seal +time is inherited by definition — fds (0–2 deliberately not scrubbed), the +environment, signal dispositions, cwd, session and process group. The address +space is replaced, so memory is not inherited; **nothing else is protected by a +boundary, because there is no boundary.** This is also why the seal line is +forgeable by the child: same stderr, same process. + +**[loss: BREACH · disclosure: documented-only — `pid=` is printed but no field says what that identity implies]** + +## Residual — the sealing library is vendored out-of-tree and unaudited + +**Claim.** `Cargo.toml` carries +`[patch.crates-io] landlock = { git = "…/eslerm/rust-landlock", rev = "d7bb0c1…" }`, so the +crate that installs ring 1 is not the published one, `cargo install oring` does +not build, and the clean-registry-dependency guarantee is **suspended for exactly +the dependency that does the confining** (D-4b). 58 packages resolve in +`Cargo.lock`. + +**[loss: BREACH · disclosure: **silent** — nothing in a run reports which landlock implementation sealed it]** + +## Residual — seccompiler's lowering is not re-proven here + +**Claim.** oring verifies its own hand-assembled cBPF and takes the rest of +ring 2's compilation on faith — it is Firecracker's TCB, imported. A lowering bug +produces a filter that installs cleanly and denies less than the rules say. +[`VERIFICATION.md`](VERIFICATION.md) states the boundary. + +**[loss: BREACH · disclosure: **silent**]** + +## Residual — `nnp=1` is a literal, not a measurement + +**Claim.** The only seal-line field not derived from run state. + +**Mechanism — three legs, and one of them is dead.** The field was described here +as true by a *double* invariant: `set_no_new_privs()?` aborts the pipeline before +the line is reached, and unprivileged `SECCOMP_SET_MODE_FILTER` returns `EACCES` +without NNP. **Measured 2026-08-02, the second leg is unreachable:** +`seccompiler::apply_filter_with_flags` calls `prctl(PR_SET_NO_NEW_PRIVS, 1, …)` +itself immediately before the `seccomp()` syscall +(`seccompiler-0.5.0/src/lib.rs:344`), and `SeccompProfile` has two variants with +no no-filter option — so every seal sets NNP twice and that `EACCES` path can +never fire through oring. oring's own call is **defence-in-depth**, not the sole +mechanism. + +**Measured.** `tests/nnp.rs` — bare `NoNewPrivs 0 / Seccomp 0`, sealed +`NoNewPrivs 1 / Seccomp 2`, one variable. It also checks the literal against the +kernel for the same run, which is what makes the field falsifiable by a run at +all; flipping it to `nnp=0` turns the suite red. + +**Not established.** That privilege escalation is prevented — only that the bit +the guarantee rests on is set and that oring's report of it is not lying. And +**no test can pin `preexec::set_no_new_privs()` itself**, for the reason above: +removing it leaves the child at `NoNewPrivs: 1` regardless. Do not read a green +run as evidence that line is load-bearing, and do not delete it on that basis +(T15). + +**[loss: BREACH · disclosure: **silent** — the field asserts a property it does not measure]** + +## Residual — `errata=` is a lower bound, not a certificate + +**Claim.** Two kernels agreeing on `(abi, errata)` may still differ in behaviour. +The mask enumerates only errata the kernel *declares*, and 7.2-rc4 carries at +least one behaviour change no erratum covers. + +**[loss: BREACH · disclosure: stated — `DESIGN.md` §12 says so, and the field is printed]** + +## Residual — diagnostics render caller-supplied bytes raw + +**Claim.** ~~13 of ~14 print sites rendered caller bytes unsanitised.~~ +**Closed 2026-08-02.** Only `denials.rs` escaped them, and it is the only one +whose input comes from the post-`execve` child — every other site took bytes +from the *caller*, which the threat model does not treat as trusted. A forged +`SEALED` line was injected **through argv alone** under `--profile balanced`, +with no policy file at all, and ANSI escapes passed through unchanged. + +**Mechanism.** `crate::diag::safe` is now the single implementation, applied at +two levels. `OringError`'s `Display` is a chokepoint covering every refusal +message and every future one by construction, rather than depending on each +`OringError::policy(format!(...))` author to remember. The warning and info +sites that interpolate caller data — skipped policy paths, a granted tier +holding a socket, tier overlap, `--exec-tmp`, and `$TMPDIR` — call it directly. +Escaping happens at render, not at construction, so messages stay matchable by +tests and only the terminal sees the escaped form. + +**Measured.** `tests/diag_injection.rs` drives `ESC[2K\r` plus a plausible +`SEALED` line through argv and through `$TMPDIR`, and asserts no raw `ESC` or +`CR` reaches stderr — with a guard that the payload appears *escaped* rather +than merely absent, so a cell cannot pass over output that never contained the +argument. Reverting either sanitiser reopens its own path and turns exactly that +test red. + +**Not established.** Escaping control characters does not make the output +unspoofable: a path that is long, right-to-left, or a homoglyph of another path +is still misleading and is not addressed. Nor does it constrain the **child**, +which shares stderr after the seal and can print anything — that residual is +documented above and unchanged. What closed is forgery by the *caller*, before +the child exists. + +**[loss: MISREPORT · disclosure: **silent** — the forged line is content-indistinguishable from the real one]** + +## Residual — `panic = "abort"` is `[profile.release]` only + +**Claim.** "A parse bug fails closed" holds for the shipped binary and not for +debug or test builds, which unwind. Stated because `DESIGN.md` §12's fuzzing +decision rests on it. + +**[loss: BREACH · disclosure: documented-only]** + +## Residual — the under-coverage disclosure is discardable by the invoker + +**Claim.** `unhandled=` is the only seal-line field reporting what the run left +**unrestricted**, and it is written with `let _ = writeln!(stderr, …)`. + +**Measured** 2026-07-29 on the shipped `balanced` profile at V10, which leaves +`net.BindUdp,net.ConnectSendUdp` unhandled: with fd 2 piped the line is present; +with `2>/dev/null` **zero** disclosure lines appear, the seal proceeds and the +child exits 0. `ORING_QUIET` is refused (`tests/udp_wall.rs`) while a shell +redirect is unmediated — the same channel, two suppression routes, one guarded +and one not. **The write to `/dev/null` succeeds**, so no error check inside +oring can detect the case. + +**Mechanism.** Structural at this layer, established rather than assumed: the +audit transport `src/denials.rs` uses is a *consumer* of kernel-emitted Landlock +records (types 1423/1424) read via `journalctl`. oring has no producer side, +`AUDIT_USER_MSG` needs `CAP_AUDIT_WRITE` that an unprivileged tool lacks, and +`/dev/log` would be a new channel opened at step 11 when oring is **already +sealed** (step 6) — grantable only by the very policy under audit. oring +additionally blocks `SYS_syslog`. Pinned by +`tests/residual_boundary.rs::the_under_coverage_disclosure_is_discardable_by_the_caller`. + +**Control.** `--require-full-coverage`, an opt-in that turns any unhandled right +into refusal code 120 before `execve` — an exit code is the one signal a redirect +cannot remove. Measured: it refuses `balanced` at V10, seals a policy naming both +UDP directions (`unhandled=-`), and still returns 120 with stderr discarded. It +is not the default because that would refuse every default run today, **so the +residual stands for any run without the flag**, which is every default run. + +**Not established.** The product question is open and is not decided here: `D-4` +rejects the degraded-path-with-a-warning pattern everywhere else, and +`unhandled=` is that pattern surviving in the one place it is load-bearing for +auditing. + +**[loss: MISREPORT · disclosure: documented-only — this is the only place it is written down, and no instrument reports that a disclosure was discarded]** + +--- + +**What follows from having this list at all.** **Seven of the eight** residuals +in this section are `silent` or `documented-only`: **an operator running oring +cannot discover from the run that any of them is the case.** That is the honest +cost of exec-and-vanish — there is no surviving component to attest to its own +integrity — and it is `open-questions.md` Q10 arriving from a third direction. +(`open-questions.md Q` is a work-item handle on the same footing as the +`BUILD-QUEUE.md` T-numbers: the file is the maintainer's and is not in this +repository, so the question is stated here or it is not available to you.) + +--- + +## Non-goals + +1. **Not a VM/hypervisor boundary.** Shared host kernel. Kernel-isolation-critical + workloads (a Xen fuzzing rig) need a VM underneath. +2. **Not a kernel-0-day defence** beyond surface reduction. +3. **Not a network policy engine.** Domain/IP egress is host nftables + default-deny or a filtering proxy — a **required companion control** + (`DESIGN.md` §9), stated loudly. +4. **Does not author or load host MAC.** SELinux/AppArmor stay the host's job and + stack underneath (intersection). +5. **Does not mask procfs/sysfs.** That is a runtime `maskedPaths` / mount-view + job. Pathname unix-socket connect *is* mediated at V9 via `RESOLVE_UNIX` + (`DESIGN.md` §4). +6. **No supervisor, seccomp-notify, OCI-hook validation, egress firewall, + credential broker, cgroups or rlimits.** Each needs a persistent or privileged + component oring by definition is not. diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index 474e6ae..061ca68 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -11,6 +11,8 @@ oring **builds the filter/ruleset it intends**, exhaustively, where the | Property | Artifact | Tool | Scope | |----------|----------|------|-------| | **x32 guard is TOTAL** — `eval == KILL_PROCESS iff arch != AUDIT_ARCH_X86_64 \|\| (nr & X32_SYSCALL_BIT)`, else `ALLOW` | `seccomp::x32_guard_total` | **Kani** (bounded model check on the real Rust) | proven over **all 2⁶⁴** `(arch, nr)`; matrix samples 1 cell | +| **ABI clamp never over-claims** — `abi_from_version` never names an ABI the linked crate cannot handle, clamps at/above the ceiling *to* the ceiling (never to a lower/empty set), and maps each in-range version to exactly its own ABI | `landlock::abi_from_version_clamp` | **Kani** | proven over **every `i32`**; the unit test spot-checks 10 values | +| **Filter is truncation-invariant** — the emitted cBPF cannot change verdict on a truncated/short read of the `seccomp_data` high bits (the H2 regression class) | `tests/seccomp_smt.rs` | **z3 SMT** over the emitted program | both shipped profiles + a positive control that must detect an injected high-bit dependency | | tier masks: `write_noexec` never grants `Execute`; no tier grants device-node creation; `wx == wn \| Execute` lattice | `landlock::tests::tier_invariants` | `#[test]` (finite `AccessFs` flag domain) | exhaustive by construction (const-eval, not FV) | The x32 guard is the one genuinely proof-shaped object in the tree: a frozen, @@ -20,13 +22,38 @@ sampled test reliably catches. ## Running the proofs -Opt-in — **not** in CI (Kani is minutes/harness): +Kani is opt-in — **not** in CI, so `make verify` is its only gate. The SMT gate +*is* in CI (`make smt`) and needs only `z3`, no Landlock kernel. + +**"minutes per harness" was wrong and it is the reason this stayed unfixed.** +Measured 2026-07-28 on 7.2.0-rc4: `x32_guard_total` **0.09–0.11 s** of solver +time, `abi_from_version_clamp` **0.016–0.024 s**; `make verify` end to end +**2.1 s warm, 4.4–4.6 s cold**, and **4.9 s pinned to 4 CPUs** — a +runner-shaped control showing the figure is not core-count sensitive. The +estimate overstated the cost by roughly two orders of magnitude, and every +downstream document inherited it (a conformance rubric carried "~5–10 min" +and "1 d" for the same work, both unmeasured). + +The real cost is the **install**, not the run: Kani vendors its own pinned +nightly — demonstrated to coexist with this repo's `rust-toolchain.toml`, since +`make verify` runs green here — and `~/.kani` is ~483 MB. The harnesses need no +musl target, no `z3`, no Landlock kernel and no KVM, and they build with the +feature set `lint-build`'s clippy step already compiles. So the shape is: cache +`~/.kani`, `cargo install --locked kani-verifier@`, run `make verify` in +`lint-build` — **not** in `confinement-v10`, whose cost is a kernel build the +proofs do not need. Pin the version: an unpinned bump changes the vendored +nightly and can change what `#[kani::unwind(9)]` covers. ```sh -make verify # cargo kani --harness x32_guard_total -cargo test --lib # the tier_invariants + interpreter corner checks (fast, in the normal suite) +make verify # cargo kani: x32_guard_total AND abi_from_version_clamp +cargo test --lib # tier_invariants + interpreter corner checks (fast, normal suite) +ORING_REQUIRE_SMT=1 cargo test --features bpf-dump --test seccomp_smt # z3 gate ``` +`ORING_REQUIRE_SMT=1` makes a missing `z3` fatal rather than a silent skip — the +same never-silent-green contract `ORING_REQUIRE_KERNEL=1` provides for the +kernel-enforced suites. + ## Explicitly NOT verified (out of scope by construction) - **"The cage holds."** The Landlock LSM and the kernel seccomp BPF VM are the @@ -38,3 +65,156 @@ cargo test --lib # the tier_invariants + interpreter corner checks (fast, in `SeccompFilter → BpfProgram` compilation is Firecracker's TCB, not re-proven. - **W^X as a whole / pipeline ordering.** Completeness assertions over the kernel syscall surface and integration properties — tests, not theorems. +- **Which layer denied — for the MATRIX.** `tests/containment_matrix.rs` asserts an + expected errno per cell; it does not establish that *oring* produced the denial. + On a host with a second MAC enforcing (SELinux/AppArmor), or where DAC already + forbids the operation, a deny cell can pass on the other layer's authority. A + manual bare-vs-confined control pass (2026-07-24) found every deny cell + attributable except `shmat-exec`, which denies `EACCES` unconfined on that host — + but the matrix harness does not perform that control itself, and the last full + run was on a SELinux-enforcing host. Treat a green matrix there as necessary, + not sufficient. See the limitation note at the top of that file. + + **Control pass re-run 2026-07-28 on 7.2.0-rc4, SELinux Enforcing.** All **20** + cells that assert a denial (19 `Expect::Deny` + the one `Expect::Kill`) — **21 of 30 as of 2026-07-29**, after two in-domain PROC cells landed (T19); the pass below covered the 20 that existed then were + probed bare by running the harness's own payload — `oring __probe `, + debug build — with no wrapper, so the seal is the only variable. Result: + **14 succeed outright unconfined** (the strongest form of attribution), + **5 return a distinct errno**, and **exactly one returns the errno the cell + asserts**. + + **That one is `fs-write-readonly`** — it writes `/etc/oring-matrix`, which DAC + already forbids uid 1000, so it is `EACCES` bare and `EACCES` sealed. It + establishes nothing about oring. **Demonstrated, not inferred:** under a mutant + that grants every handled FS right on `/` (ring 1's FS layer stops enforcing + entirely), this cell still reports `PASS` while `wx-noexec-tier` correctly goes + `FAIL`. **Fixed and pinned (2026-08-02).** The cell no longer targets `/etc`: it writes + into a harness-owned `read_only` grant that DAC permits, so the bare arm + succeeds and the denial is attributable, with a companion read cell proving the + grant is live. **The fixture also exists now:** every denial cell runs bare + before it runs sealed, and a cell whose bare arm satisfies the sealed + expectation is reported `UNATTRIBUTABLE` and must be declared, with the + declaration checked in both directions. What that closes is the *rot*: the + attribution was a hand-check on two dates, and a cell added after the last one + went uncovered. What it does **not** close is which layer produced an attributed + denial — bare-succeeds shows the environment does not supply the denial by + itself, not that ring 1 rather than ring 2 produced it. Per-cell table: + `deep_research/notes/2026-07-28-t21-control-pass-vetted.md`. + + **CORRECTED 2026-07-28 — the first write-up of this re-run was wrong three + ways, and the corrections matter more than the result.** + + 1. It said "all 19 deny cells (20 at the time; **21 of 30** since the T19 in-domain cells)". There are **20**; `19` is the count of the + `Expect::Deny` enum variant, and the same paragraph listed the `Kill` cell + among those probed. The count was taken from the type, not from the + property "asserts a denial". + 2. It said ten cells succeed unconfined and "the rest return a distinct + errno". Measured: **14**. The four IPC cells (`unix-connect{,-abstract}` + × 2 profiles) succeed bare; they do not return a distinct errno. The + likeliest cause is an unfaithful first control — a bare `unix-connect` + against an unbound path fails `ENOENT`, which reads as "distinct errno" and + **overstates** how much the cell proves. It cannot be diagnosed now, + because that pass retained no data. + 3. It said "the exception has moved" and drew the lesson that a dated claim + ages in its *content*. **That mechanism is fabricated.** The `shmat-strict` + row has asserted `EPERM` since `9d1c272` (initial release) and has never + changed, and the 07-24 note itself records `EACCES` bare — which is + *distinguishable*, by the predicate stated in that same note. Nothing aged. + The 07-24 conclusion did not follow from its own evidence when it was + written, and today's re-run reproduces its **observation** exactly. A + re-run that disagrees with the record must separate "the world changed" + from "the earlier inference was wrong"; this one assumed the first and + published the flattering story. + + What survives is the narrower lesson: an exception recorded as a **name** + (`shmat-exec`) rather than as a **property** — *a cell whose asserted errno + equals the errno the operation already returns unconfined* — cannot be + re-checked mechanically, so neither the original error nor its restatement was + catchable by reading. Attribution is a per-run computation, and until the + harness performs it (G19) any prose here is a snapshot that no gate defends. + + `tests/protocol_wall.rs` **does** perform it, and that is why it is a separate + file rather than more matrix rows. `assert_oring_refused` runs each triple BARE + first and skips loudly when the kernel refuses it anyway — without which the SCTP + cell passed on a `localmodconfig` kernel with no `sctp.ko`, satisfied entirely by + the kernel's own `EPROTONOSUPPORT`. Its complement, + `assert_oring_changed_the_errno`, covers the cases where the kernel also refuses + but with a *distinguishable* errno, and fails loudly if the bare errno is already + `EAFNOSUPPORT` — because then the two causes are indistinguishable and the cell + would establish nothing. + +## The claim table is mutation-audited (2026-07-27) + +Every row of `docs/ATTACKS.md` was audited by removing the **enforcement +mechanism** it depends on and recording which tests die — eight mutations +covering all twenty rows. + +**The breakdown, stated rather than summed to a single number.** All 20 cited +tests exist; there are no phantom citations. Of the 20 rows: **16 die to a +mutation of their own mechanism** (15 at the 07-27 audit, plus row 9 as of +2026-07-28); **3 are `(control)` allow-assertions with no mutation run against +them** (`3d`, `3f`, `4`); **1 is not mutable from oring at all** (row 12). + +*This replaces "19 of 20 rows are backed by a test that provably fails when its +mechanism is removed", which never reconciled with the table above it.* That +count reached 19 only by treating row 12 — which the very next bullet calls +*measured, not mutation-backed* — and three controls that had no mutation run +as backed. The sentence was self-inconsistent before row 9 was fixed, so the +repair is not to increment it. + +- **Row 9 (x32 re-entry) was the failure. Fixed 2026-07-28, and the first fix + was not enough.** The mutant is `JSET` on `X32_SYSCALL_BIT` swapped for `JEQ` + on `X32_SYSCALL_BIT | 39`, allowing every x32 syscall except `getpid`; it left + the whole suite green and `make check` at rc=0, SMT included — not one cell went red. + - `22f6cca` added a second process cell at a second number. An adversarial + mutation sweep then measured that cell's marginal detection power as **1 + mutant out of 89** — the one it was written against — and found two that beat + it: `i4.k := 0xC0000000` survived the **entire** gate, and + `i4 := JGT X32_SYSCALL_BIT|38` leaked x32 `read`, `write`, `open`, `mmap`, + `mprotect` and `ioctl` **with both cells green**, because both probed numbers + sit above the threshold. Probing more numbers cannot close this: the dominant + leak family is monotone and always spares the numbers a cell names. + - `e53cd67` closes it at the model instead — + `x32_guard_matches_its_spec_over_a_dense_table` asserts the emitted program + equals its specification over 12,354 points, killing all 89 enumerated + mutants and all 79 leaky survivors in microseconds with no kernel. It runs + where the process cells take `require_or_skip` and count green. + - **A note on the SMT gate, corrected here because the error propagated.** The + claim "SMT included" is true of the *gate* and misleading about *coverage*: + `tests/seccomp_smt.rs` consumes `dump_groups()`, which compiles the four + errno groups only. **The hand-assembled x32 guard is not in the SMT model at + any number** — it is out of scope, not narrowly scoped. +- **Row 12 is not mutable from oring.** Its denier is + `hook_ptrace_access_check`, which consults no handled access right. The row is + *measured*, not *mutation-backed*; a kernel change to `domain_scope_le()` + would retire the guarantee with no signal in this tree. +- **`containment_matrix` is one test for 30 cells**, so a mutation that kills it + does not say which cell caught it. Coverage is proven; attribution inside the + matrix is not. + +Run any of these with `--no-fail-fast`. Without it `cargo test` stops after the +first failing binary, and a mutation that kills six tests reports one. + +Full method and per-row table: +`deep_research/notes/2026-07-27-guarantee-audit.md`. + +## Practice: a green test is not evidence until it can go red + +Every refusal-shaped check added since 2026-07-26 is **mutation-verified** — the +check is stubbed out and the suite re-run, and the change is only accepted if +exactly the intended cells go red while the clean/warn cells stay green. + +This is not ceremony. It has caught real defects in the tests themselves: + +- `inet_socketpair_is_refused_under_strict` stayed GREEN with the type rule + removed, because every cell in it used `IPPROTO_SCTP` — already blocked by the + *protocol* rule, so the test never exercised the axis it was written for. + Protocol-0 cells were added and it now fails as intended. +- `partial_net_restriction_is_disclosed_by_name` was re-run against the ORIGINAL + `abi > CRATE_MAX_ABI` predicate to confirm it catches the historical bug rather + than a paraphrase of it. + +The same discipline applies to skips: `ORING_REQUIRE_KERNEL=1`, +`ORING_REQUIRE_SMT=1` and `ORING_REQUIRE_UDP=1` each turn a would-be skip into a +hard failure, because `ok. 0 passed` reads green and `SKIP` goes to stderr, which +cargo discards. diff --git a/scripts/check-commit-msg.sh b/scripts/check-commit-msg.sh new file mode 100755 index 0000000..80cdf61 --- /dev/null +++ b/scripts/check-commit-msg.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# SPDX-License-Identifier: BSD-3-Clause +# +# Refuse a commit message carrying host-specific detail. +# +# WHY THIS EXISTS, measured 2026-08-01: a commit on the release branch carried +# the maintainer's real home directory on four lines of its body, pasted in with +# a measurement transcript. It was caught by a review, one commit before the +# branch would have been pushed. Commit messages publish and cannot be +# unpublished — a host rewrites nothing, and a later reword leaves the original +# object fetchable by SHA — so this is a class of defect with no remedy after +# the fact, only prevention before it. +# +# Scope, stated because a guard's frame is the thing to check: this reads ONE +# message, the one being written. It says nothing about messages already in the +# history. `make check-history` is the sweep over every ref. +# +# Usage: check-commit-msg.sh (git passes the message path) +# Exit: 0 clean · 1 host detail found · 2 could not read the message + +set -eu + +msg="${1:-}" +[ -n "$msg" ] && [ -r "$msg" ] || { + echo "check-commit-msg: no readable message at '${msg}'. Exit 2 — nothing was" >&2 + echo " examined, and that is not the same as nothing being wrong." >&2 + exit 2 +} + +# Strip comment lines: git's template explains itself in them and they never ship. +body=$(grep -v '^#' "$msg" || true) + +# Absolute home directories of real users. `/home/u` and `/home/x` are the +# synthetic paths this project's documentation uses in examples, so they are +# allowed by being too short to be a real login here — the pattern requires 3+. +hits=$(printf '%s\n' "$body" | grep -nE '/home/[a-z_][a-z0-9_-]{2,}|/Users/[a-z_][a-z0-9_-]{2,}' || true) + +# Machine names that identify the maintainer's hosts. Word-bounded: an +# unbounded 'xtra' matches 'extra', which cost a review a false positive over +# twelve files before it was caught. +hits="${hits}$(printf '%s\n' "$body" | grep -nwE 'xtal|xtra' || true)" + +if [ -n "$hits" ]; then + cat >&2 < …` — see `bench help`. Driven by `bench/run.sh`, +//! which supplies the pinning, the rounds, and the controls; every subcommand +//! here measures exactly one term and prints exactly one line. +//! +//! # Why this is Rust and in-tree rather than a scratch C/python fixture +//! +//! Not taste. `make check` runs `cargo clippy --all-targets`, so a binary in the +//! package is COMPILED by the everyday gate and cannot silently rot as the code +//! it measures moves — which a scratch harness does the first time an errno +//! group changes shape. It also calls [`oring::seccomp::dump_groups`] directly, +//! so the programs timed here are the programs `seccomp::apply` installs, with +//! no JSON round-trip and no generated C to regenerate. `tests/seccomp_smt.rs` +//! made the same call for the same reason. +//! +//! Everything here is safe Rust: filters are installed through `seccompiler`'s +//! own `apply_filter`, `NO_NEW_PRIVS` through `oring::preexec`. `unsafe_code` is +//! denied package-wide and this binary needs no exception. +//! +//! # What it deliberately does NOT do +//! +//! No averaging across rounds, no "best of" hidden inside a subcommand, no +//! warm-up it does not announce. Each invocation reports one number plus the +//! frame that number is only meaningful inside (n, path, filter count). The +//! interleaving and the min-of-rounds live in the driver where they are visible. + +// Pedantic exceptions, argued rather than silenced wholesale: +// - the casts are iteration counts and elapsed nanoseconds moving between +// integer and float. At the magnitudes here (n <= 3e6, us <= 1e6) f64 is +// exact, and a benchmark that refused to divide would be a worse instrument. +// - `unnecessary_wraps`: every subcommand returns `Result<(), String>` so +// `main`'s dispatch is uniform and a future failure mode does not change the +// signature. Two of them cannot currently fail; that is not worth an +// asymmetry in the table above. +#![allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] +#![allow(clippy::cast_sign_loss, clippy::unnecessary_wraps)] + +use std::cmp::Ordering; +use std::hint::black_box; +use std::process::{Command, ExitCode, Stdio}; +use std::time::Instant; + +#[cfg(feature = "bpf-dump")] +use seccompiler::{apply_filter, sock_filter, BpfProgram}; + +const USAGE: &str = "\ +bench — what oring costs, measured (analysis only; see bench/run.sh) + + bench syscalls ns per syscall (getpid), for the ring-2 steady state + bench opens us per open+close, for the ring-1 steady state + bench paths the primitives the per-path seal cost is built from + bench exec us per fork+exec, with the child's exit status CHECKED + bench install [--strict] us to install each errno filter (the per-invocation term) + bench filters [--strict] install the first k filters, then the syscall loop + bench sim [--strict] how far an allowed syscall walks the emitted cBPF + +Run the whole sweep, with controls and pinning: bench/run.sh"; + +fn main() -> ExitCode { + let argv: Vec = std::env::args().collect(); + let sub = argv.get(1).map_or("help", String::as_str); + let rest: Vec<&str> = argv.iter().skip(2).map(String::as_str).collect(); + let strict = rest.contains(&"--strict"); + + let r = match sub { + "syscalls" => arg_u64(&rest, 0).and_then(syscalls), + "opens" => match (rest.first(), arg_u64(&rest, 1)) { + (Some(p), Ok(n)) => opens(p, n), + _ => Err("usage: bench opens ".to_string()), + }, + "paths" => match (rest.first(), arg_u64(&rest, 1)) { + (Some(d), Ok(n)) => paths(d, n), + _ => Err("usage: bench paths ".to_string()), + }, + "exec" => exec_bench(&rest), + "install" => install(strict), + "filters" => match (arg_u64(&rest, 0), arg_u64(&rest, 1)) { + (Ok(k), Ok(n)) => filters(k as usize, n, strict), + _ => Err("usage: bench filters [--strict]".to_string()), + }, + "sim" => sim(strict), + "help" | "-h" | "--help" => { + println!("{USAGE}"); + return ExitCode::SUCCESS; + } + other => Err(format!("unknown subcommand `{other}`\n\n{USAGE}")), + }; + + match r { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("bench: {e}"); + ExitCode::from(2) + } + } +} + +fn arg_u64(rest: &[&str], i: usize) -> Result { + rest.get(i) + .ok_or_else(|| "missing numeric argument".to_string())? + .parse() + .map_err(|e| format!("bad numeric argument: {e}")) +} + +// --- steady state ---------------------------------------------------------- + +/// ns per syscall. `getpid` on purpose: it is the cheapest real syscall, so any +/// seccomp cost shows up as the largest possible FRACTION — measuring against an +/// expensive syscall would flatter the result. glibc has not cached `getpid` +/// since 2.25 and musl never did, so `process::id()` is one real syscall. +/// +/// The warm-up loop is announced rather than hidden: first-touch faults and the +/// CPU's frequency ramp belong outside the measured window, and a reader has to +/// be able to see that choice. +fn syscalls(n: u64) -> Result<(), String> { + let mut acc: u32 = 0; + for _ in 0..200_000u64 { + acc ^= std::process::id(); + } + black_box(acc); + let t = Instant::now(); + for _ in 0..n { + acc ^= std::process::id(); + } + let el = t.elapsed(); + black_box(acc); + println!( + "{:.2} ns/syscall (getpid, n={n})", + el.as_secs_f64() * 1e9 / n as f64 + ); + Ok(()) +} + +/// us per `open`+`close`. Landlock mediates the path WALK, so its cost lands +/// here and not on the syscall entry ring 2 taxes — measured, this term is ~30x +/// the per-syscall one. +/// +/// Bounded claim, and the bound matters: warm dentry cache, one path shape, one +/// filesystem. Run it under seals with different RULE COUNTS to test whether the +/// cost scales with the ruleset. +fn opens(path: &str, n: u64) -> Result<(), String> { + for _ in 0..2_000u64 { + drop(std::fs::File::open(path)); + } + let t = Instant::now(); + for _ in 0..n { + // A failure here is the interesting case under a seal: the policy did + // not grant the path, so there is nothing to time. Refuse to print a + // number for a loop that measured EACCES. + let f = std::fs::File::open(path).map_err(|e| format!("open {path}: {e}"))?; + drop(black_box(f)); + } + let el = t.elapsed(); + println!( + "{:.3} us/open+close ({path}, n={n})", + el.as_secs_f64() * 1e6 / n as f64 + ); + Ok(()) +} + +/// The primitives the seal's per-path cost is built from. +/// +/// `add_tier` pays one `metadata` and one `PathFd::new` per declared path, and +/// the pre-seal guards each pay a `canonicalize`: every declared path is +/// canonicalised in `apply_proc_mode`, again in `check_tier_overlap`, again in +/// `proc_exposure`, and 5x per WRITE path in `writable_shared_caches` (the +/// canonicalise sits inside the `SHARED_CACHES` loop). This prices those calls +/// so the per-path term can be attributed rather than guessed at. +/// +/// `File::open` stands in for `PathFd`'s `O_PATH` open, which std cannot express. +/// `O_PATH` is the cheaper of the two, so this row is an UPPER bound on that +/// component — stated because an upper bound used as an estimate is how a cost +/// model drifts. +fn paths(dir: &str, n: u64) -> Result<(), String> { + let one = |label: &str, f: &dyn Fn() -> bool| { + for _ in 0..5_000u64 { + black_box(f()); + } + let t = Instant::now(); + for _ in 0..n { + black_box(f()); + } + println!( + " {label:<20} {:.3} us", + t.elapsed().as_secs_f64() * 1e6 / n as f64 + ); + }; + one("canonicalize", &|| std::fs::canonicalize(dir).is_ok()); + one("metadata", &|| std::fs::metadata(dir).is_ok()); + one("File::open+drop", &|| std::fs::File::open(dir).is_ok()); + println!(" (n={n} each, {dir}; File::open is an UPPER bound on PathFd's O_PATH open)"); + Ok(()) +} + +// --- per invocation -------------------------------------------------------- + +/// us per fork+exec, over `N` runs, **with the child's exit status checked +/// against `expected`**. +/// +/// The check is the point of this harness, not a courtesy. The first draft of +/// this sweep reported `oring --profile strict` as FASTER than the bare command +/// — that invocation exited 121 (`--workdir must be absolute`) with stderr on +/// /dev/null, so it never sealed and never exec'd. A clean-looking fast number +/// over a command that did not run. Any disagreement voids the whole run: the +/// timing line is not printed at all, because a number printed beside a warning +/// gets quoted without it. +fn exec_bench(rest: &[&str]) -> Result<(), String> { + let n = arg_u64(rest, 0)?; + let want: i32 = rest + .get(1) + .ok_or_else(|| "usage: bench exec ".to_string())? + .parse() + .map_err(|e| format!("bad expected status: {e}"))?; + let cmd = rest.get(2..).unwrap_or(&[]); + let (prog, args) = cmd + .split_first() + .ok_or_else(|| "usage: bench exec ".to_string())?; + + let mut t = Vec::with_capacity(n as usize); + let mut bad = 0u64; + let mut first_bad = None; + for _ in 0..n { + let start = Instant::now(); + let st = Command::new(prog) + .args(args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|e| format!("spawn {prog}: {e}"))?; + t.push(start.elapsed().as_secs_f64() * 1e6); + let got = st.code().unwrap_or(-1); + if got != want { + bad = bad.saturating_add(1); + first_bad.get_or_insert(got); + } + } + if bad > 0 { + return Err(format!( + "VOID — {bad} of {n} runs exited {:?}, expected {want}. `{prog}` did not do what \ + this measurement assumed, so the timing is not reported.", + first_bad.unwrap_or(-1) + )); + } + t.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); + let at = |q: f64| { + t.get(((t.len() as f64) * q) as usize) + .copied() + .unwrap_or(0.0) + }; + println!( + "min={:.0} p50={:.0} p90={:.0} us (n={n}, all exited {want})", + t.first().copied().unwrap_or(0.0), + at(0.5), + at(0.9) + ); + Ok(()) +} + +/// Convert one dumped group to the form `apply_filter` installs. +#[cfg(feature = "bpf-dump")] +fn to_program(insns: &[(u16, u8, u8, u32)]) -> BpfProgram { + insns + .iter() + .map(|&(code, jt, jf, k)| sock_filter { code, jt, jf, k }) + .collect() +} + +/// Time each `seccomp(SET_MODE_FILTER)` install. +/// +/// This is the term `docs/DESIGN.md` §5 did not name and the one that dominates: +/// measured 2026-07-30, ~316 us for the four `balanced` groups, against ~628 us +/// of total added per-invocation cost. The process is sealed when this returns — +/// it is a one-shot measurement by construction, which is also why the install +/// order here is the install order in `seccomp::apply` (the EPERM group blocks +/// `seccomp` itself and must go last). +#[cfg(feature = "bpf-dump")] +fn install(strict: bool) -> Result<(), String> { + oring::preexec::set_no_new_privs().map_err(|e| format!("NO_NEW_PRIVS: {e}"))?; + let groups = oring::seccomp::dump_groups(strict).map_err(|e| format!("dump_groups: {e}"))?; + let mut total = 0f64; + for g in &groups { + let prog = to_program(&g.insns); + let t = Instant::now(); + apply_filter(&prog).map_err(|e| format!("install {}: {e}", g.name))?; + let us = t.elapsed().as_secs_f64() * 1e6; + println!( + " install {:<14} {:>4} insns {us:>8.1} us", + g.name, + g.insns.len() + ); + total += us; + } + println!( + " {:<22} {:>10} {total:>8.1} us ({} filters, {} insns)", + "TOTAL", + "", + groups.len(), + groups.iter().map(|g| g.insns.len()).sum::() + ); + Ok(()) +} + +/// Install the first `k` filters, then run the syscall loop. +/// +/// The sweep k=0..4 is what separates "seccomp ENTRY costs 5 ns" from "each +/// filter costs 5 ns". Measured, it is entirely the former: the step is at k=1 +/// and filters 2..4 are free, so the errno partition costs nothing at runtime +/// even though an allowed syscall walks ~215 instructions across the stack. +/// Nothing but `k` varies between arms, which is why this exists instead of +/// comparing seals. +#[cfg(feature = "bpf-dump")] +fn filters(k: usize, n: u64, strict: bool) -> Result<(), String> { + let groups = oring::seccomp::dump_groups(strict).map_err(|e| format!("dump_groups: {e}"))?; + if k > groups.len() { + return Err(format!("k must be 0..={}", groups.len())); + } + if k > 0 { + oring::preexec::set_no_new_privs().map_err(|e| format!("NO_NEW_PRIVS: {e}"))?; + } + for g in groups.iter().take(k) { + apply_filter(&to_program(&g.insns)).map_err(|e| format!("install {}: {e}", g.name))?; + } + print!("filters={k} "); + syscalls(n) +} + +// --- the emitted filter, without a kernel ---------------------------------- + +/// How far an allowed syscall walks the emitted cBPF. +/// +/// A MODEL of the filter, not a timing: it needs no kernel and no root and +/// answers "how long is the scan" exactly, but 215 instructions and the +5 ns +/// `filters` measures do not reconcile arithmetically — the kernel JITs the +/// program and the real cost turns out to be seccomp entry. Use this to reason +/// about filter SHAPE and `filters` to reason about time. +/// +/// The miss path is what matters: a syscall oring does not block must fail every +/// comparison, so for allowed syscalls the common case is the worst case of a +/// linear scan and it grows with the denylist. +/// +/// Args are all zero, which is right for the miss path (no argument predicate +/// can match) and wrong for reading a hit's depth. The boundary is printed with +/// the result for that reason. Relative to `seccomp::eval_cbpf`, which covers +/// the 7-instruction x32 guard for the Kani proof, this interprets the wider +/// subset seccompiler emits. +#[cfg(feature = "bpf-dump")] +// A spread a real child actually makes, so the table is not just an aggregate. +const HOT: &[(&str, u32)] = &[ + ("read", 0), + ("write", 1), + ("close", 3), + ("mmap", 9), + ("mprotect", 10), + ("ioctl", 16), + ("clone", 56), + ("execve", 59), + ("futex", 202), + ("getdents64", 217), + ("clock_gettime", 228), + ("openat", 257), + ("newfstatat", 262), +]; + +#[cfg(feature = "bpf-dump")] +fn sim(strict: bool) -> Result<(), String> { + const SWEEP: u32 = 470; // x86_64 syscall table fits under this — printed as the frame. + let groups = oring::seccomp::dump_groups(strict).map_err(|e| format!("dump_groups: {e}"))?; + + println!( + "boundary: profile={}, {} filters, syscall nrs 0..{}, all six args = 0, arch = x86_64", + if strict { "strict" } else { "balanced" }, + groups.len(), + SWEEP - 1 + ); + let mut total = 0usize; + for g in &groups { + println!( + " {:<14} errno={:<3} syscalls={:>3} program={:>4} insns", + g.name, + g.errno, + g.nrs.len(), + g.insns.len() + ); + total += g.insns.len(); + } + println!( + " {:<14} {:>28} program={total:>4} insns across the stack", + "TOTAL", "" + ); + + let mut per = Vec::with_capacity(SWEEP as usize); + for nr in 0..SWEEP { + let mut sum = 0usize; + for g in &groups { + sum += eval(&g.insns, nr)?; + } + per.push(sum); + } + let sum: usize = per.iter().sum(); + println!( + "\nexecuted per syscall across all {} filters: min {} mean {:.1} max {}", + groups.len(), + per.iter().min().copied().unwrap_or(0), + sum as f64 / per.len() as f64, + per.iter().max().copied().unwrap_or(0) + ); + + println!("\n syscall nr executed (per filter)"); + for &(label, nr) in HOT { + let each: Result, String> = groups.iter().map(|g| eval(&g.insns, nr)).collect(); + let each = each?; + let parts: Vec = each.iter().map(ToString::to_string).collect(); + println!( + " {label:<16} {nr:<4} {:>8} ({})", + each.iter().sum::(), + parts.join("+") + ); + } + Ok(()) +} + +/// Interpret one cBPF program for syscall `nr` with zero args; returns the +/// number of instructions executed. Total by construction: any opcode +/// seccompiler does not emit is an error, never a silent count. +#[cfg(feature = "bpf-dump")] +fn eval(insns: &[(u16, u8, u8, u32)], nr: u32) -> Result { + const AUDIT_ARCH_X86_64: u32 = 0xc000_003e; + let mut mem = [0u32; 16]; + let (mut a, mut pc, mut steps) = (0u32, 0usize, 0usize); + while let Some(&(code, jt, jf, k)) = insns.get(pc) { + steps = steps.saturating_add(1); + let cls = code & 0x07; + match cls { + 0x00 => { + // LD + a = match code & 0xe0 { + 0x20 => match k { + // struct seccomp_data: nr, arch, instruction_pointer, args[6] + 0 => nr, + 4 => AUDIT_ARCH_X86_64, + _ => 0, + }, + 0x00 => k, + 0x60 => mem.get(k as usize).copied().unwrap_or(0), + m => return Err(format!("unhandled LD mode {m:#x} at {pc}")), + }; + pc = pc.saturating_add(1); + } + 0x02 => { + // ST + if let Some(slot) = mem.get_mut(k as usize) { + *slot = a; + } + pc = pc.saturating_add(1); + } + 0x04 => { + // ALU + match code & 0xf0 { + 0x50 => a &= k, + o => return Err(format!("unhandled ALU op {o:#x} at {pc}")), + } + pc = pc.saturating_add(1); + } + 0x05 => { + // JMP + let off = match code & 0xf0 { + 0x00 => usize::try_from(k).unwrap_or(0), + 0x10 => usize::from(if a == k { jt } else { jf }), + 0x20 => usize::from(if a > k { jt } else { jf }), + 0x30 => usize::from(if a >= k { jt } else { jf }), + 0x40 => usize::from(if a & k != 0 { jt } else { jf }), + o => return Err(format!("unhandled JMP op {o:#x} at {pc}")), + }; + pc = pc.saturating_add(1).saturating_add(off); + } + 0x06 => return Ok(steps), // RET + c => return Err(format!("unhandled class {c} at {pc}")), + } + } + // seccompiler never emits a fall-off, and the kernel would reject one. Fail + // loudly rather than return a count that looks like a measurement. + Err(format!("program fell off the end at pc={pc} without a RET")) +} + +// Without `bpf-dump` there is no way to reach the emitted programs, so the four +// ring-2 subcommands refuse rather than silently measuring nothing. `bench` is +// declared with `required-features = ["bench"]` and `bench` enables `bpf-dump`, +// so this arm is unreachable in a normal build — it exists so an explicit +// `--no-default-features` combination fails with a sentence instead of a +// compile error about a missing function. +#[cfg(not(feature = "bpf-dump"))] +fn install(_strict: bool) -> Result<(), String> { + Err("built without `bpf-dump`: the emitted filters are unreachable".to_string()) +} +#[cfg(not(feature = "bpf-dump"))] +fn filters(_k: usize, _n: u64, _strict: bool) -> Result<(), String> { + Err("built without `bpf-dump`: the emitted filters are unreachable".to_string()) +} +#[cfg(not(feature = "bpf-dump"))] +fn sim(_strict: bool) -> Result<(), String> { + Err("built without `bpf-dump`: the emitted filters are unreachable".to_string()) +} diff --git a/src/denials.rs b/src/denials.rs index d7888f7..dbf2976 100644 --- a/src/denials.rs +++ b/src/denials.rs @@ -215,21 +215,9 @@ pub fn collect(pid: u32, since: &str) -> Result { }) } -/// Escape control/escape bytes so an attacker-chosen denied path cannot inject -/// terminal escape sequences into the operator's TTY when `--denials` is read. -/// Printable text (including non-ASCII UTF-8 filenames) passes through unchanged. -fn sanitize(s: &str) -> String { - use std::fmt::Write as _; - let mut out = String::with_capacity(s.len()); - for c in s.chars() { - if c.is_control() { - let _ = write!(out, "\\x{:02x}", u32::from(c)); - } else { - out.push(c); - } - } - out -} +/// Escaping lives in `crate::diag` so there is ONE implementation. This module +/// had the only copy, and the other print sites had none (T26). +use crate::diag::safe as sanitize; /// Human-readable report. Groups by (rights, path) so a loop denied 400 times /// reads as one line with a count. diff --git a/src/diag.rs b/src/diag.rs new file mode 100644 index 0000000..89cd0e3 --- /dev/null +++ b/src/diag.rs @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: BSD-3-Clause +//! One sanitiser for every diagnostic that renders bytes oring did not choose. +//! +//! oring's diagnostics interpolate caller-controlled strings: policy paths, the +//! target name, `$TMPDIR`, an `io::Error` carrying a path. `src/denials.rs` +//! escaped them; **13 of ~14 other print sites did not**, and a forged `SEALED` +//! line was injected through argv alone under `--profile balanced`, with no +//! policy file (T26). The threat model does not license treating those bytes as +//! trusted — `ORING_POLICY` is ambient, and argv comes from whoever invoked +//! oring, which under the agent-wrapping story may be the thing being confined. +//! +//! The loss is `MISREPORT`, not `BREACH`: nothing here widens the cage, but a +//! terminal that honours escape sequences can be made to repaint a `REFUSE` as a +//! `SEALED`, and the operator acts on what they saw. +//! +//! **Does NOT** shorten, quote or validate. A path can still mislead by being +//! long, right-to-left, or a homoglyph of another path — none of which is a +//! control byte, and none of which is closed here. +/// Escape control characters so caller-supplied bytes cannot inject terminal +/// escape sequences into the operator's TTY. +/// +/// Printable text, including non-ASCII UTF-8 filenames, passes through +/// unchanged: a legitimate path with an accented character must remain readable, +/// or operators start ignoring the diagnostics that carry it. +#[must_use] +pub fn safe(s: &str) -> String { + use std::fmt::Write as _; + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + if c.is_control() { + // `\x1b` rather than the raw byte. Width is not padded beyond two + // digits because `char::is_control` admits U+0085 and U+2028, whose + // escapes are longer — truncating them would produce a DIFFERENT + // character rather than an escaped one. + let _ = write!(out, "\\x{:02x}", u32::from(c)); + } else { + out.push(c); + } + } + out +} + +/// `safe` over anything that renders — paths, errors, `OsStr` via `Display`. +#[must_use] +pub fn safe_display(d: &impl std::fmt::Display) -> String { + safe(&d.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn escape_sequences_do_not_survive() { + // The exact shape that forged a SEALED line: erase the real line, print + // a fake one. + let forged = "\x1b[2K\rooring: SEALED v9.9.9 grants=999/999"; + let out = safe(forged); + assert!(!out.contains('\x1b'), "ESC survived: {out:?}"); + assert!(!out.contains('\r'), "CR survived: {out:?}"); + assert!(out.contains("\\x1b") && out.contains("\\x0d"), "{out:?}"); + } + + #[test] + fn ordinary_paths_are_untouched() { + for s in [ + "/home/u/.cargo/registry", + "/work/repo/café/naïve.rs", + "/tmp/oring-1234", + ] { + assert_eq!(safe(s), s, "a legitimate path was altered"); + } + } + + #[test] + fn newlines_cannot_forge_a_second_line() { + // A path containing a newline could otherwise emit what looks like an + // additional, independent diagnostic line. + let out = safe("/tmp/x\noring: SEALED forged"); + assert!(!out.contains('\n'), "newline survived: {out:?}"); + } +} diff --git a/src/error.rs b/src/error.rs index 80d310a..e70bba6 100644 --- a/src/error.rs +++ b/src/error.rs @@ -63,14 +63,23 @@ impl OringError { } } +/// **The chokepoint (T26).** Every refusal message reaches the operator through +/// this impl, and every one of them interpolates something the caller chose — a +/// policy path, the target name, an `io::Error` carrying a path. Sanitising here +/// covers all four variants and every future call site by construction, rather +/// than relying on each `OringError::policy(format!(...))` author to remember. +/// +/// Escaping at RENDER rather than at CONSTRUCTION is deliberate: a message is +/// built once and may be matched on by tests or callers, and only the terminal +/// needs the escaped form. `is_control` is a no-op on every legitimate path, so +/// nothing readable changes. impl fmt::Display for OringError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - OringError::Refuse { msg, .. } => write!(f, "{msg}"), - OringError::Policy(m) => write!(f, "{m}"), - OringError::Target(m) => write!(f, "{m}"), - OringError::Usage(m) => write!(f, "{m}"), - } + let msg = match self { + OringError::Refuse { msg, .. } => msg, + OringError::Policy(m) | OringError::Target(m) | OringError::Usage(m) => m, + }; + write!(f, "{}", crate::diag::safe(msg)) } } diff --git a/src/landlock.rs b/src/landlock.rs index 1919769..a7ce61a 100644 --- a/src/landlock.rs +++ b/src/landlock.rs @@ -6,12 +6,18 @@ //! `AccessFs` vocabulary against a `(writable, executable)` policy + node //! kind (see `Tier`/`RIGHTS`), so device-node creation and W^X are //! structural properties, not hand-maintained unions. -//! - hard V7/6.15 floor: refuse below it. RESOLVE_UNIX (V9, pathname-AF_UNIX -//! connect) is additive hardening applied best-effort with `--features -//! resolve-unix` on a ≥V9 kernel; when inactive it is a loudly-reported -//! residual, not a silent gap. Above the crate ceiling the newer right -//! (V10 UDP) is UNHANDLED — oring warns (abi-ahead), never implies full -//! coverage (CALIF F1) +//! - hard V10/7.2 floor: refuse below it. RESOLVE_UNIX (V9, pathname-AF_UNIX +//! connect) is IN the floor — Landlock has no hook on that connect below V9, +//! so beneath the floor the docker.sock/ssh-agent class is unmediated by +//! ring 1 and no FS tier can gate it. +//! - coverage is disclosed in TWO halves and neither is complete alone: rights +//! the crate cannot NAME (above its ceiling) get the abi-ahead warn, and +//! rights it names but this run did not HANDLE are derived by set difference +//! against `from_all(abi)` and reported as `unhandled=` + a partial-class +//! warn. Keying the whole disclosure on the first half is what let a V10 cap +//! bump silence it while UDP stayed open (CALIF F1) +//! - V10 UDP rights (`egress_udp`/`ingress_udp`) are opt-in and REFUSED below +//! V10 — a dropped UDP handle is an open egress path, not a missing grant //! - net HARD-required so a deny-all can never silently become allow-all //! - manual IgnoreIfMissing: ENOENT → skip, any other error → fail closed @@ -19,47 +25,242 @@ use std::io; use std::path::{Path, PathBuf}; use landlock::{ - Access, AccessFs, AccessNet, CompatLevel, Compatible, NetPort, PathBeneath, PathFd, + Access, AccessFs, AccessNet, BitFlags, CompatLevel, Compatible, NetPort, PathBeneath, PathFd, PathFdError, Ruleset, RulesetAttr, RulesetCreatedAttr, RulesetStatus, Scope, ABI, }; use crate::error::{OringError, Result}; use crate::policy::Policy; -/// Hard Landlock ABI floor: **V7 (kernel 6.15)** — the minimum for a coherent -/// cage: FS tiers + Refer/Truncate, TCP net (V4), IoctlDev (V5), IPC scope (V6) -/// and audit (V7) are all guaranteed at V7. Below it oring refuses. +/// Hard Landlock ABI floor: **V10 (kernel 7.2)** — the minimum for a coherent +/// cage. FS tiers + Refer/Truncate, TCP net (V4), IoctlDev (V5), IPC scope (V6), +/// audit (V7), `RESOLVE_UNIX` (V9) and the UDP net rights (V10) are all +/// guaranteed at V10. Below it oring refuses. /// -/// V9's `RESOLVE_UNIX` — the right that walls `connect()` to a pathname AF_UNIX -/// socket (docker.sock / ssh-agent) — is *additive hardening*, not part of the -/// floor: it is applied best-effort when the `resolve-unix` feature is built AND -/// the kernel is ≥ V9 (7.1). When it is not active, pathname-AF_UNIX connect is -/// an UNMEDIATED residual — a granted `/tmp` exposes the operator's ssh-agent — -/// which oring reports loudly at seal and warns about at policy time -/// (`warn_on_sockets`). This is a deliberate degradation, kept fail-safe: the -/// missing right only ever removes a *restriction*, never widens a grant, and its -/// absence is never silent. -pub const FLOOR_ABI: i32 = 7; - -/// Highest ABI the linked `landlock` crate can NAME/handle. The published crate -/// reaches **V7**; the `resolve-unix` feature (built against a V9-capable fork) -/// reaches **V9**. A running kernel above this exposes restrictable rights the -/// crate cannot emit — those classes are UNHANDLED (= unrestricted) even though +/// V9 is in the floor because of `RESOLVE_UNIX` — the right that walls +/// `connect()` to a pathname AF_UNIX socket (docker.sock / ssh-agent). Landlock +/// has **no hook on AF_UNIX `connect()` below V9**, so beneath this floor that +/// path is unmediated by ring 1 no matter what the FS tiers say: a granted `/tmp` +/// exposes the operator's ssh-agent, and withholding the grant only obscures the +/// socket rather than gating it (D-4b). Through v0.1 this was a +/// loudly-reported residual behind an opt-in `resolve-unix` feature — that was +/// the degraded-path-with-a-warning pattern [D-4] rejects everywhere else, so +/// v0.2 folds it into the floor: oring refuses rather than run the partial cage. +/// +/// **RAISED TO V10 / kernel 7.2 on 2026-07-29.** V9's `RESOLVE_UNIX` reasoning above still +/// holds and is now *below* the floor, so it is guaranteed rather than argued. What V10 +/// adds is **UDP**: `LANDLOCK_ACCESS_NET_{BIND,CONNECT_SEND}_UDP`. Below V10 oring cannot +/// mediate UDP at all, so `egress_tcp: []` could not mean "no egress" and DNS/UDP exfil was +/// a residual the built-in profiles had to carry — the degraded-path-with-a-warning pattern +/// [D-4] rejects everywhere else, and the argument that folded V9 in applies unchanged. +/// +/// Cost, stated plainly and larger than the V9 raise: **every kernel below 7.2 is +/// refused**, which at the time of the decision is every released stable kernel. oring +/// supports 7.2.0-rc4 and later, deliberately. The crate comes from the out-of-tree patch +/// until V10 lands in a published `landlock` release — and note V9 *has* landed upstream +/// (0.4.7), so the patch now carries **only** V10, a smaller delta than the V9 fork was. +/// +/// **What this retires.** The ring-2 `MSG_FASTOPEN` rule stops being load-bearing: +/// mainline `33cb713db016` fixed that Landlock gap in 7.2, so at this floor ring 1 gates +/// the route itself — measured 2026-07-29, dropping the ring-2 rule on 7.2.0-rc4 still +/// yields EACCES. The rule stays as defence in depth; what changes is that it is no longer +/// the only thing between a policy and an open port. +pub const FLOOR_ABI: i32 = 10; + +/// The crate ceiling as an ABI **variant** — the single place to edit when the +/// vendored crate learns a new ABI. +/// +/// Everything else about the ceiling is derived from this: [`CRATE_MAX_ABI`] is +/// `CEILING as i32`, and [`abi_from_version`] clamps to `CEILING`. Before, the +/// integer and the clamp target were two independent encodings coupled only by +/// a Kani proof and a unit test — editing one alone compiled fine and produced a +/// binary that reported `abi=V10>handledV9` while handling V10 rights. Now that +/// desynchronisation is not expressible. +/// +/// This works because the crate's `ABI` discriminants ARE the version numbers +/// (`Unsupported = 0`, `V1 = 1`, … `V10 = 10`, `compat.rs`), so `as i32` is the +/// exact inverse of the naming — asserted below rather than assumed. +#[cfg(feature = "resolve-unix")] +const CEILING: ABI = ABI::V10; +#[cfg(not(feature = "resolve-unix"))] +const CEILING: ABI = ABI::V7; + +/// Highest ABI the linked `landlock` crate can NAME/handle — **derived from +/// [`CEILING`]**, which the vendored fork behind the `resolve-unix` feature +/// provides. A running kernel above this exposes restrictable rights the crate +/// cannot emit — those classes are UNHANDLED (= unrestricted) even though /// `restrict_self` reports FullyEnforced (which certifies only the *requested* /// subset). oring reports the shortfall (abi-ahead) rather than implying full /// coverage. +pub const CRATE_MAX_ABI: i32 = CEILING as i32; + +// The discriminants-are-version-numbers property this file now leans on, pinned +// at COMPILE time against the vendored crate. If a future bump renumbers them, +// this fails to build rather than silently skewing every ceiling comparison. +const _: () = { + assert!(ABI::Unsupported as i32 == 0); + assert!(ABI::V1 as i32 == 1); + assert!(ABI::V7 as i32 == 7); +}; #[cfg(feature = "resolve-unix")] -pub const CRATE_MAX_ABI: i32 = 9; +const _: () = { + assert!(ABI::V9 as i32 == 9); + assert!(ABI::V10 as i32 == 10); +}; + +/// Mainline kernel version that first carries each Landlock ABI. +/// +/// Exists because the kernel version was written out by hand wherever an operator +/// had to be told what to upgrade to, and those literals drifted the moment the +/// floor moved: `--check` said "need kernel 7.1+" while refusing anything below +/// V10, and two skip messages in `tests/common` told the operator a cell "needs +/// kernel 7.1" for the same reason. A number the code does not derive is a number +/// that goes stale silently, because nothing recomputes it. +/// `""` for an ABI with no recorded version — callers must treat that as unknown +/// rather than as a version, which is what [`FLOOR_KERNEL`] enforces at compile +/// time. A `match` and not a table because `clippy::indexing_slicing` is denied +/// crate-wide and a `const fn` cannot iterate. +const fn kernel_for(abi: i32) -> &'static str { + match abi { + 7 => "6.12", + 8 => "6.15", + 9 => "7.1", + 10 => "7.2", + _ => "", + } +} + +/// Kernel version an operator needs for [`FLOOR_ABI`], e.g. `"7.2"`. +/// +/// Resolved in a `const fn` so a floor with no mapped kernel version is a +/// **compile error**, not a runtime panic in the one code path that only executes +/// on a machine too old to run the tests. Raising `FLOOR_ABI` without adding its +/// kernel here cannot ship. +pub const FLOOR_KERNEL: &str = { + let k = kernel_for(FLOOR_ABI); + assert!( + !k.is_empty(), + "FLOOR_ABI has no mapped kernel version — add an arm to kernel_for" + ); + k +}; + +/// ABI that first names `LANDLOCK_ACCESS_NET_{BIND,CONNECT_SEND}_UDP` — the +/// gate for `egress_udp`/`ingress_udp`. +/// +/// Public and named rather than an inline `10` for the same reason +/// `tests/common` reads [`FLOOR_ABI`] instead of hardcoding `9`: a literal +/// duplicated between the enforcement gate and the test that covers it can drift +/// apart, and the drift shows up as a test that quietly stops exercising the +/// gate rather than as a failure. +pub const UDP_ABI: i32 = 10; + +// The UDP rights are GUARANTEED BY THE FLOOR since 2026-07-29, pinned at compile time +// rather than left as a coincidence of two integers. It is what lets the UDP cells be +// unconditional: below V10 oring refuses to seal at all, so there is no kernel oring +// accepts on which `egress_udp` cannot be named. If a future floor drop makes this false +// the build fails here, instead of the UDP suites silently reverting to skip-and-count. +const _: () = assert!(UDP_ABI <= FLOOR_ABI); + +/// ABI that first names `AccessFs::ResolveUnix` — the pathname-AF_UNIX `connect()` wall. +/// +/// Named rather than left as a `9` inside a test, because the floor's relationship to it is +/// a property of the product: under a V9 floor the two were the SAME number and the pairing +/// was pinned by equality. At a V10 floor the wall is guaranteed with a MARGIN, so the +/// property to hold is an inequality — and it belongs at compile time beside the UDP one, +/// not as a runtime assertion over two constants (which is a check that cannot fail, and +/// clippy says so). +pub const RESOLVE_UNIX_ABI: i32 = 9; + +// The floor GUARANTEES the pathname-AF_UNIX wall rather than coinciding with it. A floor +// drop below V9 fails the build here — which is the residual the floor exists to delete. +const _: () = assert!(RESOLVE_UNIX_ABI <= FLOOR_ABI); + +// A V9 floor cannot be met by a crate that tops out at V7: `abi_from_version` +// could never name V9, so every run would refuse at the floor gate. Fail closed +// at COMPILE time with a legible message instead of shipping a binary that +// always refuses. #[cfg(not(feature = "resolve-unix"))] -pub const CRATE_MAX_ABI: i32 = 7; +compile_error!( + "oring requires the `resolve-unix` feature: the V10 floor (FLOOR_ABI = 10) needs a \ + landlock crate that names ABI V10 + the UDP net rights, which the published \ + crate does not yet provide. Build with `--features resolve-unix` and a V9 crate \ + override (see .cargo/config.toml.example). When upstream ships V9 in a release, \ + drop the feature and depend on it directly." +); /// True when the RESOLVE_UNIX pathname-socket wall is compiled in *and* the -/// running kernel provides it. When false, pathname-AF_UNIX connect is the -/// loudly-reported residual (see [`FLOOR_ABI`]). +/// running kernel provides it. At the V10 floor this is true for every kernel +/// oring agrees to run on — it is kept as an explicit predicate so the wall is +/// asserted from the *detected* ABI rather than assumed from the floor constant. pub fn resolve_unix_active(abi_ver: i32) -> bool { cfg!(feature = "resolve-unix") && abi_ver >= 9 } +/// One published Landlock erratum oring cares about: a behavioural fix the +/// running kernel either carries or does not, independent of its ABI integer. +struct ErratumInfo { + /// 1-based, as the kernel numbers them. See [`erratum_applied`] for the + /// bit mapping — do NOT open-code it. + number: u8, + /// ABI level the erratum was declared against. + abi: i32, + summary: &'static str, +} + +/// Errata introduced at or below [`FLOOR_ABI`], i.e. every one a kernel meeting +/// oring's floor is expected to carry. Kept explicit rather than derived: a +/// future erratum on a newer ABI must be *added deliberately*, so oring never +/// warns about a fix it has not reasoned about. +/// +/// Erratum 3 is the load-bearing one — without it, rename/link across +/// disconnected directories can widen access rights past `LANDLOCK_ACCESS_FS_REFER`, +/// and oring grants `Refer` in **every** tier (§4). +const FLOOR_ERRATA: &[ErratumInfo] = &[ + ErratumInfo { + number: 1, + abi: 4, + summary: "TCP rights no longer restrict non-TCP stream sockets (SMC/MPTCP/SCTP)", + }, + ErratumInfo { + number: 2, + abi: 6, + summary: "signal scoping no longer blocks same-process cross-domain signals", + }, + ErratumInfo { + number: 3, + abi: 1, + summary: "disconnected-directory rename/link can no longer widen rights past FS_REFER", + }, +]; + +/// Is erratum `number` present in `mask`? +/// +/// The kernel sets bit `number - 1`: `landlock_errata |= BIT(number - 1)` in +/// `security/landlock/setup.c`, and `Documentation/userspace-api/landlock.rst` +/// gives the userspace form as `errata & (1 << (N - 1))`. +/// +/// **One definition, deliberately.** This mapping was open-coded at two call +/// sites when the errata support first landed; either could have drifted, and +/// neither was reachable by a test. It is also why the arithmetic is saturating +/// and the shift checked rather than `1u64 << (number - 1)`: the crate denies +/// `clippy::arithmetic_side_effects`, and a `0` number would underflow `u8` into +/// a 255-bit shift — a panic in a wrapper whose whole contract is failing +/// closed without aborting. +fn erratum_applied(mask: u64, number: u8) -> bool { + let bit = u32::from(number).saturating_sub(1); + 1u64.checked_shl(bit).is_some_and(|b| mask & b != 0) +} + +/// Render the errata bitmask for the seal line: `0x7`, or `?` when the kernel +/// did not answer. Never blank — an absent probe must be visible, not implied. +fn describe_errata(errata: Option) -> String { + match errata { + Some(mask) => format!("0x{mask:x}"), + None => "?".to_string(), + } +} + /// Ground-truth of which rings actually engaged — feeds the seal line (§12). #[derive(Debug)] pub struct RingReport { @@ -68,9 +269,26 @@ pub struct RingReport { pub scope: &'static str, pub audit: &'static str, pub abi: String, + /// Which **declared** Landlock errata the running kernel carries (`0x7`), or + /// `?` if it did not answer. Reported because the ABI integer alone does not + /// establish which fixes are present. It is a LOWER BOUND: behaviour changes + /// ship without errata (see [`crate::sys::landlock_errata`]), so a full mask + /// does not certify documented behaviour. + pub errata: String, /// `granted/intended` FS paths. Enforcement status says the cage is on; /// this says whether it is the cage you asked for. pub grants: String, + /// Restrictable rights the crate can NAME at this kernel's ABI that this run + /// did not handle — i.e. left unrestricted — or `-` for none. Derived by set + /// difference (see [`unhandled_rights`]), so it cannot go stale as the crate + /// or the policy schema grows. + /// + /// This is the field that makes under-coverage attestable rather than + /// merely warned about: `fs=enforced` says the cage is on and `net=` says + /// which directions were asked for, but neither could express "and these + /// specific rights were left open", which is exactly what a reader auditing + /// a seal line after the fact needs to know. + pub unhandled: String, } // FS access tiers, derived — not enumerated (§4). @@ -208,17 +426,18 @@ impl Tier { /// True when a detected ABI is below the hard floor — oring must refuse. Pure so /// the fail-closed boundary is unit-testable on any kernel (the real `apply` only -/// hits the refuse path below kernel 6.15, which the test host is not). +/// hits the refuse path below kernel 7.1, which the test host may not be). fn below_floor(abi_ver: i32) -> bool { abi_ver < FLOOR_ABI } -/// Apply ring 1. Hard floor is Landlock **V7 (kernel 6.15)**: FS/Refer/Truncate -/// (V1-3), TCP net (V4), IoctlDev (V5), IPC scope (V6) and audit (V7) are all -/// guaranteed there. RESOLVE_UNIX (V9) is additive hardening applied best-effort -/// when the `resolve-unix` feature + a ≥V9 kernel are present; otherwise its -/// absence is a loudly-reported residual (see [`FLOOR_ABI`]). Below V7 oring -/// refuses cleanly rather than run a partial cage. +/// Apply ring 1. Hard floor is Landlock **V10 (kernel 7.2)**: FS/Refer/Truncate +/// (V1-3), TCP net (V4), IoctlDev (V5), IPC scope (V6), audit (V7), +/// `RESOLVE_UNIX` (V9) and the UDP net rights (V10) are all guaranteed there. +/// Below the floor oring refuses cleanly rather than run a partial cage — +/// pathname-AF_UNIX `connect()` has no Landlock hook at all beneath V9, so a +/// sub-floor run would leave the docker.sock/ssh-agent class unmediated by +/// ring 1 (see [`FLOOR_ABI`]). pub fn apply(policy: &Policy) -> Result { let abi_ver = detect_abi_version(); let abi_dbg = format!("V{abi_ver}"); @@ -227,87 +446,250 @@ pub fn apply(policy: &Policy) -> Result { return Err(OringError::refuse( "landlock-abi", format!( - "Landlock ABI {abi_dbg} < V{FLOOR_ABI} (need kernel 6.15+); refusing — \ - oring requires the coherent Landlock set (FS+Refer+Truncate+net+\ - IoctlDev+scope+audit), all guaranteed at V7" + "Landlock ABI {abi_dbg} < V{FLOOR_ABI} (need kernel {FLOOR_KERNEL}+); \ + refusing — oring requires the coherent Landlock set (FS+Refer+\ + Truncate+net+IoctlDev+scope+audit+ResolveUnix+UDP), all guaranteed \ + at V{FLOOR_ABI}. Below V9 Landlock has no hook on pathname-AF_UNIX \ + connect(), so docker.sock and ssh-agent would be unmediated by ring \ + 1 and no FS tier could gate them; below V10 it cannot restrict UDP, \ + so DNS egress would be unmediated too" ), )); } + // ERRATA disclosure. The ABI integer describes the INTERFACE; the errata + // bitmask reports which DECLARED fixes the kernel carries, and the two can + // disagree because vendor kernels are assembled by backporting — which is + // the whole reason the kernel exposes this. It is a LOWER BOUND on + // behavioural difference: `MSG_FASTOPEN` mediation landed with no erratum + // (see `sys::landlock_errata`), so `0x7` means "carries the three declared + // fixes", not "behaves as documented". The floor above checks + // only the version, so without this a kernel could satisfy V9 while missing + // a fix a granted right depends on, and oring would report FullyEnforced. + // + // WARN, do not refuse. Every erratum below predates the floor and no host + // has yet been shown that meets V9 without them, so refusing would reject a + // kernel nobody has demonstrated exists — the same asymmetry the abi-ahead + // path reasons from. An unavailable probe is reported, never inferred from. + let errata = crate::sys::landlock_errata(); + let errata_report = describe_errata(errata); + if let Some(mask) = errata { + let missing: Vec<&ErratumInfo> = FLOOR_ERRATA + .iter() + .filter(|e| !erratum_applied(mask, e.number)) + .collect(); + if !missing.is_empty() { + for e in &missing { + eprintln!( + "oring: WARN kernel Landlock reports ABI {abi_dbg} but NOT erratum {} \ + (introduced at ABI V{}): {}. The ABI version is satisfied; this fix \ + is not, so the affected behaviour may differ from what oring's \ + policy assumes.", + e.number, e.abi, e.summary + ); + } + } + } + // Detected may exceed the crate ceiling; clamp to what the crate can name. let abi = abi_from_version(abi_ver); - // RESOLVE_UNIX residual: when the pathname-AF_UNIX wall is NOT active (feature - // off, or kernel < V9), report it loudly. It is an additive hardening whose - // absence removes only a restriction — but that restriction matters: a granted - // /tmp exposes the operator's ssh-agent. Never let this be silent (§2). - if !resolve_unix_active(abi_ver) { - let why = if cfg!(feature = "resolve-unix") { - format!("kernel Landlock {abi_dbg} < V9") - } else { - "built without the `resolve-unix` feature".to_string() - }; - eprintln!( - "oring: RESIDUAL pathname-AF_UNIX connect() is UNMEDIATED ({why}) — a granted \ - /tmp exposes the operator's ssh-agent (`/tmp/ssh-*/agent.*`). To close it, \ - build --features resolve-unix on a kernel 7.1+ (ABI V9). Abstract sockets are \ - still walled by IPC scope." - ); - } + // The pathname-AF_UNIX wall is floor-guaranteed from v0.2: below_floor() has + // already refused anything under V9, and the linked crate is V9-capable by the + // compile_error! on `resolve-unix`. Assert it from the DETECTED abi rather than + // inferring it from the floor constant, so a later floor edit cannot silently + // un-wall this class (§2 — the residual this replaced). + debug_assert!( + resolve_unix_active(abi_ver), + "post-floor invariant: RESOLVE_UNIX must be active at {abi_dbg}" + ); - // ABI-AHEAD disclosure (CALIF F1): a kernel above the crate's V7 ceiling has - // restrictable rights oring cannot emit, so those op classes are UNHANDLED - // (unrestricted) while restrict_self still reports FullyEnforced. Warn loudly - // — do NOT refuse (every current deployment kernel is ABI 8/9, so refusing - // would brick oring everywhere). This makes the silent under-coverage honest. + // ABI-AHEAD (CALIF F1), the CRATE-LIMITED half of the coverage disclosure: + // a kernel above the crate's ceiling has restrictable rights oring cannot + // emit, so those op classes are UNHANDLED (unrestricted) while restrict_self + // still reports FullyEnforced. Warn loudly — do NOT refuse: refusing above + // the ceiling would brick oring on every kernel newer than the crate, which + // is the opposite of the floor's logic (the floor refuses when a right oring + // NEEDS is absent; here the shortfall is a right the crate cannot NAME). + // + // This predicate is CORRECT for its half and WRONG as a total answer — the + // distinction that cost a measurement. `abi_ver > CRATE_MAX_ABI` detects + // rights the crate cannot NAME. It says nothing about rights the crate names + // and oring does not HANDLE, and those two sets stopped coinciding the moment + // the crate learned V10 while the policy schema had no UDP field. Raising the + // cap then SILENCED this warning without restricting one extra byte. The + // named-gap half below closes that; both are reported together. let abi_ahead = abi_ver > CRATE_MAX_ABI; if abi_ahead { eprintln!( "oring: WARN kernel Landlock ABI {abi_dbg} > handled V{CRATE_MAX_ABI} (linked crate cap) \ — rights the crate cannot name are UNHANDLED (unrestricted) above V{CRATE_MAX_ABI}. \ Crate-limited, not structural; FullyEnforced certifies only the V{CRATE_MAX_ABI} subset. \ - Build --features resolve-unix (V9 crate) to raise the cap." + Bump the linked landlock crate to raise the cap." ); } let ll = &policy.landlock; // --- Build the ruleset ------------------------------------------------- + // Every `handle_access` below ALSO records into a `handled_*` set. Those + // sets are the input to `coverage()` — the disclosure is computed from what + // was actually handled, never restated by hand. See [`Coverage`]. let mut rs = Ruleset::default(); rs = rs.set_compatibility(CompatLevel::BestEffort); // FS core: handle EVERY fs right up to detected ABI. Rights we never grant // in any tier (MakeChar/MakeBlock) become handled-but-denied everywhere. + let handled_fs = AccessFs::from_all(abi); rs = rs - .handle_access(AccessFs::from_all(abi)) + .handle_access(handled_fs) .map_err(|e| OringError::refuse("landlock-fs", format!("handle fs access: {e}")))?; - // Net (V4+, guaranteed at the V7 floor): per-direction, HARD requirement so + // Net (V4+, guaranteed at the V10 floor): per-direction, HARD requirement so // a deny-all can never silently become allow-all. + // UDP is V10-only. Refuse rather than degrade: a dropped TCP grant is a + // missing permission the child notices, but a dropped UDP handle is an OPEN + // EGRESS PATH the operator does not notice. Gate BEFORE building the ruleset + // so the refusal names the kernel, not a downstream handle_access error. + let udp_requested = policy.udp_requested(); + if udp_requested && abi_ver < UDP_ABI { + return Err(OringError::refuse( + "landlock-udp-abi", + format!( + "policy sets egress_udp/ingress_udp but kernel Landlock is {abi_dbg} — \ + UDP rights require V{UDP_ABI}. Refusing rather than sealing with UDP egress \ + silently unrestricted; drop the field or run a V{UDP_ABI} kernel." + ), + )); + } + let mut net_report = "off"; - if policy.net_requested() { + let mut handled_net = BitFlags::::EMPTY; + if policy.net_requested() || udp_requested { rs = rs.set_compatibility(CompatLevel::HardRequirement); - if ll.egress_tcp.is_some() { - rs = rs.handle_access(AccessNet::ConnectTcp).map_err(|e| { - OringError::refuse("landlock-net", format!("handle egress_tcp: {e}")) - })?; - } - if ll.ingress_tcp.is_some() { - rs = rs.handle_access(AccessNet::BindTcp).map_err(|e| { - OringError::refuse("landlock-net", format!("handle ingress_tcp: {e}")) - })?; + // Each arm records what it handled. The pairing (policy field → access + // right) lives HERE and nowhere else: `coverage()` derives the gap by + // set difference, so adding a right without a field — or a field + // without a right — shows up as an unhandled class instead of a silent + // hole. This is the whole fix; see the `Coverage` doc comment. + for (requested, right) in [ + (ll.egress_tcp.is_some(), AccessNet::ConnectTcp), + (ll.ingress_tcp.is_some(), AccessNet::BindTcp), + (ll.egress_udp.is_some(), AccessNet::ConnectSendUdp), + (ll.ingress_udp.is_some(), AccessNet::BindUdp), + ] { + if requested { + rs = rs.handle_access(right).map_err(|e| { + OringError::refuse("landlock-net", format!("handle {right:?}: {e}")) + })?; + handled_net |= right; + } } rs = rs.set_compatibility(CompatLevel::BestEffort); - net_report = "on"; + // Total by construction — no `unreachable!` (denied lib-wide, and a + // panic in the sealing path is the wrong failure anyway). The + // (false,false) arm is dead under the enclosing `if`, and reports the + // truth rather than a lie if that guard ever changes. + net_report = match (policy.net_requested(), udp_requested) { + (true, true) => "tcp+udp-ports", + (true, false) => "tcp-ports", + (false, true) => "udp-ports", + (false, false) => "off", + }; } - // Scope (IPC, V6+, guaranteed at the V7 floor). + // Scope (IPC, V6+, guaranteed at the V10 floor). let mut scope_report = "off"; + let mut handled_scope = BitFlags::::EMPTY; if ll.scope_ipc { + handled_scope = Scope::from_all(abi); rs = rs - .scope(Scope::from_all(abi)) + .scope(handled_scope) .map_err(|e| OringError::refuse("landlock-scope", format!("handle scope: {e}")))?; scope_report = "on"; } + // COVERAGE disclosure, the named half. Computed from the handled sets built + // above against the crate's own `from_all(abi)` enumeration — see + // [`unhandled_rights`]. + let unhandled = unhandled_rights(abi, handled_fs, handled_net, handled_scope); + let unhandled_report = if unhandled.is_empty() { + "-".to_string() + } else { + unhandled.join(",") + }; + // Hard gate (Type-II defense), mirroring `--require-all-grants` above: the + // operator asked for coverage to be ESTABLISHED, so a right left unhandled is a + // failure rather than a warning. Refuse BEFORE sealing completes — never run the + // target under a cage whose gaps were only ever announced on a channel the + // caller can discard. + // + // This is the "refused before execve" limb of CON-5. The other limb — disclosure + // — is a stderr line written with `let _ = writeln!`, and `2>/dev/null` removes + // it while the write SUCCEEDS, so nothing inside oring can detect that case. An + // exit code cannot be redirected away, which is the whole point of the flag. + if policy.require_full_coverage && !unhandled.is_empty() { + return Err(OringError::refuse( + "coverage-incomplete", + format!( + "--require-full-coverage: {} restrictable right(s) left UNHANDLED at abi=V{abi}: {}. Set the missing policy field(s), or drop the flag to seal with the gap disclosed on the seal line instead.", + unhandled.len(), + unhandled.join(", ") + ), + )); + } + + // WARN only on a PARTIAL restriction: the class was restricted at all, and + // some of its rights were left open. That is the shape that misleads — a + // policy naming `egress_tcp` reads as "the network is restricted", and on a + // V10 kernel it silently was not. A class left ENTIRELY unhandled is not + // warned about: `net=off` / `scope=off` already say so on the seal line, and + // warning on every unrestricted class would bury this one in noise. + // + // The line between the two is intent vs omission: `scope_ipc: false` is a + // value the operator affirmatively wrote, while an absent `egress_udp` + // beside a present `egress_tcp` is a field they never saw. Omission is what + // gets flagged. + if !handled_net.is_empty() { + let net_gap: Vec<&str> = unhandled + .iter() + .filter(|r| r.starts_with("net.")) + .map(String::as_str) + .collect(); + if !net_gap.is_empty() { + eprintln!( + "oring: WARN net is PARTIALLY restricted — {} handled, but {} UNHANDLED \ + (unrestricted) at {abi_dbg}. A policy that names some net fields and not \ + others restricts only what it names; set the missing field(s) to close this, \ + or state the gap deliberately.", + handled_net.iter().count(), + net_gap.join(" ") + ); + } + } + + // THE AUTOBIND FOOTGUN, warned about by name because it is invisible + // everywhere else. `sendto` on an unbound UDP socket acquires a source port + // via kernel autobind, and the kernel checks that against a synthetic + // **port 0** (`security/landlock/net.c`). So handling BindUdp without + // granting port 0 denies the autobind — and with it every datagram the + // `egress_udp` grant appears to permit. A policy reading "allow DNS out, + // allow no inbound UDP" therefore denies DNS. + // + // This warns rather than refuses: `ingress_udp: []` beside `egress_udp: []` + // is a coherent deny-everything posture, and only the combination with a + // NON-EMPTY egress grant is self-defeating. `unhandled=` cannot show this — + // both rights ARE handled, which is exactly why it needs its own warning. + if let (Some(eg), Some(ing)) = (&ll.egress_udp, &ll.ingress_udp) { + if !eg.is_empty() && !ing.contains(&0) { + eprintln!( + "oring: WARN egress_udp grants {} port(s) but ingress_udp does not include \ + port 0 — kernel autobind of the source port will be DENIED, so those \ + datagrams cannot be sent and the egress grant is inert. Add 0 to \ + ingress_udp to permit ephemeral autobind.", + eg.len() + ); + } + } + let mut created = rs .create() .map_err(|e| OringError::refuse("landlock-create", format!("create ruleset: {e}")))?; @@ -358,7 +740,7 @@ pub fn apply(policy: &Policy) -> Result { "--require-all-grants: {} of {intended} intended path(s) could not be granted \ (missing/unopenable): {}", list.len(), - list.join(", ") + crate::diag::safe(&list.join(", ")) ), )); } @@ -367,7 +749,7 @@ pub fn apply(policy: &Policy) -> Result { "oring: WARN skipped {} path(s) named by your policy but missing/unopenable — \ check for typos; the child gets NO access to them: {}", list.len(), - list.join(", ") + crate::diag::safe(&list.join(", ")) ); } else { eprintln!( @@ -378,8 +760,11 @@ pub fn apply(policy: &Policy) -> Result { } } - // Net port rules (only when a net direction was handled). - if net_report == "on" { + // Net port rules (only when a net direction was handled). Keyed on + // net_report != "off" rather than a string equality against one variant — + // the old `== "tcp-ports"` form would have silently dropped every rule the + // moment a second variant existed. + if net_report != "off" { if let Some(ports) = &ll.egress_tcp { for &p in ports { created = created @@ -398,6 +783,24 @@ pub fn apply(policy: &Policy) -> Result { })?; } } + if let Some(ports) = &ll.egress_udp { + for &p in ports { + created = created + .add_rule(NetPort::new(p, AccessNet::ConnectSendUdp)) + .map_err(|e| { + OringError::refuse("landlock-net", format!("udp connect rule :{p}: {e}")) + })?; + } + } + if let Some(ports) = &ll.ingress_udp { + for &p in ports { + created = created + .add_rule(NetPort::new(p, AccessNet::BindUdp)) + .map_err(|e| { + OringError::refuse("landlock-net", format!("udp bind rule :{p}: {e}")) + })?; + } + } } // Enforce. @@ -443,9 +846,11 @@ pub fn apply(policy: &Policy) -> Result { scope: scope_report, audit, abi: abi_report, + errata: errata_report, // Completeness, always attested: "granted/intended". 14/14 means the // sandbox is what you asked for; 10/14 means it is silently narrower. grants: format!("{}/{intended}", intended.saturating_sub(skipped.len())), + unhandled: unhandled_report, }) } @@ -548,6 +953,70 @@ pub fn detected_abi() -> i32 { detect_abi_version() } +/// Detected errata for `--explain`: the bitmask, then each floor-level erratum +/// as present or MISSING. Spelled out rather than left as hex, because the +/// point of showing it is that a reader can act on it. +pub fn detected_errata_string() -> String { + let errata = crate::sys::landlock_errata(); + let Some(mask) = errata else { + return "? (kernel did not answer the errata probe)".to_string(); + }; + let detail: Vec = FLOOR_ERRATA + .iter() + .map(|e| { + let state = if erratum_applied(mask, e.number) { + "ok" + } else { + "MISSING" + }; + format!("{}:{state}", e.number) + }) + .collect(); + format!("0x{mask:x} ({})", detail.join(" ")) +} + +/// Which restrictable rights this run left UNHANDLED — **derived, not declared**. +/// +/// For each access class, the crate's own `from_all(abi)` enumerates every right +/// it can name at the clamped ABI; the `handled_*` sets record what `apply()` +/// actually passed to `handle_access`/`scope`. The difference is the gap, named +/// right by right (`net.ConnectSendUdp`), in a form an operator can act on and a +/// test can assert against. +/// +/// **Why derived is the whole point.** The disclosure this replaced asked "is the +/// kernel newer than the crate?" as a proxy for "is anything unrestricted?". The +/// proxy held only while oring plumbed every right the crate could name, and it +/// broke silently the first time that stopped being true — bumping the crate to +/// V10 switched the warning OFF while UDP stayed wide open, because the *proxy* +/// improved rather than the *coverage*. A set difference against `from_all` +/// cannot drift that way: a right the crate learns and oring does not plumb +/// appears here on the next run, with no table to update and nothing to +/// remember. The maintenance burden is zero by construction, which is the only +/// kind that survives. +/// +/// **Bounded above by the crate ceiling, deliberately.** Rights the crate cannot +/// name are invisible to `from_all` and so cannot appear here — that residual is +/// the abi-ahead warning's job, and the two halves are reported together +/// precisely because neither is complete alone. +fn unhandled_rights( + abi: ABI, + handled_fs: BitFlags, + handled_net: BitFlags, + handled_scope: BitFlags, +) -> Vec { + let mut out = Vec::new(); + for r in (AccessFs::from_all(abi) & !handled_fs).iter() { + out.push(format!("fs.{r:?}")); + } + for r in (AccessNet::from_all(abi) & !handled_net).iter() { + out.push(format!("net.{r:?}")); + } + for r in (Scope::from_all(abi) & !handled_scope).iter() { + out.push(format!("scope.{r:?}")); + } + out +} + /// Query the kernel's Landlock ABI version (rust-landlock 0.4.5 exposes no /// public "current ABI" query, so we probe via `sys`). Returns 0 if unavailable. fn detect_abi_version() -> i32 { @@ -555,10 +1024,17 @@ fn detect_abi_version() -> i32 { } /// Map a detected ABI integer to the crate's `ABI` enum, CLAMPED to the crate's -/// max ([`CRATE_MAX_ABI`]): V7 by default, V9 with the `resolve-unix` feature. A -/// newer kernel exposes rights the crate can't name — those are left unhandled; -/// `apply()` warns loudly (abi-ahead) rather than pretending the clamp is -/// lossless. +/// max ([`CRATE_MAX_ABI`], derived from [`CEILING`] — deliberately not restated +/// as a number here: this comment still read "= V9" after the ceiling moved to +/// V10, which is the whole reason the two encodings were collapsed into one). A +/// newer kernel exposes rights the crate can't +/// name — those are left unhandled; `apply()` warns loudly (abi-ahead) rather +/// than pretending the clamp is lossless. +/// +/// The `resolve-unix` cfgs below are retained (rather than unconditional) purely +/// so a build without the feature fails with the single legible `compile_error!` +/// at the top of this module instead of a pile of unresolved `ABI::V8`/`V9` +/// paths. They go away when upstream ships V9 in a published release. fn abi_from_version(v: i32) -> ABI { match v { 1 => ABI::V1, @@ -568,42 +1044,38 @@ fn abi_from_version(v: i32) -> ABI { 5 => ABI::V5, 6 => ABI::V6, 7 => ABI::V7, - // V8/V9 are named only by a V9-capable crate; without the feature the - // published crate stops at V7, so everything above clamps there. + // V8/V9 are named only by a V9-capable crate — which the V9 floor makes + // mandatory (see the compile_error! above). The non-feature arm is + // unreachable in a build that compiles at all; it is kept so this match + // stays total for every cfg combination. #[cfg(feature = "resolve-unix")] 8 => ABI::V8, #[cfg(feature = "resolve-unix")] 9 => ABI::V9, - #[cfg(feature = "resolve-unix")] - _ => ABI::V9, - #[cfg(not(feature = "resolve-unix"))] - _ => ABI::V7, + // At and above the ceiling, clamp TO the ceiling. Spelled as `CEILING` + // rather than a repeated variant so the constant and the clamp target + // cannot drift apart — that pair was previously coupled only by a proof. + _ => CEILING, } } /// Integer rank of an `ABI` variant — the inverse of the V-number. Only used by /// the `abi_from_version` proof/tests to reason about clamp bounds. +/// +/// This was a hand-written match, which made it a THIRD independent encoding of +/// the same fact alongside [`CEILING`] and [`CRATE_MAX_ABI`]. The crate's +/// discriminants already ARE the V-numbers (pinned by the `const _` assertions +/// near [`CEILING`]), so the cast is the inverse by construction and cannot +/// drift. +/// +/// It also strictly improves the `#[non_exhaustive]` case. The old `_ => +/// i32::MAX` was a sentinel chosen to FAIL the over-claim assertion if a future +/// variant ever appeared. A cast does better: an unknown variant yields its +/// TRUE rank, which is above the ceiling and so fails the same assertion — for +/// the real reason, and with a number a reader can act on. #[cfg(any(test, kani))] fn abi_rank(abi: ABI) -> i32 { - match abi { - ABI::Unsupported => 0, - ABI::V1 => 1, - ABI::V2 => 2, - ABI::V3 => 3, - ABI::V4 => 4, - ABI::V5 => 5, - ABI::V6 => 6, - ABI::V7 => 7, - #[cfg(feature = "resolve-unix")] - ABI::V8 => 8, - #[cfg(feature = "resolve-unix")] - ABI::V9 => 9, - // `ABI` is #[non_exhaustive]. `abi_from_version` never constructs a - // variant beyond CRATE_MAX_ABI, so this is unreachable — but map it to a - // rank that FAILS the over-claim assertion, so if that ever changed the - // proof/test would catch it rather than silently pass. - _ => i32::MAX, - } + abi as i32 } /// Kani proof `abi_from_version_clamp` (run via `make verify`): for EVERY `i32`, @@ -634,6 +1106,84 @@ fn abi_from_version_clamp() { mod tests { use super::*; + /// An unanswered errata probe must render as `?`, never as blank or `0x0`. + /// `0x0` is a real answer meaning "this kernel carries none of them" and + /// must stay distinguishable from "the kernel did not tell us" — collapsing + /// the two would report absent evidence as a clean result. + #[test] + fn errata_absent_is_distinguishable_from_none_applied() { + assert_eq!(describe_errata(None), "?"); + assert_eq!(describe_errata(Some(0)), "0x0"); + assert_eq!(describe_errata(Some(7)), "0x7"); + } + + /// The bit mapping, pinned against the kernel's own: `BIT(number - 1)` + /// (`security/landlock/setup.c`). This is the assertion that was missing + /// when errata support landed — the mapping was open-coded at two call + /// sites and an off-by-one at either would have produced a false WARN on + /// every conforming kernel while both then-existing tests still passed. + #[test] + fn erratum_bit_mapping_matches_the_kernel() { + // Erratum N occupies bit N-1, so erratum 1 is the LOW bit. An + // off-by-one in either direction breaks exactly these four. + assert!(erratum_applied(0b001, 1), "erratum 1 must read bit 0"); + assert!(!erratum_applied(0b010, 1), "erratum 1 must NOT read bit 1"); + assert!(erratum_applied(0b100, 3), "erratum 3 must read bit 2"); + assert!( + !erratum_applied(0b011, 3), + "erratum 3 must NOT read bit 0/1" + ); + + // The live shape: this kernel family reports 0x7 = all three present. + for e in FLOOR_ERRATA { + assert!(erratum_applied(0b111, e.number), "0x7 means all applied"); + assert!(!erratum_applied(0, e.number), "0x0 means none applied"); + } + + // Total on degenerate input: a 0 number must not underflow into a + // 255-bit shift, and a number past the mask width must not panic. + assert!(!erratum_applied(u64::MAX, 0) || erratum_applied(u64::MAX, 1)); + assert!(!erratum_applied(u64::MAX, 200)); + } + + /// Golden pin of the table's contents against the kernel headers it + /// transcribes (`security/landlock/errata/abi-{1,4,6}.h`). + /// + /// **This is a REGRESSION guard, not a verification** — it cannot detect a + /// wrong transcription, only a later drift from whatever was transcribed, + /// because the kernel headers are not available at test time. It exists + /// because the weaker invariant below is satisfied by a table with two + /// erratum/ABI pairs swapped, which would make the WARN text name the wrong + /// ABI while every other test still passed. Update it deliberately when the + /// kernel adds an erratum; do not "fix" it to match an edit. + #[test] + fn floor_errata_table_matches_the_kernel_headers() { + let got: Vec<(u8, i32)> = FLOOR_ERRATA.iter().map(|e| (e.number, e.abi)).collect(); + assert_eq!( + got, + vec![(1, 4), (2, 6), (3, 1)], + "FLOOR_ERRATA drifted from the kernel: erratum 1 is declared at ABI 4 \ + (errata/abi-4.h), erratum 2 at ABI 6 (abi-6.h), erratum 3 at ABI 1 (abi-1.h)" + ); + } + + /// Every erratum oring warns about must have been introduced at or below + /// the floor. Warning about an erratum from a newer ABI would fire on every + /// conforming kernel — noise that trains operators to ignore the warning. + #[test] + fn floor_errata_are_at_or_below_the_floor() { + for e in FLOOR_ERRATA { + assert!( + e.abi <= FLOOR_ABI, + "erratum {} is declared at ABI V{} which is above FLOOR_ABI V{FLOOR_ABI}; \ + a kernel meeting the floor is not expected to carry it", + e.number, + e.abi + ); + assert!(e.number >= 1, "erratum numbers are 1-based (bit number-1)"); + } + } + #[test] fn tier_invariants() { // The security-load-bearing properties, asserted against the COMPUTED @@ -717,32 +1267,120 @@ mod tests { assert!(below_floor(-1), "an error probe (abi<0) must refuse"); } + /// Every ABI the crate can be at floor has a kernel version, and the mapping is + /// monotone. Guards the message an operator acts on: `--check` and the suite's + /// skip lines both tell them which kernel to get, and both used to carry `7.1` + /// as a literal — still saying it after the floor moved to V10. + /// + /// The monotone check is the half that would otherwise rot unnoticed: a wrong + /// version at a *single* entry still prints something plausible, so the property + /// worth asserting is that later ABIs never map to earlier kernels. + #[test] + fn floor_kernel_is_known() { + assert!( + !FLOOR_KERNEL.is_empty(), + "FLOOR_ABI must map to a kernel version" + ); + assert_eq!(FLOOR_KERNEL, "7.2", "V10 first shipped in mainline 7.2"); + + // Every known ABI parses as major.minor and the sequence rises with the ABI. + // Swept over a range WIDER than the mapped set, so an arm added out of order + // — or a gap in the middle — is visible rather than skipped. + let mut prev = (0u32, 0u32); + let mut prev_abi = 0i32; + let mut seen = 0; + for abi in 1..=FLOOR_ABI + 4 { + let ver = kernel_for(abi); + if ver.is_empty() { + assert!( + abi != FLOOR_ABI, + "the floor itself must have a kernel version" + ); + continue; + } + seen += 1; + let (maj, min) = ver + .split_once('.') + .map(|(a, b)| (a.parse::().unwrap(), b.parse::().unwrap())) + .unwrap_or_else(|| panic!("kernel_for({abi}) = {ver:?} is not major.minor")); + assert!( + (maj, min) >= prev, + "ABI V{abi} maps to kernel {ver}, EARLIER than V{prev_abi}'s {}.{} — \ + a later right cannot appear in an earlier kernel", + prev.0, + prev.1 + ); + prev = (maj, min); + prev_abi = abi; + } + // Frame: the span swept and how many arms it found, so "all monotone" is + // checkable against a count rather than asserted over an empty sweep. + assert!( + seen >= 4, + "swept ABI 1..={} and found only {seen} mapped version(s) — \ + the monotonicity check above was nearly vacuous", + FLOOR_ABI + 4 + ); + assert!( + kernel_for(FLOOR_ABI + 4).is_empty(), + "an unreleased ABI must map to \"\" (unknown), never to a guess" + ); + } + #[test] fn floor_and_abi_maps() { - // Floor is V7 (the coherent-cage minimum). A kernel above the crate's - // handled ceiling clamps DOWN to that ceiling, never to a lower/empty set. - assert_eq!(FLOOR_ABI, 7); + // Floor is V10 since 2026-07-29 — the coherent-cage minimum, which now includes + // the UDP net rights as well as RESOLVE_UNIX. A kernel above the crate's handled + // ceiling clamps DOWN to that ceiling, never to a lower/empty set. + // + // These two are asserted as LITERALS on purpose, not against the constants they + // pin. Deriving them (`assert_eq!(FLOOR_ABI, FLOOR_ABI)`) is the shape that cannot + // fail, and this test is the tripwire for an accidental floor change — so a + // deliberate one has to come here and say so. + assert_eq!(FLOOR_ABI, 10); + assert_eq!(CRATE_MAX_ABI, 10); + // At this floor the two coincide, which is worth stating because it makes the + // abi-ahead reporting path unreachable on a conforming kernel: there is no ABI a + // supported kernel can expose that the crate cannot name. It becomes reachable + // again the moment the kernel gains V11, and `unhandled=`/`errata=` exist for + // exactly that. + assert_eq!(FLOOR_ABI, CRATE_MAX_ABI); assert!(matches!(abi_from_version(7), ABI::V7)); + assert!(matches!(abi_from_version(8), ABI::V8)); + assert!(matches!(abi_from_version(9), ABI::V9)); + assert!(matches!(abi_from_version(10), ABI::V10)); + assert!(matches!(abi_from_version(11), ABI::V10)); - // Without the feature the crate stops at V7, so everything above clamps - // to V7 and resolve-unix is never active. - #[cfg(not(feature = "resolve-unix"))] - { - assert_eq!(CRATE_MAX_ABI, 7); - assert!(matches!(abi_from_version(9), ABI::V7)); - assert!(!resolve_unix_active(9)); - } - // With the feature the crate names V8/V9; V10+ clamps to V9, and the - // pathname-socket wall is active on a V9 kernel. - #[cfg(feature = "resolve-unix")] - { - assert_eq!(CRATE_MAX_ABI, 9); - assert!(matches!(abi_from_version(8), ABI::V8)); - assert!(matches!(abi_from_version(9), ABI::V9)); - assert!(matches!(abi_from_version(10), ABI::V9)); - assert!(resolve_unix_active(9)); - assert!(!resolve_unix_active(8)); - } + // The load-bearing pairing: the pathname-AF_UNIX wall is active at and + // above the floor, and inactive everywhere the floor refuses. If these two + // ever disagree, a sub-floor kernel could run with the class unmediated — + // which is the residual the V9 floor exists to delete (§2). + assert!(resolve_unix_active(FLOOR_ABI)); + assert!(resolve_unix_active(FLOOR_ABI + 1)); + + // **The pairing changed shape when the floor rose, and this is the interesting + // part.** Under a V9 floor the wall's ABI and the floor were the SAME number, so + // `!resolve_unix_active(FLOOR_ABI - 1)` held and pinned them together. At a V10 + // floor `resolve_unix_active(9)` is TRUE while 9 is BELOW the floor — the wall is + // now guaranteed with a margin rather than exactly at the boundary, and asserting + // the old clause would fail for a reason that is not a defect. + // + // So the property is restated as what it always meant: the wall is active + // everywhere the floor ADMITS, and the floor sits at or above the ABI that + // introduces it. A floor drop below 9 would break the second clause and fail here. + // `FLOOR_ABI >= RESOLVE_UNIX_ABI` is asserted at COMPILE time beside the constant, + // not here: over two `const`s it is a check that cannot fail at run time. + assert!(resolve_unix_active(RESOLVE_UNIX_ABI)); + assert!(!resolve_unix_active(RESOLVE_UNIX_ABI - 1)); + assert!( + below_floor(9), + "V9 has RESOLVE_UNIX but LACKS the UDP net rights, so at a V10 floor it must be \ + refused — not run with UDP unmediated, which is the residual this floor deletes" + ); + assert!( + below_floor(8), + "V8 lacks RESOLVE_UNIX as well, so it must be refused, not warned about" + ); // The clamp property `make verify` proves exhaustively over all i32, // spot-checked here so normal CI catches a dropped/mis-mapped arm without diff --git a/src/lib.rs b/src/lib.rs index 3bbcadd..43b893e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,6 +39,7 @@ #[cfg(all(target_os = "linux", not(target_arch = "x86_64")))] compile_error!("oring v1 supports only linux x86_64 (x32/seccomp arch guard); aarch64 is roadmap"); +pub mod diag; pub mod error; pub mod policy; // Post-hoc denial report (§8). Linux-only: reads Landlock V7 audit records. diff --git a/src/main.rs b/src/main.rs index 9edbea4..7b14d7d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -73,8 +73,26 @@ OPTIONS: scratch (Miri's sysroot build, cc/ld, -sys/bindgen build scripts, `go test`/`go run`). /tmp itself stays no-exec. Bounded and operator-named — wipe it between runs. + --tmpdir create DIR (0700), grant it write_noexec, and point the + child's TMPDIR at it. oring NO LONGER reads $TMPDIR from + the environment: the caller is the adversary, and a + caller-supplied path silently widened a profile's tiers + (measured: TMPDIR=$HOME/.ssh yielded write on + authorized_keys). Name the scratch dir here instead. --proc /proc grant: full (default) | self | none - (self = own pid + curated globals; closes peer-procfs) + (self = THE SEALING PID's procfs + curated globals; + closes peer-procfs METADATA. The grant resolves + /proc/self ONCE, at seal time, so it names one pid: + execve keeps it, fork does not. Every external command + the child spawns therefore gets EACCES on its OWN + /proc/self/{maps,exe,status}. It DOES buy peer-environ + privacy between the child's own subprocesses: Landlock + scopes ancestor-OR-EQUAL, so siblings under one seal + read each other's environ under --proc full and cannot + under --proc self (measured). What walls an OUT-OF-domain + peer is that domain scoping itself, not the ABI floor — + it consults no handled right and predates Landlock's + merge, so no floor bought it. See DESIGN.md §2.) --env-clear pass NO environment to the child except --env-keep vars (withhold API keys etc. from a compromised child) --env-keep allowlist one more env var to pass through (repeatable). @@ -89,6 +107,15 @@ OPTIONS: --require-all-grants refuse to seal if ANY intended path could not be granted (missing/unopenable). For CI: turns a silent under-grant into a hard failure + --require-full-coverage + refuse to seal if ANY restrictable right was left + UNHANDLED (i.e. `unhandled=` would not be `-`). + Opt-in. Both shipped profiles PASS it as of + 2026-07-29; it is a hand-authored `--policy` that can + leave a right unhandled. Use it when coverage must be + ESTABLISHED, not reported — the `unhandled=` + disclosure is a stderr line the caller can discard, + an exit code is not. --denials report what Landlock DENIED to a sealed run (§8). Turns a mystery EACCES into `fs.read_dir /home/u`. Takes the pid from the SEALED line; execve preserves @@ -101,10 +128,12 @@ OPTIONS: -h, --help this help --version version + git sha -Policy also via ORING_POLICY (JSON env). ORING_QUIET=1 suppresses the seal line. +Policy also via ORING_POLICY (JSON env). ORING_QUIET=1 suppresses the seal line +(under-coverage warnings still print — they are not suppressible). Delivery precedence (highest wins; refuse if none): --policy > ORING_POLICY > --profile. -EXIT: 120 confinement refused 121 policy error 122 target not found +EXIT: 2 usage error (unknown flag, bad --profile name — oring's own, no child ran) + 120 confinement refused 121 policy error 122 target not found 159 seccomp KILL (x32/arch guard) * child's execve-preserved status"; #[derive(Default)] @@ -113,10 +142,12 @@ struct Args { profile: Option, workdir: Option, exec_tmp: Option, + tmpdir: Option, proc_mode: Option, env_clear: bool, env_inherit: bool, require_all_grants: bool, + require_full_coverage: bool, env_keep: Vec, denials_pid: Option, since: Option, @@ -158,12 +189,14 @@ fn parse_args(argv: Vec) -> Result { "--env-clear" => a.env_clear = true, "--env-inherit" => a.env_inherit = true, "--require-all-grants" => a.require_all_grants = true, + "--require-full-coverage" => a.require_full_coverage = true, "--env-keep" => a.env_keep.push(need_val(&mut it, "--env-keep")?), "-v" | "--verbose" => a.verbose = true, "--policy" => a.policy_file = Some(need_val(&mut it, "--policy")?), "--profile" => a.profile = Some(need_val(&mut it, "--profile")?), "--workdir" => a.workdir = Some(need_val(&mut it, "--workdir")?), "--exec-tmp" => a.exec_tmp = Some(need_val(&mut it, "--exec-tmp")?), + "--tmpdir" => a.tmpdir = Some(need_val(&mut it, "--tmpdir")?), "--proc" => a.proc_mode = Some(parse_proc(&need_val(&mut it, "--proc")?)?), other => { return Err(OringError::usage(format!( @@ -221,31 +254,55 @@ fn parse_proc(s: &str) -> Result { /// controls a WRITABLE PARENT dir can still TOCTOU-swap the entry between this /// check and the grant; naming the scratch under a sticky dir (`/tmp`) or one /// the operator owns closes that. -fn create_exec_tmp(p: &Path) -> Result<()> { +/// `materialize` splits the SIDE EFFECT from the PREDICATE. `--explain` must not +/// create a directory, but it must still report a scratch path that would be +/// refused — otherwise the preview shows a plan the real run rejects, which is +/// the opposite of what a preview is for. So creation is gated; validation of an +/// entry that already exists is not. +fn create_scratch_dir(flag: &str, p: &Path, materialize: bool) -> Result<()> { use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; - match std::fs::DirBuilder::new().mode(0o700).create(p) { - Ok(()) => Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + if materialize { + match std::fs::DirBuilder::new().mode(0o700).create(p) { + Ok(()) => return Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => { + return Err(OringError::usage(format!( + "{flag}: cannot create {}: {e}", + p.display() + ))) + } + } + } + { + { // lstat, not stat: we must judge the entry itself, not its target. - let md = std::fs::symlink_metadata(p).map_err(|e| { - OringError::usage(format!("--exec-tmp: cannot stat {}: {e}", p.display())) - })?; + // Absent on a preview is fine — the real run will create it. + let md = match std::fs::symlink_metadata(p) { + Ok(md) => md, + Err(_) if !materialize => return Ok(()), + Err(e) => { + return Err(OringError::usage(format!( + "{flag}: cannot stat {}: {e}", + p.display() + ))) + } + }; if md.file_type().is_symlink() { return Err(OringError::usage(format!( - "--exec-tmp is a symlink: {} — refusing (it would grant its \ - target write_exec); name a real directory", + "{flag} is a symlink: {} — refusing (it would grant its target a \ + writable tier); name a real directory", p.display() ))); } if !md.is_dir() { return Err(OringError::usage(format!( - "--exec-tmp exists but is not a directory: {}", + "{flag} exists but is not a directory: {}", p.display() ))); } if md.permissions().mode() & 0o022 != 0 { return Err(OringError::usage(format!( - "--exec-tmp is group/other-writable ({:#o}): {} — refusing \ + "{flag} is group/other-writable ({:#o}): {} — refusing \ (another uid could plant an executable the child then runs); \ `chmod 700` it", md.permissions().mode() & 0o777, @@ -254,10 +311,6 @@ fn create_exec_tmp(p: &Path) -> Result<()> { } Ok(()) } - Err(e) => Err(OringError::usage(format!( - "--exec-tmp: cannot create {}: {e}", - p.display() - ))), } } @@ -285,6 +338,7 @@ fn load_policy(a: &Args, materialize: bool) -> Result { // best-effort defaults. Drives WARN-vs-INFO on a missing granted path. policy.user_authored = !from_profile; policy.require_all_grants = a.require_all_grants; + policy.require_full_coverage = a.require_full_coverage; // Precedence footgun (CALIF): a --policy file or ORING_POLICY env WHOLE- // REPLACES an explicit --profile (no per-field merge). An env var silently @@ -319,6 +373,24 @@ fn load_policy(a: &Args, materialize: bool) -> Result { // Landlock cannot grant a path that does not exist, so a typo would seal // the child with NO workspace and surface later as a confusing EACCES. // Fail fast and loudly instead (follows symlinks, matching PathFd). + // + // But judge the ENTRY before its target. `--exec-tmp` already refuses a + // symlink for exactly this reason and `--workdir` did not, which was an + // asymmetry with teeth: `metadata()` follows links, so + // `--workdir /tmp/link` where `link -> /usr/local/bin` granted + // `write_exec` on `/usr/local/bin` — a $PATH directory — while + // `--explain` printed only `write_exec: ["/tmp/link"]`, hiding the real + // target from the operator reviewing the cage they just asked for. + // lstat first, same test and same reasoning as `create_exec_tmp`. + let lmd = std::fs::symlink_metadata(&p) + .map_err(|e| OringError::policy(format!("--workdir: cannot stat {dir}: {e}")))?; + if lmd.file_type().is_symlink() { + return Err(OringError::policy(format!( + "--workdir is a symlink: {dir} — refusing (it would grant its \ + target a writable tier, and --explain would show only the link); \ + name the real directory" + ))); + } match std::fs::metadata(&p) { Ok(m) if m.is_dir() => {} Ok(_) => { @@ -359,9 +431,7 @@ fn load_policy(a: &Args, materialize: bool) -> Result { "--exec-tmp must be absolute: {dir}" ))); } - if materialize { - create_exec_tmp(&p)?; - } + create_scratch_dir("--exec-tmp", &p, materialize)?; // Grant the canonical path when it exists (matches how add_tier opens // it); fall back to the literal path for a preview where it may not. let granted = std::fs::canonicalize(&p).unwrap_or(p); @@ -374,16 +444,44 @@ fn load_policy(a: &Args, materialize: bool) -> Result { "oring: WARN --exec-tmp grants {} write_exec (write+execute), \ reopening a W^X hole under the `{name}` profile whose purpose is \ true W^X — only that scratch dir is affected", - granted.display() + oring::diag::safe_display(&granted.display()) ); } policy.add_workdir(granted); // write_exec tier } + // --tmpdir: the write_noexec sibling of --exec-tmp, and the replacement for + // reading $TMPDIR out of the environment (see the long note in profile.rs). + // Same validation, because the shapes it rejects are the same ones: a + // symlink would grant its target, a non-directory would grant a FILE a + // writable tier (`TMPDIR=~/.bashrc` yielded WriteFile+Truncate on the + // operator's shell rc — a deferred-execution primitive invisible to both + // the $HOME guard and the tier-overlap check), and a group/other-writable + // dir lets another uid stage content. + if let Some(dir) = &a.tmpdir { + let p = PathBuf::from(dir); + if !p.is_absolute() { + return Err(OringError::usage(format!( + "--tmpdir must be absolute: {dir}" + ))); + } + create_scratch_dir("--tmpdir", &p, materialize)?; + let granted = std::fs::canonicalize(&p).unwrap_or(p); + policy.add_workdir_noexec(granted); + } + policy.validate()?; // After every tier is final (--workdir/--proc/--exec-tmp folded in), so the // check sees the tiers the kernel will actually get. policy.check_self_protection(a.policy_file.as_deref().map(Path::new))?; + // Same point in the pipeline, opposite direction: the caller must not have + // widened a built-in profile out from under us (e.g. $TMPDIR=$HOME). + policy.check_profile_grants(std::env::var_os("HOME").map(PathBuf::from).as_deref())?; + // AFTER every injection (--workdir, --exec-tmp, $TMPDIR, --proc), because + // the overlap that matters is in the tier set actually about to be sealed — + // `--workdir /tmp` creates one that neither the profile nor the flag has on + // its own. + policy.check_tier_overlap()?; Ok(policy) } @@ -395,6 +493,11 @@ fn explain(policy: &Policy, a: &Args) { "detected Landlock ABI: {}", oring::landlock::detected_abi_string() ); + #[cfg(target_os = "linux")] + println!( + "detected errata: {}", + oring::landlock::detected_errata_string() + ); println!("read_exec: {:?}", ll.read_exec); println!("read_only: {:?}", ll.read_only); println!("write_noexec: {:?}", ll.write_noexec); @@ -461,8 +564,9 @@ fn run() -> Result<()> { return Err(OringError::refuse( "landlock-abi", format!( - "Landlock ABI V{abi} < V{} (need kernel 6.15+); seal would refuse", - oring::landlock::FLOOR_ABI + "Landlock ABI V{abi} < V{} (need kernel {}+); seal would refuse", + oring::landlock::FLOOR_ABI, + oring::landlock::FLOOR_KERNEL ), )); } @@ -502,11 +606,68 @@ fn run() -> Result<()> { // whichever keep mode admits it. `extra_keep` then force-admits TMPDIR even // under --env-clear, where it is otherwise dropped — without it the scratch // dir would be granted but unused. + // oring OWNS the child's TMPDIR. It is always set here — never inherited — + // to whatever scratch was actually granted: --exec-tmp, else --tmpdir, else + // /tmp (write_noexec in both built-in profiles by construction). + // + // Setting it is not optional once the grant stopped following it. Dropping + // the grant while still PASSING the caller's value is a Type-II failure: the + // child gets a TMPDIR it cannot write, and links fail with a mystery EACCES + // that trains the operator to loosen policy until it works. That failure + // already existed on the --policy path, where $TMPDIR was never consulted — + // measured: `TMPDIR=/scratch oring --policy p.json -- sh -c 'echo hi > + // "$TMPDIR/probe"'` gives "Permission denied". Owning the variable fixes + // both paths at once. let mut extra_keep: Vec = Vec::new(); - if let Some(dir) = &a.exec_tmp { - let resolved = std::fs::canonicalize(dir).unwrap_or_else(|_| PathBuf::from(dir)); - std::env::set_var("TMPDIR", &resolved); - extra_keep.push("TMPDIR".to_string()); + // Resolve the scratch dir from the COMMAND LINE, never the environment. + // Falling back to /tmp only when the final policy actually grants it: the + // built-in profiles put /tmp in write_noexec by construction, but a + // user-authored --policy need not, and asserting TMPDIR=/tmp against a + // policy that did not grant it would recreate the Type-II failure this + // change exists to remove — just pointed somewhere else. + let scratch: Option = match a.exec_tmp.as_ref().or(a.tmpdir.as_ref()) { + Some(d) => Some(std::fs::canonicalize(d).unwrap_or_else(|_| PathBuf::from(d))), + None => { + let tmp = Path::new("/tmp"); + let granted = policy + .landlock + .write_noexec + .iter() + .chain(policy.landlock.write_exec.iter()) + // Canonicalise the granted path, same reason as the three guards in + // policy.rs: `add_tier` grants the resolved inode. Conservative direction + // here — a symlink to /tmp would make this read "not granted" and hand the + // child no TMPDIR — but the guards must agree with the kernel, not merely + // err safely, or the next one to be written copies the lexical shape. + .any(|p| tmp.starts_with(oring::policy::canonical_or_literal(p))); + granted.then(|| PathBuf::from("/tmp")) + } + }; + if let Some(inherited) = std::env::var_os("TMPDIR") { + if scratch.as_deref() != Some(Path::new(&inherited)) { + eprintln!( + "oring: INFO ignoring inherited TMPDIR={} — oring no longer takes the \ + scratch path from the environment (a caller-supplied value silently \ + widened profile tiers; measured, TMPDIR=$HOME/.ssh granted write on \ + authorized_keys). Name it with --tmpdir or --exec-tmp instead.", + oring::diag::safe_display(&Path::new(&inherited).display()) + ); + } + } + match &scratch { + Some(s) => { + // Single-threaded and pre-seal, so set_var is sound here; the child + // env is BUILT from oring's own vars_os filtered by the keep-set, so + // this is how the new value reaches it. `extra_keep` force-admits it + // even under --env-clear, where it would otherwise be dropped. + std::env::set_var("TMPDIR", s); + extra_keep.push("TMPDIR".to_string()); + } + // No granted scratch: hand the child NO TMPDIR rather than one it cannot + // write. POSIX tools then use their own /tmp default, which is an honest + // "unset" instead of a grant oring cannot back. Removed explicitly + // because TMPDIR is in BASE_ENV_KEEP and would otherwise pass through. + None => std::env::remove_var("TMPDIR"), } // Allowlist by default. The child's env is BUILT, never scrubbed (sys::exec @@ -593,6 +754,73 @@ mod probe { let arg = args.get(1).map(String::as_str).unwrap_or(""); match op { "fs-read" => exit(errno_of(std::fs::read(arg).map(|_| ()))), + // The DIVERGENCE probe (CON-3). Reports `access(R_OK)` and `open()` for the + // SAME path in the SAME process, so the two can be compared with one variable. + // + // Landlock cannot mediate `access(2)`: verified against this kernel's own + // `security/landlock/fs.c`, which registers `file_open` and the `path_*`/`sb_*` + // hooks and registers **neither** `inode_permission` nor `inode_getattr`. So a + // withheld path stays VISIBLE to a pre-flight check while the open that follows + // is denied — a tool that trusts `access()` takes the wrong branch and produces + // a wrong result with nobody attacking. Structural: no ABI bump changes it. + // + // Exit encodes both answers so one run yields the pair: + // 0 access OK, open OK — no divergence + // 10 access OK, open DENIED — THE DIVERGENCE + // 20 access DENIED, open OK — inverted (would be surprising) + // 30 access DENIED, open DENIED — consistent refusal + // IN-DOMAIN peer read (BUILD-QUEUE T19). Two SIBLINGS under ONE seal: this + // process spawns B (holding a decoy secret in its environ), then spawns A to + // read B's `/proc//environ`, and exits with A's errno. + // + // Siblings, not parent->child, and that distinction is the whole point: + // `ptrace_may_access` permits an ANCESTOR to read a descendant regardless of + // Landlock, so a parent reading its own child would pass for reasons that have + // nothing to do with the domain relation. Landlock scopes ancestor-or-equal, so + // two children of one seal share a domain and the ptrace hook permits them — + // which is exactly the case `SECURITY.md` publishes advice about. + // + // TWO DIFFERENT MECHANISMS, which SECURITY.md itself had to separate on + // 2026-07-28 after presenting them as one controlled pair: + // --proc full -> READABLE: the domain relation permits it (0) + // --proc self -> DENIED at the Landlock FS TIER, because the sibling's + // /proc/ is not a granted path, BEFORE + // ptrace_may_access is consulted at all (EACCES) + "proc-indomain-env" => { + let me = std::env::current_exe() + .unwrap_or_else(|_| std::path::PathBuf::from("/proc/self/exe")); + let mut b = match std::process::Command::new("/bin/sleep") + .arg("5") + .env("ORING_DECOY", "sibling-secret") + .spawn() + { + Ok(c) => c, + Err(e) => exit(240 + (e.raw_os_error().unwrap_or(0) % 10)), + }; + let target = format!("/proc/{}/environ", b.id()); + let a = std::process::Command::new(&me) + .args(["__probe", "fs-read", &target]) + .status(); + let _ = b.kill(); + let _ = b.wait(); + exit(match a { + Ok(st) => st.code().unwrap_or(239), + Err(_) => 238, + }); + } + "fs-access-vs-open" => { + let c = std::ffi::CString::new(arg).unwrap_or_default(); + // SAFETY: `c` is a NUL-terminated C string that outlives the call; R_OK is + // a plain flag. `access` only reads the path. + let acc_ok = unsafe { libc::access(c.as_ptr(), libc::R_OK) } == 0; + let open_ok = std::fs::File::open(arg).is_ok(); + exit(match (acc_ok, open_ok) { + (true, true) => 0, + (true, false) => 10, + (false, true) => 20, + (false, false) => 30, + }); + } "fs-readdir" => exit(errno_of(std::fs::read_dir(arg).map(|it| { it.count(); }))), @@ -613,6 +841,15 @@ mod probe { "sc-keyctl" => exit(raw_errno(unsafe { libc::syscall(libc::SYS_keyctl, 0, 0, 0, 0, 0) })), + // T14. setns is the one namespace-family syscall whose denial is + // DIRECTLY attributable without a userns: unprivileged it returns + // EBADF bare (fd -1 is invalid, checked before any permission), and + // EPERM sealed (ring 2 refuses the call outright). The CLONE_NEW* + // flags cannot do this — unprivileged they are EPERM bare too, so a + // sealed EPERM attributes nothing; mount/pivot_root likewise. + "sc-setns" => exit(raw_errno(unsafe { + libc::syscall(libc::SYS_setns, -1i32, 0i32) + })), "sc-unshare-newuser" => exit(raw_errno(unsafe { libc::syscall(libc::SYS_unshare, 0x1000_0000) })), @@ -774,8 +1011,9 @@ mod probe { "unix-connect" => { // Pathname AF_UNIX connect. Walled by Landlock RESOLVE_UNIX at // ABI>=9 (handled via from_all, granted in no tier): connect to - // an out-of-domain pathname socket -> EACCES. Below V9 the right - // doesn't exist and this is the §2 unmediated residual. + // an out-of-domain pathname socket -> EACCES. V9 is below the floor as + // of v0.2, so this is a proof cell on every kernel oring runs on + // — no longer a build-gated cell that auto-skips (§2, D-4b). let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0) }; if fd < 0 { exit(os_errno()); @@ -825,6 +1063,50 @@ mod probe { let r = unsafe { libc::connect(fd, (&a as *const libc::sockaddr_un).cast(), len) }; exit(if r < 0 { os_errno() } else { 0 }); } + // T15. `NO_NEW_PRIVS` is a headline guarantee and the seal line's + // `nnp=1` is a LITERAL — the one field there not derived from run + // state. It is true by a double invariant (`set_no_new_privs()?` + // aborts before the line is reached, and unprivileged + // `SECCOMP_SET_MODE_FILTER` returns EACCES without NNP) rather than + // by observation, so it could be falsified by a refactor and never + // by a run. This probe is what lets a run falsify it: it reports the + // kernel's own view from `/proc/self/status`, which `execve` + // preserves, so the value read here is the sealed child's. + // + // Encoding: NoNewPrivs * 10 + Seccomp. Bare = 0, sealed = 12 + // (NNP 1, seccomp mode 2 = filter). 200+ = the field could not be + // read or parsed, which must not be mistaken for a bare 0. + "nnp-status" => { + let Ok(s) = std::fs::read_to_string("/proc/self/status") else { + exit(200); + }; + let field = |k: &str| -> Option { + s.lines() + .find_map(|l| l.strip_prefix(k)) + .and_then(|v| v.trim().parse::().ok()) + }; + match (field("NoNewPrivs:"), field("Seccomp:")) { + (Some(n), Some(sc)) => exit(n * 10 + sc), + (None, _) => exit(201), + (_, None) => exit(202), + } + } + // T10. Read one byte from a raw fd number and report whether it was + // there. The FD scrub is `close_range(3.., CLOSE_RANGE_CLOEXEC)`, + // which SETS CLOEXEC rather than closing — the fds go away at the + // `execve` that follows, so the child is the only place the effect + // is observable. 0 = the fd was open and readable, otherwise errno + // (EBADF = 9 is the scrubbed case). + "fd-read" => { + let n: libc::c_int = arg.parse().unwrap_or(-1); + let mut buf = [0u8; 1]; + // SAFETY: `buf` is a valid writable single byte for the length + // passed. `read(2)` on a closed or invalid descriptor returns + // -1/EBADF rather than touching the buffer, which is precisely + // the case under test. + let rc = unsafe { libc::read(n, buf.as_mut_ptr().cast(), 1) }; + exit(raw_errno(rc as libc::c_long)); + } "proc-read" => { // Peer SECRET read: /proc//environ. Goes through // ptrace_may_access → Landlock's ptrace hook blocks it for peers @@ -933,10 +1215,38 @@ mod probe { exit(0); } "x32-call" => { - // An x32-numbered getpid — the guard must SIGSYS-kill us before + // An x32-numbered syscall — the guard must SIGSYS-kill us before // the kernel dispatches, regardless of whether x32 is even // supported. If we survive to exit, the guard FAILED. - unsafe { libc::syscall((0x4000_0000i64) | libc::SYS_getpid) }; + // + // `arg` names the 64-bit number to OR the x32 bit into; empty = + // `getpid`, the number every cell used until 2026-07-28. Taking a + // SECOND number is the whole point of `BUILD-QUEUE.md` T1: a guard + // mutated from `JSET X32_SYSCALL_BIT` to `JEQ X32_SYSCALL_BIT|39` + // kills x32 `getpid` and allows every other x32 syscall, and the + // full suite stayed green because nothing probed a second number. + let nr: i64 = if arg.is_empty() { + libc::SYS_getpid + } else { + match arg.parse() { + Ok(n) => n, + // LOUD on a bad number. Falling back to getpid would make a + // typo'd cell pass against a getpid-only mutant — the cell + // would be green for the wrong reason, which is the exact + // defect T1 exists to remove. + Err(_) => exit(249), + } + }; + // EXPLICIT ZERO ARGUMENTS. `libc::syscall` is variadic: calling it + // with no argument list leaves the syscall's argument registers + // holding whatever happened to be there. That was harmless while + // this probe was hardcoded to `getpid`, which takes none — but the + // number is now caller-supplied, so a BYPASSED guard would execute + // an arbitrary syscall with garbage arguments. Zeros make the + // bypass observable without making it destructive: `read(0, NULL, + // 0)` returns immediately, and every other low number degenerates + // the same way. + unsafe { libc::syscall((0x4000_0000i64) | nr, 0, 0, 0, 0, 0, 0) }; exit(0); // reaching here = NOT blocked (test fails) } _ => { diff --git a/src/pipeline.rs b/src/pipeline.rs index 5602f3a..b7daac8 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -81,6 +81,54 @@ pub fn seal( seccomp::apply(policy.seccomp.profile)?; // 11. report (ground truth from the actual restriction) then execve. + // + // UNDER-COVERAGE IS NOT SUPPRESSIBLE. `ORING_QUIET=1` silences the seal + // line, which is an informational *status report*; it must not silence a + // *disclosure* that rights were left unrestricted. The two compose into a + // blind spot otherwise, and it is reachable by the party oring treats as the + // adversary: `quiet` comes from an ENVIRONMENT VARIABLE (`main.rs`), so a + // wrapper script or parent process can set it without the policy author's + // involvement — and a class left entirely unhandled deliberately does not + // raise the partial-class WARN in `landlock::apply`, on the reasoning that + // `net=off` on the seal line already says so. Under quiet there is no seal + // line to say it. Measured: `ORING_QUIET=1` with a policy naming no net + // fields produced EMPTY stderr while all four net rights were open. + // D1b: does this run leave a shared cross-project cache writable? Computed + // here rather than in `check_profile_grants`, which returns early for + // `user_authored` policies — and a hand-written `--policy` is now the only + // remaining route into the class, since the built-in profiles stopped + // granting these on 2026-07-28. + let writable_caches = policy.writable_shared_caches(); + let shared_cache_write = if writable_caches.is_empty() { + "none".to_string() + } else { + writable_caches.join(",") + }; + // A DISCLOSURE, not a verdict: it reports which named caches are writable and + // says nothing about whether the run is safe. Fires for the named list only, + // so it stays rare enough to mean something. Not suppressible by + // ORING_QUIET, for the same reason `unhandled=` is not: a quiet flag may + // silence a status report, never the fact that a wall is absent. + if !writable_caches.is_empty() { + use std::io::Write as _; + let _ = writeln!( + std::io::stderr(), + "oring: WARN shared package cache WRITABLE by the child: {} — a later \ + UNCONFINED build in an UNRELATED project reads these, and neither cargo \ + nor go re-verifies what it finds there. Grant it deliberately or not at \ + all (the maintainer's decision log, D1).", + shared_cache_write + ); + } + if quiet && ring1.unhandled != "-" { + use std::io::Write as _; + let _ = writeln!( + std::io::stderr(), + "oring: WARN sealed with rights UNRESTRICTED: {} (seal line suppressed by \ + ORING_QUIET; under-coverage is reported regardless)", + ring1.unhandled + ); + } if !quiet { use std::io::Write as _; // Non-fatal by construction: a closed/unwritable stderr must NOT abort @@ -91,14 +139,36 @@ pub fn seal( // this run; execve PRESERVES the pid, so this IS the child's pid. let _ = writeln!( std::io::stderr(), - "oring: SEALED {} ring1_fs={} grants={} abi={} net={} scope={} audit={} nnp=1 pid={}", + "oring: SEALED {} ring1_fs={} grants={} abi={} errata={} net={} scope={} audit={} unhandled={} shared_cache_write={} proc={} ring2={} nnp=1 pid={}", version_tag(), ring1.fs, ring1.grants, ring1.abi, + ring1.errata, ring1.net, ring1.scope, ring1.audit, + // Which restrictable rights this run left OPEN. `abi=` reports the + // interface version and `net=`/`scope=` report what was asked for; + // none of them could say what was left unrestricted, which is the + // question an auditor reading a seal line after the fact is asking. + ring1.unhandled, + shared_cache_write, + // Which /proc exposure the run actually installed. There was NO field for + // this: `--proc self|none` is the control `--help` and DESIGN §2 advertise, + // and `grants=` was the operator's only feedback on whether it applied — + // it reads identical either way. Two independent routes defeated the mode + // guard (BUILD-QUEUE T24) and no instrument would have shown either. + // DERIVED from the tiers, never from the flag, so it can disagree with + // `--proc` and say so. + policy.proc_exposure(), + // Ring 2 had NO field here at all: every other token reports ring 1 + // or process state, so a reader could not tell which seccomp profile + // sealed — and after the socket() protocol allowlist landed, ring 2 + // carries a load-bearing egress property (non-TCP/UDP refused in + // AF_INET/AF_INET6) that nothing in this line mentioned. `net=` was + // identical before and after that fix, which is what prompted this. + policy.seccomp.profile.as_str(), std::process::id(), ); } diff --git a/src/policy.rs b/src/policy.rs index 42a86b5..a8b7e9e 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -40,6 +40,43 @@ pub struct Policy { /// works. For CI/automation, where a silent degradation is invisible. #[serde(skip)] pub require_all_grants: bool, + + /// Set by `--require-full-coverage` (not a JSON field). Refuse to seal if ANY + /// restrictable right was left UNHANDLED — i.e. if `unhandled=` would be + /// anything but `-`. + /// + /// **Why this exists as a flag rather than the default.** `unhandled=` is a + /// degraded path with a warning, which `D-4` rejects everywhere else in this + /// tool — the V9 floor was folded in for exactly that reason. + /// + /// The original reason it stayed opt-in is GONE, and this note is here so the + /// dead reason is not re-cited. It used to be that making it fatal would refuse + /// every default run, because the shipped profiles left + /// `net.BindUdp,net.ConnectSendUdp` unhandled — itself only because a V10-only + /// default would not seal below 7.2. `FLOOR_ABI` is 10 and the profiles now + /// handle both, so **measured 2026-07-29, `--require-full-coverage` exits 0 on + /// both `balanced` and `strict`** (`the_shipped_udp_posture_is_dns_only_or_deny_all` + /// pins the posture that makes it so). + /// + /// What still keeps it opt-in is a DIFFERENT argument, and a weaker one: a + /// hand-authored `--policy` that omits a field currently warns, and defaulting + /// this would turn every such policy into an exit 120. That is a compatibility + /// call on user-authored input, not a statement about oring's own defaults, and + /// it is open rather than settled. + /// + /// **What it buys, and it is not "a louder warning".** The disclosure is a + /// stderr line written with `let _ = writeln!`, so oring's INVOKER can discard + /// it with `2>/dev/null` — measured, and the write to `/dev/null` SUCCEEDS, so + /// no error check inside oring can detect the case. There is no second channel: + /// the audit transport `denials.rs` reads is a *consumer* of kernel-emitted + /// Landlock records, a producer needs `CAP_AUDIT_WRITE` an unprivileged tool + /// lacks, and `/dev/log` would be grantable only by the very policy under audit + /// because the disclosure is emitted after the seal. So an operator who needs + /// coverage to be *established* rather than *reported* cannot get it from the + /// disclosure at any redirection. This flag converts it into an **exit code**, + /// which no redirect can suppress. + #[serde(skip)] + pub require_full_coverage: bool, } #[derive(Debug, Default, Deserialize)] @@ -58,6 +95,22 @@ pub struct Landlock { pub egress_tcp: Option>, #[serde(default)] pub ingress_tcp: Option>, + /// UDP directions, Landlock **V10+**. Same None/Some([]) convention as the + /// TCP pair. Requesting either on a sub-V10 kernel is REFUSED, not degraded: + /// silently dropping a requested net right is the Type-II failure the floor + /// exists to prevent (§4 D-4), and here the drop would be an open egress + /// path rather than a missing grant. + /// + /// `ConnectSendUdp` covers `connect(2)` AND `sendto(2)` to an explicit + /// remote port. `BindUdp` covers `bind(2)`. Note the autobind trap: an + /// unbound socket that sends acquires a source port via autobind, so + /// handling `BindUdp` without granting it denies ordinary egress — measured + /// 2026-07-26, and the reason egress-only policies must handle + /// `ConnectSendUdp` alone. + #[serde(default)] + pub egress_udp: Option>, + #[serde(default)] + pub ingress_udp: Option>, #[serde(default = "default_true")] pub scope_ipc: bool, } @@ -79,6 +132,18 @@ pub enum SeccompProfile { Strict, } +impl SeccompProfile { + /// Name for the seal line's `ring2=` field. There is deliberately no + /// `off` variant — ring 2 is mandatory, which is what stops the socket() + /// protocol wall being defeated by asking for no seccomp at all. + pub fn as_str(self) -> &'static str { + match self { + SeccompProfile::Balanced => "balanced", + SeccompProfile::Strict => "strict", + } + } +} + /// How much of `/proc` a profile grants (read). Peer-procfs is a §2 residual; /// `SelfOnly` closes it for standalone use at the cost of tools that read other /// pids' /proc. `None` is the tightest. @@ -108,10 +173,119 @@ pub const PROC_SELF_READONLY: &[&str] = &[ /// Canonicalize `p`, or fall back to the literal path. Fail-open: an /// un-canonicalizable path (missing, permission) is compared as-written rather /// than dropped — enforcement is Landlock's, this is only a pre-seal predicate. -fn canonical_or_literal(p: &Path) -> PathBuf { +/// Resolve a path for COMPARISON, falling back to the literal when it does not exist. +/// +/// Public because the bin is a separate crate and `main.rs` has a guard on the same +/// inputs. **Every guard that predicts what `add_tier` will do must go through this**: +/// `add_tier` opens with `PathFd::new`, which follows symlinks, so a rule lands on the +/// TARGET inode and a lexical comparison silently disagrees with the kernel. Four guards +/// compared lexically until 2026-07-29 (BUILD-QUEUE T24); one of them was a measured +/// BREACH. +pub fn canonical_or_literal(p: &Path) -> PathBuf { std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()) } +/// Shared, cross-project package caches — the named vector list from D1b. +/// +/// NAMED, not a heuristic, and that is deliberate. A blanket warning on every +/// `--policy` trains operators to ignore it (the `ORING_QUIET` / denial-noise +/// failure) and implies the operator erred when they may have chosen on purpose. +/// These five are the paths a LATER UNCONFINED toolchain reads, so a write here +/// reaches every future project on the machine — measured: a payload under +/// `~/.cargo/registry/src` executes at the operator's uid on an ordinary +/// `cargo build`, in an unrelated project. +const SHARED_CACHES: &[&[&str]] = &[ + &[".cargo", "registry"], + &[".cargo", "git"], + &[".npm"], + &["go", "pkg"], + &[".cache"], +]; + +impl Policy { + /// Named shared caches this policy leaves WRITABLE by the child, as + /// `~`-relative labels. Empty when none — the shipped profiles' state since + /// 2026-07-28. + /// + /// Checks BOTH directions, because either makes a shared cache writable: + /// a write tier at-or-above the cache (Landlock unions along the path walk, + /// so an ancestor grant confers write on everything beneath), and a write + /// tier NESTED INSIDE it (`--policy` granting `~/.cargo/registry/src`). + /// + /// Deliberately NOT implemented in `check_profile_grants`: that returns + /// `Ok(())` immediately for `user_authored` policies, which are now the only + /// remaining route into this hazard. + pub fn writable_shared_caches(&self) -> Vec { + // EVERY plausible home, not just `$HOME` — this is a DISCLOSURE, and a + // disclosure that an ambient variable can silence is not one. + // + // T25: `$HOME` is ambient, and `RESIDUALS.md` names it alongside + // `ORING_POLICY` and `$TMPDIR` as a source the caller's own environment + // is not trusted to control. This function read it and nothing else, so + // `HOME=/tmp/decoy` (or `HOME` unset) made the cache paths resolve + // somewhere no tier could match, and the seal line reported + // `shared_cache_write=none` over a policy granting write on the real + // `~/.cargo/registry`. The confinement was never affected; the operator's + // view of it was, which is the MISREPORT class and worse than an opaque + // field because it reads as a positive result. + // + // The passwd entry is the non-ambient answer and is checked first. + // `$HOME` is retained as an ADDITIONAL root rather than replaced: a + // legitimately relocated home, a container without a passwd entry, or a + // test harness pointing HOME at a fixture are all real, and taking the + // union means a decoy can only ever ADD a place to look. It cannot + // remove the true one, which is the property that was missing. + let mut roots: Vec = Vec::new(); + for cand in [crate::sys::passwd_home(), crate::profile::home()] + .into_iter() + .flatten() + { + let cand = canonical_or_literal(&cand); + if !roots.contains(&cand) { + roots.push(cand); + } + } + if roots.is_empty() { + return Vec::new(); + } + let writes: Vec<&PathBuf> = self + .landlock + .write_noexec + .iter() + .chain(self.landlock.write_exec.iter()) + .collect(); + let mut hit = Vec::new(); + for parts in SHARED_CACHES { + let caches: Vec = roots + .iter() + .map(|r| { + let mut cache = r.clone(); + for c in *parts { + cache.push(c); + } + cache + }) + .collect(); + if caches.iter().any(|cache| { + writes + .iter() + // Canonicalise the GRANTED path for the same reason `apply_proc_mode` + // does: a symlink to a shared cache is granted on its target inode, so a + // lexical compare reports `shared_cache_write=none` when the write is + // real. That is the MISREPORT direction of T24 rather than the breach + // direction, and it defeats the disclosure rather than the confinement. + .any(|w| { + let cw = canonical_or_literal(w); + cache.starts_with(&cw) || cw.starts_with(cache) + }) + }) { + hit.push(format!("~/{}", parts.join("/"))); + } + } + hit + } +} + impl Policy { /// Parse from a JSON string (file or env). deny_unknown_fields ⇒ a typo'd /// key errors rather than silently granting nothing. @@ -125,6 +299,12 @@ impl Policy { self.landlock.egress_tcp.is_some() || self.landlock.ingress_tcp.is_some() } + /// True if the policy asks for ANY UDP restriction (incl. deny-all). + /// Drives the hard V10 gate in ring 1 — see [`Landlock::egress_udp`]. + pub fn udp_requested(&self) -> bool { + self.landlock.egress_udp.is_some() || self.landlock.ingress_udp.is_some() + } + /// Inject a workdir into the write_exec tier (from --workdir). §7. pub fn add_workdir(&mut self, dir: PathBuf) { self.landlock.write_exec.push(dir); @@ -137,8 +317,79 @@ impl Policy { /// Rewrite the /proc grant in read_only per `--proc`. Drops every existing /// `/proc*` read_only entry, then re-adds per mode. + /// What `/proc` exposure the policy actually carries, for the seal line's `proc=`. + /// + /// **DERIVED from the tiers, not from the flag.** `CONSTRAINTS.md` CON-5 records that + /// `unhandled=` is weak precisely because it compares a printed token to the *request* + /// rather than to an outcome; repeating that here would add a field that agrees with + /// `--proc` by construction and could never disagree with it. This reads the tiers as + /// they stand after `apply_proc_mode`, so a `--policy` that grants `/proc` by another + /// name is reported as what it is. + /// + /// **What it does NOT establish, stated rather than implied:** these are the tiers + /// oring *asked* Landlock for. `add_tier` skips a path that does not exist + /// (IgnoreIfMissing), so a granted-but-absent `/proc` path would still be counted + /// here. Closing that gap needs the installed ruleset, not the policy — the same + /// structural limit CON-5's `observable-via` already names for every instrument oring + /// ships. + pub fn proc_exposure(&self) -> String { + let under_proc: Vec = self + .all_tiers() + .into_iter() + .flat_map(|(_t, paths)| paths.to_vec()) + .map(|p| canonical_or_literal(&p)) + .filter(|p| p.starts_with("/proc")) + .collect(); + if under_proc.is_empty() { + return "none".to_string(); + } + // The whole subtree, however it was spelled. + if under_proc.iter().any(|p| p == Path::new("/proc")) { + return "full".to_string(); + } + // Resolve the curated list TOO. `/proc/self` is itself a symlink — it resolves to + // `/proc/` — so comparing resolved grants against the literal constant can + // never match and reported `custom:9` for a plain `--proc self`. Measured, one + // commit after fixing the same symlink assumption in four other guards. + let curated: std::collections::BTreeSet = PROC_SELF_READONLY + .iter() + .map(|p| canonical_or_literal(Path::new(p))) + .collect(); + let got: std::collections::BTreeSet = under_proc.iter().cloned().collect(); + if got == curated { + return "self".to_string(); + } + // Neither shape: report the count rather than guessing a label, so an operator + // reading `proc=custom:3` knows to look. + format!("custom:{}", got.len()) + } + pub fn apply_proc_mode(&mut self, mode: ProcMode) { - self.landlock.read_only.retain(|p| !p.starts_with("/proc")); + // CANONICALISE BEFORE COMPARING. `add_tier` opens with `PathFd::new`, which + // FOLLOWS symlinks, so the rule lands on the TARGET inode — a lexical + // `starts_with("/proc")` therefore misses `/tmp/procs -> /proc` and this retain + // leaves the grant in place. + // + // MEASURED 2026-07-29 with `__probe proc-cmdline 1` (world-readable, so gated + // only by the Landlock /proc grant): `/proc` named directly gives EACCES under + // both `--proc self` and `--proc none`; a SYMLINK to `/proc` gives 0 — READABLE — + // under both. `--proc self` is the mode `--help` and DESIGN §2 advertise, and it + // was the unmeasured half of BUILD-QUEUE T24 until this probe. + // EVERY TIER, not just `read_only`. Measured 2026-07-29 with + // `__probe proc-cmdline 1`: under `--proc none` a `/proc` grant in `read_exec` or + // `write_noexec` survived and peer cmdline stayed READABLE — **no symlink needed**. + // The symlink defeat (T24) and this tier gap are two independent routes to the same + // breach, and filtering one tier closed neither properly. + // + // The flag WINS over the policy here, which is what the previous `read_only`-only + // retain already did; refusing on the conflict instead would be defensible and is + // an operator-visible choice, so it is named rather than taken. + let strip = + |v: &mut Vec| v.retain(|p| !canonical_or_literal(p).starts_with("/proc")); + strip(&mut self.landlock.read_only); + strip(&mut self.landlock.read_exec); + strip(&mut self.landlock.write_noexec); + strip(&mut self.landlock.write_exec); match mode { ProcMode::Full => self.landlock.read_only.push(PathBuf::from("/proc")), ProcMode::SelfOnly => self @@ -192,12 +443,16 @@ impl Policy { } } - // Loud warns for the known footguns (§2/§7). + // Loud warns for the known footguns (§2/§7). The UDP-DNS residual is + // conditional now: `egress_udp` covers it at V10+, so warning + // unconditionally would train operators to ignore a warning that no + // longer describes their cage. if let Some(ports) = &self.landlock.egress_tcp { - if ports.contains(&53) { + if ports.contains(&53) && self.landlock.egress_udp.is_none() { eprintln!( - "oring: WARN egress_tcp includes 53 but Landlock does not cover UDP DNS — \ - DNS/UDP egress is unrestricted (see §2)" + "oring: WARN egress_tcp includes 53 but egress_udp is unset — \ + DNS/UDP egress is unrestricted (see §2). On a V10 kernel, set \ + egress_udp to close it." ); } } @@ -221,10 +476,16 @@ impl Policy { } /// Warn if a granted tier holds (or contains) a live AF_UNIX socket, WHEN the - /// pathname-socket wall is not active (built without `resolve-unix`, or a - /// kernel below V9). There, `connect()` to a pathname socket is unmediated — - /// a mounted `docker.sock` or the operator's `/tmp/ssh-*/agent.*` is reachable. - /// At/above the wall this is inert, so we stay silent. + /// pathname-socket wall is not active. There, `connect()` to a pathname socket + /// is unmediated — a mounted `docker.sock` or the operator's + /// `/tmp/ssh-*/agent.*` is reachable. At/above the wall this is inert, so we + /// stay silent. + /// + /// Since the floor was introduced this fires only on a sub-floor kernel — i.e. moments + /// before `landlock::apply` refuses outright — so it is no longer a live lint + /// on any run that reaches exec. It is KEPT deliberately: it is the tripwire + /// that would start warning again if the floor were ever lowered below V9, + /// which is exactly the change that would resurrect the residual. /// /// Scans **two** levels: the granted dir and one level of subdirs. The socket /// that matters most, ssh-agent, lives at `/tmp/ssh-XXXXXX/agent.NNNN` — two @@ -242,7 +503,7 @@ impl Policy { "oring: WARN granted tier holds a unix socket: {} — the pathname-AF_UNIX \ wall is not active, so connect() to it is UNMEDIATED; don't grant dirs \ holding docker.sock/podman.sock/bus or an ssh-agent socket", - sock.display() + crate::diag::safe_display(&sock.display()) ); } } @@ -286,7 +547,13 @@ impl Policy { const SOCKET_FREE_ROOTS: [&str; 3] = ["/proc", "/sys", "/usr"]; for (_tier, paths) in self.all_tiers() { for p in paths { - if SOCKET_FREE_ROOTS.iter().any(|root| p.starts_with(root)) { + // Canonicalise so the prune agrees with what is actually scanned. + // This one fails CONSERVATIVELY on a symlink (it scans a path it could + // have skipped), but the inverse bites: `/proc/../home/x` lexically + // starts_with `/proc` and would be pruned while resolving elsewhere, so a + // socket there would be missed. Fixed for the class, not the direction. + let cp = canonical_or_literal(p); + if SOCKET_FREE_ROOTS.iter().any(|root| cp.starts_with(root)) { continue; } let Ok(md) = std::fs::symlink_metadata(p) else { @@ -322,6 +589,193 @@ impl Policy { /// refusing: that means `/proc/self/exe` is unreadable, an environment /// defect rather than a policy input, and refusing there would brick hosts /// whose policy is in fact sound. The detected-overlap case is a hard error. + /// + /// Companion: [`Policy::check_profile_grants`], which refuses the inverse — + /// a caller-supplied variable widening a *profile's* tiers. + /// Refuse (or warn about) a policy whose tiers OVERLAP such that a path ends + /// up in a wider tier than the one that named it. + /// + /// # Why this is needed at all + /// + /// **The tier lattice is not closed under the union Landlock performs.** + /// Landlock has no deny rule and no unmount: rules on an ancestor and a + /// descendant are OR'd, and the wider grant wins unconditionally. So + /// `read_exec ∪ write_noexec = write_exec` — two grants that each + /// individually satisfy W^X compose into a tier **neither one names**, and + /// the narrower grant is provably inert. + /// + /// Measured consequences before this check existed: + /// * `--workdir /tmp` under `balanced` (which has `/tmp` in `write_noexec`) + /// silently upgraded the whole of `/tmp` to `write_exec` and a script + /// written there executed — while `--exec-tmp`'s own help text promises + /// "`/tmp` itself stays no-exec", `grants=` reported full success, and the + /// seal line was indistinguishable from a sound run. + /// * `TMPDIR=$HOME/.cargo` broke all three of `balanced`'s own documented + /// invariants; the tree's `profile::` unit tests fail under it while oring + /// sealed happily. The invariants were written down and testable, and + /// nothing checked them at seal time. + /// + /// This is the same defect Vitaly Chikunov reported upstream in 2024 as + /// "permission additivity" (`LL_FS_RW=/ LL_FS_RO=/home` → the file is + /// created), where Salaün's reply proposed either OR-ing rules or erroring + /// out. Landlock cannot make the restrictive one win, so oring errors out. + /// + /// # The rule + /// + /// For each declared `(path, tier)`, the EFFECTIVE tier is the union of + /// every declared tier whose path covers it. Then: + /// + /// | condition | verdict | + /// |---|---| + /// | effective grants **write** where declared did not | **REFUSE** | + /// | effective grants **exec** where declared did not, and is writable | **REFUSE** | + /// | effective grants **exec** where declared did not, not writable | WARN | + /// + /// In one sentence: *the effective tier must equal the declared tier, except + /// that `read_only` may effectively be `read_exec`.* The two refusing arms + /// are `landlock.rs`'s own tier invariants — "read_only never grants write" + /// and "W^X" — lifted from per-rule to **per-path-after-union**, which is the + /// level the kernel actually enforces at. The warning arm is inert: nothing + /// writable gains exec, so the child can only execute content it could + /// already read and cannot modify. + /// + /// # The trap this must not fall into + /// + /// A naive "any cross-tier containment" rule REFUSES THE SHIPPED PROFILE: + /// `balanced` puts `~/go` in `read_only` with `~/go/bin` in `read_exec` and + /// `~/go/pkg` in `write_noexec` (§7's deliberate split). Those are + /// *outer-narrower* — the union with a narrower ancestor does not widen + /// anything — and the effective-tier formulation handles them by + /// construction rather than by a special case. Both profiles verify clean. + /// + /// # Residual, stated rather than claimed away + /// + /// Paths are compared after canonicalization, so symlinks are seen but + /// **bind mounts and hard links are not**: if `/mnt/x` bind-mounts `$HOME`, + /// `write_noexec /mnt/x` and `read_exec ~/.cargo/bin` do not compare as + /// nested and the union is missed. Landlock enforces on inodes; this + /// compares paths. It is a pre-seal predicate, not the enforcement boundary. + pub fn check_tier_overlap(&self) -> Result<()> { + // (path, writable, executable, tier name) for every declaration. + let ll = &self.landlock; + let decls: Vec<(PathBuf, bool, bool, &str)> = [ + (&ll.read_only, false, false, "read_only"), + (&ll.read_exec, false, true, "read_exec"), + (&ll.write_noexec, true, false, "write_noexec"), + (&ll.write_exec, true, true, "write_exec"), + ] + .iter() + .flat_map(|(list, w, x, name)| { + list.iter() + .map(move |p| (canonical_or_literal(p), *w, *x, *name)) + }) + .collect(); + + let mut warnings: Vec = Vec::new(); + for (path, dw, dx, dname) in &decls { + // Effective tier: union over every declaration covering this path. + // `Path::starts_with` is component-wise, so /tmpfoo is not "under" + // /tmp — a prefix-string compare here would be a false positive. + let (mut ew, mut ex) = (*dw, *dx); + let mut widened_by: Option<(&Path, &str)> = None; + for (q, qw, qx, qname) in &decls { + if !path.starts_with(q) { + continue; + } + let widens = (*qw && !ew) || (*qx && !ex); + ew |= *qw; + ex |= *qx; + if widens && q != path { + widened_by = Some((q.as_path(), qname)); + } else if widens { + // Same path declared in two tiers — the union still widens. + widened_by = Some((q.as_path(), qname)); + } + } + let Some((by, byname)) = widened_by else { + continue; + }; + let effective = match (ew, ex) { + (false, false) => "read_only", + (false, true) => "read_exec", + (true, false) => "write_noexec", + (true, true) => "write_exec", + }; + // Class A: gained write. Class B: gained exec while writable. + if (ew && !dw) || (ex && !dx && ew) { + // Same path in two tiers reads very differently from an ancestor + // widening a descendant, and the repair is different too. + let how = if by == path.as_path() { + format!( + "{} is declared BOTH {dname} and {byname} — Landlock ORs them", + path.display() + ) + } else { + format!( + "{} is declared {dname}, but its ancestor {} is declared {byname}, and \ + Landlock ORs rules on an ancestor with rules on a descendant", + crate::diag::safe_display(&path.display()), + crate::diag::safe_display(&by.display()) + ) + }; + return Err(OringError::policy(format!( + "tier overlap: {how} — so the EFFECTIVE tier is {effective}. The narrower \ + grant is inert and the cage is wider than the policy says. Refusing rather \ + than sealing a cage whose own description is wrong. Fix by naming the \ + union tier deliberately, or by narrowing the wider grant." + ))); + } + // Class C: gained exec but nothing writable did — inert, so warn. + if ex && !dx { + warnings.push(format!( + "oring: WARN tier overlap: {} is declared {dname} but is effectively \ + {effective}, because {} is declared {byname}. Harmless here (nothing \ + writable gained exec), but the policy does not describe the cage.", + path.display(), + by.display() + )); + } + } + for w in warnings { + eprintln!("{w}"); + } + Ok(()) + } + + pub fn check_profile_grants(&self, home: Option<&Path>) -> Result<()> { + // An operator who writes $HOME into a --policy file means it; a profile + // that arrives there via the environment does not. `user_authored` is + // the flag that already draws that line (:35). + if self.user_authored { + return Ok(()); + } + let Some(home) = home else { + return Ok(()); + }; + let home = canonical_or_literal(home); + for tier in [&self.landlock.write_exec, &self.landlock.write_noexec] { + for p in tier { + let cp = canonical_or_literal(p); + if home.starts_with(&cp) { + return Err(OringError::policy(format!( + "profile self-protection: a built-in profile would grant write over \ + {} , which CONTAINS your home directory ({}) — almost certainly \ + because $TMPDIR points there. balanced honours $TMPDIR so linkers \ + can write scratch, and the only guards were 'absolute' and 'not \ + /tmp'; measured, TMPDIR=$HOME granted write on ~/.ssh and \ + ~/.bashrc. In oring's threat model the CALLER is the adversary, so \ + a caller-supplied variable must not widen a profile's tiers. \ + Refusing rather than sealing that cage. Narrow or unset $TMPDIR, \ + or state the grant deliberately in a --policy file", + cp.display(), + home.display() + ))); + } + } + } + Ok(()) + } + pub fn check_self_protection(&self, policy_file: Option<&Path>) -> Result<()> { let writable: Vec = self .landlock @@ -399,6 +853,91 @@ mod tests { /// A policy that fills SOME tiers but not all is valid — only an *all-empty* /// policy is the refuse-worthy footgun. Pins the empty-check to `&&` (all) /// rather than `||` (any), which would over-reject partial policies. + /// D1b: every named shared cache must be detected individually, in BOTH + /// directions, and an unrelated write tier must report clean. + /// + /// Asserts a per-element property rather than "some cache was found" — the + /// T21 lesson is that a single-element check misses the element nobody + /// listed. Mutating `SHARED_CACHES` (dropping any entry) turns this red. + #[test] + fn every_named_shared_cache_is_detected_individually() { + let Some(h) = crate::profile::home() else { + eprintln!("SKIP: every_named_shared_cache_is_detected_individually: HOME unset"); + return; + }; + let mk = |w: PathBuf| { + let mut ll = Landlock::default(); + ll.write_noexec.push(w); + Policy { + version: 1, + landlock: ll, + seccomp: Seccomp::default(), + user_authored: true, + require_all_grants: false, + require_full_coverage: false, + } + }; + + // HARDCODED, deliberately NOT derived from `SHARED_CACHES`. Deriving the + // expectation from the constant under test makes the check unable to + // fail: dropping an entry would silently drop its assertion too. + // Measured — that is exactly what the first version of this test did, + // and three of five mutations came back green. + let expected = [ + "~/.cargo/registry", + "~/.cargo/git", + "~/.npm", + "~/go/pkg", + "~/.cache", + ]; + assert_eq!( + SHARED_CACHES.len(), + expected.len(), + "SHARED_CACHES changed size — update `expected` deliberately, do not \ + derive it from the constant this test exists to check" + ); + + for label in expected { + let mut cache = h.clone(); + for c in label.trim_start_matches("~/").split('/') { + cache.push(c); + } + let label = label.to_string(); + + // exact grant + assert_eq!( + mk(cache.clone()).writable_shared_caches(), + vec![label.clone()], + "{label}: an exact write grant must be reported" + ); + // grant NESTED inside the cache + assert!( + mk(cache.join("nested")) + .writable_shared_caches() + .contains(&label), + "{label}: a write tier inside the cache must be reported — part of a \ + shared cache is still writable" + ); + } + + // Ancestor grant covers everything beneath it (Landlock unions along the walk). + let anc = mk(h.join(".cargo")).writable_shared_caches(); + assert!( + anc.contains(&"~/.cargo/registry".to_string()) + && anc.contains(&"~/.cargo/git".to_string()), + "an ancestor grant on ~/.cargo must report both caches beneath it, got {anc:?}" + ); + + // Negative control: an unrelated write tier must report clean, or the + // field means nothing. + assert!( + mk(PathBuf::from("/tmp")) + .writable_shared_caches() + .is_empty(), + "an unrelated write tier must not be reported as a shared cache" + ); + } + #[test] fn partial_policy_is_accepted() { let mut ll = Landlock::default(); @@ -409,6 +948,7 @@ mod tests { seccomp: Seccomp::default(), user_authored: false, require_all_grants: false, + require_full_coverage: false, }; assert!( p.validate().is_ok(), @@ -426,6 +966,7 @@ mod tests { seccomp: Seccomp::default(), user_authored: false, require_all_grants: false, + require_full_coverage: false, }; p.add_workdir_noexec(PathBuf::from("/work/scratch")); assert_eq!( @@ -460,6 +1001,7 @@ mod tests { seccomp: Seccomp::default(), user_authored: false, require_all_grants: false, + require_full_coverage: false, }; let found = p.sockets_in_granted_tiers(); @@ -474,4 +1016,195 @@ mod tests { drop((l1, l2)); let _ = std::fs::remove_dir_all(&root); } + + /// **`guard(link_to_X) == guard(X)`** — the property BUILD-QUEUE T24 asks for, over + /// every tier and every mode. + /// + /// `add_tier` opens with `PathFd::new`, which FOLLOWS symlinks, so a rule lands on the + /// target inode. Any guard predicting that must canonicalise, or it silently disagrees + /// with the kernel. Four guards compared lexically; this pins the one that was a + /// measured breach. + /// + /// MEASURED before the fix, with `__probe proc-cmdline 1` (world-readable, so gated + /// only by the Landlock `/proc` grant): `/proc` named directly gave EACCES under both + /// `--proc self` and `--proc none`, while a SYMLINK to `/proc` gave **0 — READABLE** + /// under both. `--proc self` is the mode `--help` and `docs/DESIGN.md` §2 advertise, + /// and it was the unmeasured half of T24 until then. + /// + /// AND A SECOND, INDEPENDENT ROUTE, needing no symlink at all: the retain covered only + /// `read_only`, so a `/proc` grant in `read_exec` or `write_noexec` survived `--proc + /// none` outright. Both routes are quantified here — 4 tiers × 3 modes × 2 spellings. + #[test] + fn proc_mode_treats_a_symlink_exactly_like_its_target_in_every_tier() { + let d = std::env::temp_dir().join(format!("oring-t24-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let link = d.join("procs"); + let _ = std::fs::remove_file(&link); + #[cfg(unix)] + std::os::unix::fs::symlink("/proc", &link).unwrap(); + assert!( + std::fs::canonicalize(&link).is_ok_and(|t| t == std::path::Path::new("/proc")), + "the fixture must really resolve to /proc, or this test proves nothing" + ); + + // Which tier the grant sits in, as a mutable accessor. + type Tier = fn(&mut Landlock) -> &mut Vec; + let tiers: [(&str, Tier); 4] = [ + ("read_only", |l| &mut l.read_only), + ("read_exec", |l| &mut l.read_exec), + ("write_noexec", |l| &mut l.write_noexec), + ("write_exec", |l| &mut l.write_exec), + ]; + let modes = [ + ("full", ProcMode::Full), + ("self", ProcMode::SelfOnly), + ("none", ProcMode::None), + ]; + + let mut cases = 0; + for (tname, tier) in tiers { + for (mname, mode) in modes { + let outcome = |grant: &Path| -> Vec { + let mut p = Policy { + version: 1, + landlock: Landlock::default(), + seccomp: Seccomp::default(), + user_authored: true, + require_all_grants: false, + require_full_coverage: false, + }; + tier(&mut p.landlock).push(grant.to_path_buf()); + p.apply_proc_mode(mode); + let mut all: Vec = p + .landlock + .read_only + .iter() + .chain(p.landlock.read_exec.iter()) + .chain(p.landlock.write_noexec.iter()) + .chain(p.landlock.write_exec.iter()) + .cloned() + .collect(); + all.sort(); + all + }; + let direct = outcome(Path::new("/proc")); + let via_link = outcome(&link); + assert_eq!( + direct, via_link, + "tier {tname}, --proc {mname}: a symlink to /proc must be treated \ + exactly like /proc. `add_tier` follows symlinks, so any difference \ + here is a guard disagreeing with the kernel." + ); + // The property ALONE would be satisfied by both arms leaking equally, so + // pin the absolute outcome per mode too. Written per-mode because a blanket + // "nothing under /proc survives" is FALSE for `self` — that mode grants + // `/proc/self`, `/proc/cpuinfo` and friends by design, and asserting + // otherwise failed loudly here, which is the test being wrong rather than + // the code. + for (label, got) in [("direct", &direct), ("via symlink", &via_link)] { + let under_proc: Vec<&PathBuf> = got + .iter() + .filter(|p| canonical_or_literal(p).starts_with("/proc")) + .collect(); + match mname { + "none" => assert!( + under_proc.is_empty(), + "tier {tname}, --proc none, {label}: {under_proc:?} survived — \ + `none` must leave NOTHING resolving under /proc" + ), + "self" => { + let allowed: Vec = + PROC_SELF_READONLY.iter().map(PathBuf::from).collect(); + for p in &under_proc { + assert!( + allowed.contains(p), + "tier {tname}, --proc self, {label}: {p:?} survived and \ + is not in PROC_SELF_READONLY — `self` grants only the \ + named self-scoped paths" + ); + } + } + _ => {} + } + } + cases += 1; + } + } + assert_eq!(cases, 12, "4 tiers x 3 modes, each checked both spellings"); + std::fs::remove_dir_all(&d).ok(); + } + + /// `proc=` must be DERIVED from the tiers, so it can disagree with `--proc`. + /// + /// The weakness `CONSTRAINTS.md` CON-5 records about `unhandled=` is that it compares + /// a printed token to the *request*; a `proc=` that echoed the flag would repeat it and + /// could never disagree. The last case here is the one that matters: a policy granting + /// a `/proc` path with `ProcMode::None` applied reports `custom:1`, **not** `none`. + #[test] + fn proc_exposure_is_derived_from_the_tiers_not_from_the_flag() { + let d = std::env::temp_dir().join(format!("oring-procx-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let link = d.join("procs"); + let _ = std::fs::remove_file(&link); + #[cfg(unix)] + std::os::unix::fs::symlink("/proc", &link).unwrap(); + + let mk = |grants: Vec, mode: Option| -> String { + let ll = Landlock { + read_only: grants, + ..Default::default() + }; + let mut p = Policy { + version: 1, + landlock: ll, + seccomp: Seccomp::default(), + user_authored: true, + require_all_grants: false, + require_full_coverage: false, + }; + if let Some(m) = mode { + p.apply_proc_mode(m); + } + p.proc_exposure() + }; + + assert_eq!(mk(vec![PathBuf::from("/etc")], None), "none"); + assert_eq!(mk(vec![PathBuf::from("/proc")], None), "full"); + assert_eq!( + mk(vec![link.clone()], None), + "full", + "a symlink to /proc is /proc — `add_tier` follows it" + ); + assert_eq!( + mk(PROC_SELF_READONLY.iter().map(PathBuf::from).collect(), None), + "self", + "the curated set must resolve to `self`; /proc/self is itself a symlink, so \ + both sides have to be canonicalised or this reports custom" + ); + assert_eq!(mk(vec![PathBuf::from("/proc/cpuinfo")], None), "custom:1"); + + // THE PROPERTY: the field reports the tiers, not the flag. `apply_proc_mode` runs + // first and strips /proc, then the grant is re-added the way a `--policy` would. + let ll = Landlock { + read_only: vec![PathBuf::from("/etc")], + ..Default::default() + }; + let mut p = Policy { + version: 1, + landlock: ll, + seccomp: Seccomp::default(), + user_authored: true, + require_all_grants: false, + require_full_coverage: false, + }; + p.apply_proc_mode(ProcMode::None); + p.landlock.read_only.push(PathBuf::from("/proc/cpuinfo")); + assert_eq!( + p.proc_exposure(), + "custom:1", + "with ProcMode::None applied and a /proc path still granted, the seal line must \ + say custom:1 — a field that echoed the flag would say `none` and be wrong" + ); + std::fs::remove_dir_all(&d).ok(); + } } diff --git a/src/profile.rs b/src/profile.rs index 8c4f5cd..1c32184 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -14,7 +14,7 @@ fn paths(list: &[&str]) -> Vec { } /// Absolute $HOME, if set. Toolchain caches live under it. -fn home() -> Option { +pub(crate) fn home() -> Option { std::env::var_os("HOME") .map(PathBuf::from) .filter(|p| p.is_absolute()) @@ -36,15 +36,88 @@ pub fn balanced() -> Policy { "/usr/libexec", "/opt", ]); - let mut read_only = paths(&["/etc", "/usr/share", "/proc"]); - // /run dropped from balanced: granting it broadly exposes docker.sock / - // podman.sock / journald sockets (connect() to a pathname socket is - // unmediated by both rings, §2). Add /run/user/ via --policy if a tool - // genuinely needs XDG_RUNTIME_DIR. - let mut write_noexec = paths(&[ + // `/etc/resolv.conf` is named EXPLICITLY, in addition to `/etc`, and the + // redundancy is the point: on most modern hosts it is a **symlink out of + // `/etc`** and the `/etc` grant does not reach its target. + // + // Landlock rules an inode subtree. A symlink makes the VFS restart the walk + // at the target path — `/run/systemd/resolve/stub-resolv.conf` on + // Fedora/Ubuntu, `/run/NetworkManager/resolv.conf` under NM, + // `/run/connman/...` under ConnMan — which never touches `/etc`, so no rule + // matches and the open is denied. (A *bind mount*, as containers use, keeps + // the path: Landlock walks up to the mount root and `follow_up`s into + // `/etc`, where the rule does match. So the container case worked and the + // systemd case did not — the opposite of what "different filesystem" would + // predict. The dividing line is symlink vs mount, and it is a property of + // Landlock, not of resolv.conf.) + // + // This entry needs no new machinery because `add_tier` classifies with + // `std::fs::metadata` and opens with `PathFd::new`, both of which FOLLOW + // symlinks (see the usrmerge note there). The rule therefore lands on the + // resolved target inode, whatever it happens to be — so one literal path + // covers every layout above without hardcoding any of them. + // + // Exposure is exactly ONE additional readable inode — measured against the + // alternatives, all rejected: granting `/run/systemd/resolve` hands over the + // *uplink* resolv.conf (the real upstream nameserver — network-topology + // recon for an untrusted child) plus a directory listing; generalising to + // "the target's parent directory" is worse still, because on an NM host that + // is `/run/NetworkManager`, holding `system-connections/*.nmconnection` — + // WiFi PSKs and 802.1x secrets. + // + // WHY THIS IS A SECURITY FIX AND NOT A CONVENIENCE ONE: with the file + // unreadable, glibc's `dns` module does not fail closed. It falls back to + // its compiled-in default nameserver `127.0.0.1:53` — measured, with a UDP + // catcher: the query really goes there. On any host running dnsmasq, + // unbound, bind or a dev stub on that address, the confined child silently + // resolves through a resolver the operator never chose, bypassing + // systemd-resolved's split-DNS routing, per-link domains, DNSSEC and DoT. + // Silent wrong-resolver is a worse failure than the visible outage. + // + // Residual, stated rather than hidden: the grant pins an INODE, so an atomic + // replace (write-temp + rename, which systemd-resolved does) invalidates it + // for an already-sealed child — measured. Bounded by systemd's + // `conservative_renameat`, which removes the temp instead of renaming when + // content is unchanged, so the inode survives no-op rewrites. + let mut read_only = paths(&["/etc", "/etc/resolv.conf", "/usr/share", "/proc"]); + // /run stays out of balanced. The original reason — pathname-socket + // connect() being unmediated — is gone since the floor walls that class + // outright [D-4b], but least privilege is reason enough: no build tool + // needs /run. Add /run/user/ via --policy for a genuine + // XDG_RUNTIME_DIR case. Note `/etc/resolv.conf` above resolves INTO /run on + // most hosts — that single inode is the deliberate exception, not a crack in + // this rule. + // + // /var/log and /var/cache are NOT granted. Nothing a build does writes + // there, and both are shared system sinks: /var/log is where evidence of + // the session lands, /var/cache holds packages a later root process + // installs. On a normal host DAC already refuses the write, which is + // precisely why granting them looked free — the grant is dead weight that + // becomes a real widening on any host where a subdirectory is group- or + // world-writable. A tier that only ever matters when something else is + // misconfigured is a tier to delete. + // NOT `mut`, since 2026-07-29. The `mut` was residue from the shared-cache grant + // removal (D1): this binding used to be pushed to, and once those pushes went the + // compiler started warning and the warning sat in the build. That matters more than + // one keyword — a security tool whose build prints warnings is a tool whose NEXT + // warning goes unread. + // + // It is also one half of CON-2's mutation point, so the exact recipe is written down + // rather than left to be re-derived. That row's `enforced` status rests on re-adding + // a shared-cache grant and watching + // `no_profile_grants_write_on_a_shared_package_cache` turn red, and the mutation is + // now TWO lines in TWO places: + // + // 1. restore `mut` on this binding, and + // 2. add `write_noexec.push(h.join(".cargo/registry"));` inside the + // `if let Some(h) = home()` block below — NOT here, where `h` is not yet bound. + // + // Verified 2026-07-29: that mutant panics at the assertion in this file's tests; + // reverted, sha256 identical. An earlier attempt put the push at this binding and did + // not compile, which is worth recording because a mutation that fails to build is + // indistinguishable from one the test caught if you only check for a red run. + let write_noexec = paths(&[ "/tmp", - "/var/log", - "/var/cache", "/var/tmp", // Standard character devices (file-set → RW+ioctl, no exec). No real // program runs without /dev/null; RNG needs /dev/urandom. /dev/tty when @@ -88,26 +161,121 @@ pub fn balanced() -> Policy { read_exec.push(h.join(".rustup")); // rustc/cargo toolchain binaries read_only.push(h.join(".gitconfig")); // git identity - // Read the cache roots (cargo needs ~/.cargo/config.toml, ~/.cargo/env) - // WITHOUT granting write on them — they are parents of bin/. - read_only.push(h.join(".cargo")); + // git's XDG spelling of the SAME files. `~/.gitconfig` and + // `$XDG_CONFIG_HOME/git/config` are one thing in two locations — the + // identical relationship `.cargo/config.toml` / `.cargo/config` has + // below, and the same reason for granting both. + // + // Named individually rather than granting `~/.config/git/`, for the + // reason stated on the cargo lines: a directory grant unions down the + // path walk and sweeps whatever the tool puts there later. These three + // are the closed set git reads from that directory. + // + // WHY THIS IS NOT COSMETIC (T23, measured 2026-07-28). Landlock hooks + // the open and NOT the metadata check, so a denied path answers + // `access(R_OK)` → true, `stat()` → ok, `open()` → EACCES. **DAC cannot + // produce that combination** — `chmod 000` fails both together. git + // pre-flights with `access()`, is told the file is readable, then dies + // when the open fails. Ungranted, `~/.config/git/config` made EVERY git + // operation fail in-cage with `fatal: unknown error occurred while + // reading the configuration files` — naming neither the file nor the + // sandbox. `ignore` is fatal too; `attributes` degrades cleanly and is + // granted anyway, because granting two of the three would leave a + // silent behavioural difference instead of a loud one. + // + // This grant fixes the INSTANCE. The class — any tool that pre-flights + // then opens — is a DESIGN §2 residual and is not closeable here. + // `notes/2026-07-28-landlock-access-open-divergence.md`. + read_only.push(h.join(".config").join("git").join("config")); + read_only.push(h.join(".config").join("git").join("ignore")); + read_only.push(h.join(".config").join("git").join("attributes")); + + // cargo needs its CONFIG, not its directory. A `read_only` grant on + // `~/.cargo` unions down the path walk and sweeps in + // `credentials.toml` — the crates.io token, plaintext — along with any + // `[registries.*] token` in config. That is the same union reasoning + // that forced the write-side bin/ split below, applied to the read + // axis; it was missed here. Name the files instead. A build or test + // never needs the token: only `cargo publish` and authenticated + // registry operations do, and those are not what a cage is for. + read_only.push(h.join(".cargo").join("config.toml")); + read_only.push(h.join(".cargo").join("config")); // pre-1.39 spelling + read_only.push(h.join(".cargo").join("env")); read_only.push(h.join("go")); read_exec.push(h.join(".cargo").join("bin")); read_exec.push(h.join("go").join("bin")); - // Write only where the toolchains actually write, none of it a parent - // of bin/. `.package-cache` is cargo's top-level lock FILE; add_tier - // routes it to the file mask, so this grants write to that inode alone - // and not to `~/.cargo`. It is skipped if absent — a $HOME that has - // never run cargo has no lock file yet, and cargo cannot create one - // under a read-only `~/.cargo` (see the compat note in DESIGN §7). - write_noexec.push(h.join(".cargo").join("registry")); - write_noexec.push(h.join(".cargo").join("git")); - write_noexec.push(h.join(".cargo").join(".package-cache")); - write_noexec.push(h.join("go").join("pkg")); - - write_noexec.push(h.join(".npm")); - write_noexec.push(h.join(".cache")); + // Shared package caches: READABLE, never writable. + // + // CORRECTED 2026-07-28, hours after the first attempt, and the bug is + // worth stating because it is not obvious: `write_noexec` CONFERRED + // READ. The first cut simply deleted those lines, which removed the + // paths from every tier — so `cargo build --offline` failed against a + // fully warm registry with "no matching package found / location + // searched: crates.io index". The runtime check that was run + // ("the write now returns EACCES") CANNOT DISTINGUISH "write removed" + // from "path removed entirely" — both produce EACCES. The functionality + // check was run against a hand-built read-only `CARGO_HOME`, i.e. a + // model of the change rather than the change. Demote, do not delete. + read_only.push(h.join(".cargo").join("registry")); + read_only.push(h.join(".cargo").join("git")); + read_only.push(h.join(".npm")); + // `~/go/pkg` needs no line: `~/go` is already read_only above. + // `~/.cache` is deliberately NOT restored — see the note below. + + // NO WRITE GRANT ON ANY SHARED PACKAGE CACHE. Removed 2026-07-28 by + // operator decision (the maintainer's decision log, D1). This block used + // to grant `write_noexec` on `~/.cargo/{registry,git,.package-cache}`, + // `~/go/pkg`, `~/.npm` and `~/.cache`, on the reasoning "write only + // where the toolchains actually write, none of it a parent of bin/". + // That reasoning was sound about `bin/` and wrong about the caches. + // + // WHY, MEASURED END-TO-END 2026-07-28: cargo verifies a tarball once at + // download and never verifies anything on disk again. `.cargo-ok` is a + // version tag with no hash; the `Source::verify` hook exists and + // `RegistrySource` does not implement it; a cache hit on a `.crate` + // checks only that the file is non-empty. So a payload appended to a + // `build.rs` under `registry/src`, with `.cargo-ok` left untouched, + // executes at the OPERATOR's uid on an ordinary `cargo build` — or on + // `cargo check` alone, which is what an editor runs — in a completely + // unrelated project. Cross-project, persistent across sessions, and + // absent from both repos' git history. + // + // W^X buys nothing against it: the chain plants SOURCE that a later + // toolchain compiles, it does not execute from the cache. And this is + // not the workdir-poisoning residual — that tree is the one the agent + // exists to edit and the operator knows is tainted. These are not. + // + // WHAT THIS COSTS, MEASURED: a warm offline build against a fully + // read-only CARGO_HOME succeeds (rc=0) — cargo tolerates an unwritable + // lock file and an unwritable registry. What breaks is FETCHING a + // dependency that is not already cached, and it breaks loudly, naming + // the crate. Pre-warm outside the cage, or grant a cache explicitly + // with `--policy`. + // + // Three designs to AVOID re-proposing; each was tried and failed: + // - read-only `registry/src` only: cargo's GC evicts `src` at 1 month + // and `cache` at 3, then re-extracts from a length-checked-only + // tarball, so the poison outlives the eviction. + // - a private per-scope `CARGO_HOME`: that is a `CARGO_HOME` whose + // `config.toml` the agent owns, and `[build] rustc-wrapper` there is + // code execution — reopening the hole `config.toml` above closes. + // - seeding a private scope from the shared tree: the agent owns its + // scope's symlinks and aims them at the shared tree, so the + // unconfined seeder writes the poison for it. + // + // `~/.cache` IS NOT RESTORED, and that is a separate judgement from the + // write question. It is an XDG namespace whose membership third parties + // decide after this policy was written — `ccache`, `sccache`, `bazel`, + // `uv`, `pre-commit` each join it on the next install — and it holds a + // credential (`~/.cache/huggingface/token`), which is the same + // credentials-in-a-granted-tier defect this file already guards for + // `~/.cargo/credentials.toml` by name. Read access to it was collateral + // from the old write grant, not a considered decision. If a build needs + // a specific subdirectory, name it in `--policy`. NOTE `~/.cache/go-build` + // is `GOCACHE` and is therefore unavailable in the cage — unmeasured, + // and the honest statement is that Go builds are not covered by the + // cargo cost measurement below. } else { // No HOME → no toolchain caches granted; a subsequent `cargo build` will // EACCES with no obvious cause. Announce it (matches the skip-path @@ -119,22 +287,50 @@ pub fn balanced() -> Policy { ); } - // Honor $TMPDIR when it differs from /tmp — linkers (cc/ld) and many build - // tools write scratch there; without it `cargo build` fails at link. Kept - // write_noexec (W^X): temp objects are written, not executed. Tools that - // exec from scratch (`go test`/`go run`) need an exec scratch via --policy. - if let Some(t) = std::env::var_os("TMPDIR") { - let p = PathBuf::from(t); - if p.is_absolute() && p.as_path() != std::path::Path::new("/tmp") { - write_noexec.push(p); - } - } + // $TMPDIR IS NO LONGER READ FROM THE ENVIRONMENT. It used to be pushed into + // write_noexec here, guarded only by "absolute" and "not /tmp". + // + // In oring's threat model the CALLER is the adversary, and this made a + // caller-supplied environment variable widen a built-in profile's tiers. + // `check_profile_grants` was added to catch the obvious case and refuses a + // tier that CONTAINS $HOME — but it tests `home.starts_with(tier)`, so + // narrowing by one component walked straight past it. Measured: + // `TMPDIR=$HOME/.ssh --profile balanced` sealed, and the child got + // `O_WRONLY` on `~/.ssh/authorized_keys` — the exact escalation that + // refusal message claims to prevent. `TMPDIR=$HOME/.cargo` additionally + // broke all three of this profile's own documented invariants, and the + // tree's `profile::` unit tests fail under it while oring sealed happily. + // + // NO PREDICATE ON THE PATH FIXES THIS. Each candidate was tested and each + // either misses the case or breaks a legitimate one: a symmetric $HOME guard + // breaks `TMPDIR=~/tmp` and misses `/usr/local/bin` and `/etc`; banning + // dot-paths misses `~/Mail` and wrongly rejects `~/.cache/tmp`; requiring a + // sticky or world-writable dir breaks `/run/user/` (0700); + // caller-created-this-run is forgeable by the caller who owns $HOME. The + // decisive point: **`~/.ssh` is an existing, caller-owned, mode-0700 + // directory — exactly the shape `create_exec_tmp` accepts as a GOOD scratch + // dir.** The distinguishing information is intent, and intent is not in the + // environment. + // + // So oring stops taking the path from there and takes it from the command + // line instead: `--tmpdir` (write_noexec) or `--exec-tmp` (write_exec). Both + // are operator assertions rather than ambient state, and both are validated. + // oring then SETS the child's `TMPDIR` to whatever it actually granted, so + // the grant and the variable can no longer disagree. + // + // Prior art is unanimous — no unprivileged sandbox honours a caller-supplied + // $TMPDIR as a path specification: bubblewrap never reads it; flatpak + // unconditionally UNSETS TMPDIR/TEMP/TMP/TEMPDIR ("their session values are + // likely to interfere with the functioning of the sandbox"); systemd SETS + // $TMPDIR=/tmp under PrivateTmp=; sudo's env_reset drops it; glibc's + // tempnam(3) honours it "only when the program is not set-user-ID". Policy { version: 1, // Built-in profile: paths are best-effort defaults, not author intent. user_authored: false, require_all_grants: false, + require_full_coverage: false, landlock: Landlock { read_exec, // /proc read is required by almost every real toolchain (Go/node @@ -146,6 +342,19 @@ pub fn balanced() -> Policy { write_noexec, egress_tcp: Some(vec![443]), ingress_tcp: Some(Vec::new()), // explicit deny-all binds + // DNS out, and NOTHING else in UDP. Port 0 on the ingress side is + // not an inbound grant: it is the kernel's synthetic port for + // AUTOBIND of a sending socket's ephemeral source port, so without + // it the egress grant above is inert and DNS fails closed. See the + // autobind note in landlock.rs, which warns on exactly that pair. + // + // These were `None` — UNHANDLED, i.e. unrestricted UDP — because a + // V10-only default would have refused to seal on every kernel in + // the field. FLOOR_ABI is now 10, so oring refuses those kernels + // regardless and that reason is gone; leaving them unset would have + // kept the §2 UDP/DNS residual for nothing. + egress_udp: Some(vec![53]), + ingress_udp: Some(vec![0]), scope_ipc: true, }, seccomp: Seccomp { @@ -169,6 +378,7 @@ pub fn strict() -> Policy { // Built-in profile: paths are best-effort defaults, not author intent. user_authored: false, require_all_grants: false, + require_full_coverage: false, landlock: Landlock { read_exec: paths(&[ "/usr/bin", @@ -197,6 +407,16 @@ pub fn strict() -> Policy { // §2 residual. egress_tcp: Some(Vec::new()), ingress_tcp: Some(Vec::new()), + // Deny-all UDP, matching the TCP posture above. Empty-beside-empty + // is the coherent case the autobind warning deliberately does not + // fire on: nothing may be sent, so no source port is needed. + // + // These were `None`, which left the STRICTER profile with deny-all + // TCP and UNRESTRICTED UDP egress — the §2 residual at its most + // surprising, and the widest gap between posture and intent of + // anything oring shipped. At FLOOR_ABI 10 it closes. + egress_udp: Some(Vec::new()), + ingress_udp: Some(Vec::new()), scope_ipc: true, }, seccomp: Seccomp { @@ -205,13 +425,18 @@ pub fn strict() -> Policy { } } +/// Every built-in profile, name beside constructor. **This is the span every +/// ∀-over-profiles test quantifies over**, which is why `by_name` is derived +/// from it rather than being a second `match` beside it: a profile that is +/// resolvable but absent from this table would be silently exempt from all of +/// them, and the tests would still report a pass over the wrong span. +pub type Profile = (&'static str, fn() -> Policy); + +pub const ALL: &[Profile] = &[("balanced", balanced), ("strict", strict)]; + /// Resolve a profile name to its baked policy. pub fn by_name(name: &str) -> Option { - match name { - "balanced" => Some(balanced()), - "strict" => Some(strict()), - _ => None, - } + ALL.iter().find(|(n, _)| *n == name).map(|(_, ctor)| ctor()) } /// Does the named profile put `--workdir` in write_exec (true) or write_noexec @@ -225,6 +450,22 @@ mod tests { use super::*; use std::path::Path; + /// EVERY path in EVERY tier, for both baked profiles. + /// + /// A credential must not be READABLE either, so the write-only view is the + /// wrong span for the check below — `read_only` is exactly where a + /// convenience grant for `~/.ssh/known_hosts` would land. + fn all_tiers(p: &crate::policy::Policy) -> Vec { + p.landlock + .read_exec + .iter() + .chain(p.landlock.read_only.iter()) + .chain(p.landlock.write_exec.iter()) + .chain(p.landlock.write_noexec.iter()) + .cloned() + .collect() + } + /// Every path in a write tier, for both baked profiles. fn write_tiers(p: &crate::policy::Policy) -> Vec { p.landlock @@ -235,6 +476,102 @@ mod tests { .collect() } + /// No built-in profile may leave a net right UNHANDLED. + /// + /// `None` on one of these fields does not mean "denied", it means oring never + /// names the right to the kernel and the child keeps it in full. That is how + /// `strict` shipped with deny-all TCP beside UNRESTRICTED UDP egress — a + /// DNS/exfil path out of the profile whose whole promise was no egress. + /// + /// Asserted over every field rather than the two UDP ones, and over `ALL` + /// rather than a written-out pair, so this closes the SHAPE of the defect + /// instead of the instance: a future net right added to `Landlock`, or a + /// third profile, is covered the day it exists. + #[test] + fn no_profile_leaves_a_net_right_unhandled() { + for (name, ctor) in ALL { + let ll = ctor().landlock; + for (field, handled) in [ + ("egress_tcp", ll.egress_tcp.is_some()), + ("ingress_tcp", ll.ingress_tcp.is_some()), + ("egress_udp", ll.egress_udp.is_some()), + ("ingress_udp", ll.ingress_udp.is_some()), + ] { + assert!( + handled, + "{name}: {field} is None — the right is UNHANDLED, so the child \ + keeps it entirely. Write Some(vec![]) for an explicit deny-all." + ); + } + } + } + + /// A profile granting UDP egress must also permit ephemeral autobind. + /// + /// `sendto` on an unbound socket acquires its source port via kernel + /// autobind, which Landlock checks against a synthetic **port 0**. So + /// handling `BindUdp` without granting 0 denies the source port and with it + /// every datagram `egress_udp` appears to permit: the grant is INERT and the + /// profile silently does not do the thing it says. `landlock::apply` warns on + /// this at run time; for shipped profiles it is a build-time property, because + /// a warning nobody reads is not a control. + #[test] + fn every_profile_granting_udp_egress_permits_autobind() { + for (name, ctor) in ALL { + let ll = ctor().landlock; + let (Some(eg), Some(ing)) = (&ll.egress_udp, &ll.ingress_udp) else { + continue; // handled by no_profile_leaves_a_net_right_unhandled + }; + if !eg.is_empty() { + assert!( + ing.contains(&0), + "{name}: egress_udp grants {eg:?} but ingress_udp {ing:?} omits port 0 \ + — autobind is denied, so the egress grant sends nothing" + ); + } + } + } + + /// The shipped UDP decision itself: `balanced` resolves names and nothing + /// more, `strict` sends no datagrams at all. + /// + /// The two properties above are shape checks — both would still pass if + /// `balanced` quietly grew a grant on :123 or :5353. This is the VALUE, and + /// it is a deliberate narrowing: DNS is the one UDP port a toolchain needs to + /// fetch anything, and it is also the classic exfil channel, so widening it + /// is a decision that should have to edit a test that says so. + /// + /// Measured at this posture on 7.2.0-rc4, via bash `/dev/udp` under each + /// profile with an unsealed control: balanced :53 permitted / :9 denied, + /// strict both denied, and `getent hosts` resolves under balanced (0) but + /// not under strict (2). + #[test] + fn the_shipped_udp_posture_is_dns_only_or_deny_all() { + let b = balanced().landlock; + assert_eq!( + b.egress_udp.as_deref(), + Some(&[53u16][..]), + "balanced grants DNS out and nothing else" + ); + assert_eq!( + b.ingress_udp.as_deref(), + Some(&[0u16][..]), + "balanced permits autobind only — port 0 is not an inbound service grant" + ); + + let s = strict().landlock; + assert_eq!( + s.egress_udp.as_deref(), + Some(&[][..]), + "strict sends no UDP; empty is an explicit deny-all, not an omission" + ); + assert_eq!( + s.ingress_udp.as_deref(), + Some(&[][..]), + "strict binds no UDP port, not even for autobind" + ); + } + /// The invariant the tier split exists to hold, stated so that /// reverting the split fails here rather than shipping green. /// @@ -251,10 +588,8 @@ mod tests { /// so this runs anywhere the crate compiles. #[test] fn no_write_tier_is_an_ancestor_of_a_path_bin_dir() { - for name in ["balanced", "strict"] { - let Some(p) = by_name(name) else { - panic!("{name}: profile missing"); - }; + for (name, ctor) in ALL { + let p = ctor(); let writes = write_tiers(&p); for exec_dir in &p.landlock.read_exec { // Only the $PATH-bearing bin dirs matter: a writable /usr/bin @@ -276,6 +611,186 @@ mod tests { } } + /// No shipped profile may grant write on a SHARED, CROSS-PROJECT package + /// cache. This is the negative assertion D1 turned on: the mechanism half + /// of "an out-of-tier write is denied" was always tested, but nothing + /// asserted that the *shipped profiles* leave these paths out of every + /// write tier — and a convenience grant added later would have kept the + /// whole suite green while reopening the class. + /// + /// Measured 2026-07-28: with these granted, a payload appended to a + /// `build.rs` under `~/.cargo/registry/src` executes at the operator's uid + /// on an ordinary `cargo build`, or on `cargo check` alone, in an unrelated + /// project. Decided in the maintainer's decision log, D1 — that log is not in + /// this repository, so the reasoning that matters is restated above. + /// + /// This asserts the ABSENCE of a grant, so it can only fail by someone + /// adding one back. That is exactly its job. + /// T16: no shipped profile grants ANY access to a host credential store. + /// + /// This is the first sentence of the guarantee list in `docs/RESIDUALS.md` + /// and of the README — *"FS access outside policy tiers — host creds + /// (`~/.ssh`, `~/.aws`)"* — and until now **nothing tested it**. + /// `read_outside_tiers_is_eacces` builds its own policy, so it proves + /// Landlock denies an out-of-tier read; it says nothing about whether the + /// SHIPPED profiles leave these paths out of every tier. A convenience grant + /// added to `balanced()` would keep the entire suite green while falsifying + /// the example a reader meets first. + /// + /// Checked over ALL FOUR tiers, not just the write ones: `read_only` is + /// precisely where a well-meaning `~/.ssh/known_hosts` or `~/.netrc` grant + /// would be added, and reading a private key is the loss this bullet names. + /// + /// **Ancestor test, both directions.** A tier that IS a credential path and a + /// tier that CONTAINS one are both failures — and so is a credential path + /// that contains a tier, which is how `~/.ssh` would be caught if someone + /// granted `~/.ssh/config` specifically. + #[test] + fn no_profile_grants_any_access_to_a_credential_store() { + let Some(h) = home() else { + eprintln!("SKIP: no_profile_grants_any_access_to_a_credential_store: HOME unset"); + return; + }; + + // VACUITY CONTROL. If HOME resolution silently produced a tier set with + // nothing under it, every assertion below would hold over an empty span + // and this test would be a clean-looking zero. `balanced` is documented + // to grant the toolchain caches, so it must have at least one tier under + // $HOME for the check to mean anything. (`strict` grants no $HOME at all + // by design, which is why the control is asserted on `balanced` only.) + let balanced_home_tiers = all_tiers(&balanced()) + .into_iter() + .filter(|t| t.starts_with(&h)) + .count(); + assert!( + balanced_home_tiers > 0, + "VACUOUS: `balanced` has no tier under {} at all, so a credential check over $HOME paths asserts nothing. Either HOME resolution is broken here or the profile stopped granting the toolchain caches — both make this test a clean-looking zero rather than a pass.", + h.display() + ); + + // Host credential stores. Each is a store a compromised child reaching it + // would be a BREACH of the first guarantee oring advertises. + let creds = [ + h.join(".ssh"), + h.join(".aws"), + h.join(".gnupg"), + h.join(".netrc"), + h.join(".kube"), + h.join(".docker").join("config.json"), + h.join(".config").join("gh"), + // Shell history: not a credential store by design, but routinely a + // credential store in practice. + h.join(".bash_history"), + h.join(".zsh_history"), + ]; + + for (name, ctor) in ALL { + let p = ctor(); + for tier in all_tiers(&p) { + for cred in &creds { + assert!( + !cred.starts_with(&tier) && !tier.starts_with(cred), + "{name}: {} is reachable via tier {} — this is the FIRST example in the guarantee list in README.md and docs/RESIDUALS.md (\"host creds (~/.ssh, ~/.aws)\"), so granting it does not merely widen the cage, it falsifies the sentence a reader meets first. If a credential path must be reachable, the operator names it in a hand-written --policy and owns that decision; a shipped profile may not make it for them.", + cred.display(), + tier.display() + ); + } + } + } + } + + #[test] + fn no_profile_grants_write_on_a_shared_package_cache() { + let Some(h) = home() else { + eprintln!("SKIP: no_profile_grants_write_on_a_shared_package_cache: HOME unset"); + return; + }; + // Cross-project caches a later UNCONFINED toolchain reads. + let caches = [ + h.join(".cargo").join("registry"), + h.join(".cargo").join("git"), + // The lock FILE inode. It was granted write until 2026-07-28 and is + // listed here because it is not a descendant of any path above, so + // re-adding exactly that one grant would otherwise leave this test + // green — the single omission the first mutation run happened not + // to probe. + h.join(".cargo").join(".package-cache"), + h.join("go").join("pkg"), + h.join(".npm"), + h.join(".cache"), + ]; + for (name, ctor) in ALL { + let p = ctor(); + for w in write_tiers(&p) { + for cache in &caches { + assert!( + !cache.starts_with(&w) && &w != cache, + "{name}: {} is writable via tier {} — a confined child can plant a \ + build.rs/postinstall/setup.py there that a LATER UNCONFINED build \ + in an UNRELATED project compiles and runs at the operator's uid. \ + Cross-project, persistent, absent from both repos' git history. \ + W^X does not help: the chain plants SOURCE, it does not exec from \ + the cache. If a cache must be writable, the operator grants it \ + explicitly with --policy.", + cache.display(), + w.display() + ); + } + } + } + } + + /// Both spellings of a tool's config are granted, or the tool breaks in a + /// way that names neither the file nor the sandbox (T23). + /// + /// The expected set is written out LONGHAND on purpose. Deriving it from + /// the profile — or from a shared constant — is what made the T22 test + /// unable to fail: three of five mutations came back green because the + /// expectation moved with the thing under test. The length assertion forces + /// a deliberate edit here when a path is added, rather than a silent one. + #[test] + fn both_spellings_of_a_config_are_readable() { + let Some(h) = home() else { + eprintln!("SKIP: both_spellings_of_a_config_are_readable: HOME unset"); + return; + }; + let Some(p) = by_name("balanced") else { + panic!("balanced profile missing"); + }; + let expected = [ + h.join(".gitconfig"), + h.join(".config").join("git").join("config"), + h.join(".config").join("git").join("ignore"), + h.join(".config").join("git").join("attributes"), + h.join(".cargo").join("config.toml"), + h.join(".cargo").join("config"), + ]; + assert_eq!( + expected.len(), + 6, + "update this test deliberately when the set changes" + ); + // Readable = named in read_only/read_exec, or beneath something that is. + let readable: Vec<&PathBuf> = p + .landlock + .read_only + .iter() + .chain(p.landlock.read_exec.iter()) + .collect(); + for want in &expected { + assert!( + readable.iter().any(|r| want.starts_with(r)), + "balanced does not grant read on {} — a tool that pre-flights with \ + access() (which Landlock leaves answering from DAC) will be told the \ + file is readable, fail the open with EACCES, and take a die() branch. \ + Measured: without ~/.config/git/config every git operation in-cage \ + fails with a message naming neither the file nor the sandbox. \ + Grant BOTH spellings or neither.", + want.display() + ); + } + } + /// Guards the specific grants by name, so a rename or a "helpful" widening /// back to `write_exec.push(h.join(".cargo"))` is caught even if the generic /// invariant above is somehow satisfied. @@ -315,4 +830,60 @@ mod tests { } let _ = Path::new("/"); } + + /// The read-side counterpart of the split above, and the one that was + /// missed: EVERY tier confers read, so a `read_only` grant on `~/.cargo` + /// unions down onto `credentials.toml` and hands a confined child the + /// crates.io token in plaintext. One unprivileged read, no kernel bug, no + /// cooperating peer — and a leaked publish token is not revocable in any + /// way that undoes what was published with it. It also falsifies the + /// stated invariant that credentials are withheld by construction. + /// + /// Guards the credential paths by name so a "helpful" re-widening back to + /// `read_only.push(h.join(".cargo"))` fails here rather than shipping. + #[test] + fn cargo_credentials_are_never_readable() { + let Some(p) = by_name("balanced") else { + panic!("balanced profile missing"); + }; + let Some(h) = home() else { + eprintln!("SKIP: cargo_credentials_are_never_readable: HOME unset"); + return; + }; + // Read is conferred by every tier, not just the read_* ones. + let all: Vec = p + .landlock + .read_only + .iter() + .chain(p.landlock.read_exec.iter()) + .chain(p.landlock.write_noexec.iter()) + .chain(p.landlock.write_exec.iter()) + .cloned() + .collect(); + for secret in [ + h.join(".cargo").join("credentials.toml"), + h.join(".cargo").join("credentials"), // pre-1.39 spelling + ] { + for t in &all { + assert!( + !secret.starts_with(t), + "{} is covered by tier {} — Landlock unions along the path, \ + so the crates.io token is READABLE by the child. Grant \ + ~/.cargo/config.toml and ~/.cargo/env by name, never the \ + directory.", + secret.display(), + t.display() + ); + } + } + // And the config cargo actually needs must still be granted, or builds + // break while this test stays green. + assert!( + p.landlock + .read_only + .iter() + .any(|r| r == &h.join(".cargo").join("config.toml")), + "~/.cargo/config.toml must stay in read_only or cargo loses its config" + ); + } } diff --git a/src/seccomp.rs b/src/seccomp.rs index 9a6e164..5faf22a 100644 --- a/src/seccomp.rs +++ b/src/seccomp.rs @@ -51,10 +51,26 @@ const MSG_OOB: u64 = 0x1; // net=on, connect(2) to a denied port is EACCES while // sendto(..., MSG_FASTOPEN, &sa) establishes a full bidirectional session. // Landlock fixed this in 33cb713db016 (mainline, merged 2026-07-01, ships in -// 7.2, Cc: stable), but oring's floor is V7/6.15 and every supported kernel -// through 7.1.x still has the gap — so ring 2 closes it. (The floor raise to -// the fix ships in 7.2; every kernel at oring's 6.15 floor through 7.1.x is -// still pre-fix, so this guard stays until the floor reaches 7.2.) +// 7.2, Cc: stable). +// +// **THIS RULE'S OLD RETIREMENT CONDITION HAS FIRED, AND IT STAYS ANYWAY — the +// reason changed, so read this before deleting it.** The comment here used to say +// "oring's floor is V9/7.1 and every supported kernel through 7.1.x still has the +// gap … It stays until the floor reaches 7.2." The floor reached 7.2 on +// 2026-07-29, so that sentence now reads as an instruction to remove the rule. +// +// What replaces it: ring 1's mediation of this path is NOT DETECTABLE AT RUNTIME. +// `sys::landlock_errata` returns the errata the kernel *declares*, the tree +// declares exactly three, and **none covers this fix** — so a vendor kernel +// reporting ABI V10, assembled by backporting, can lack the behaviour while +// looking identical to a fixed one from oring's side. A floor is a statement about +// the interface; this gap is behavioural. So ring 2 is what makes `strict`'s "no +// TCP egress" true independent of how the kernel was assembled, and it is cheap: +// one seccompiler rule on a flag bit. +// +// Mainline 7.2-rc4 does mediate it (`security/landlock/net.c:354`, source-read) — +// which is exactly why this must not be read as "the kernel is broken". It is +// "oring cannot verify that this kernel is not." const MSG_FASTOPEN: u64 = 0x2000_0000; // pipe2(O_NOTIFICATION_PIPE) attaches a watch_queue to the pipe, handing the // child kernel/watch_queue.c — the same unprivileged-LPE launchpad class oring @@ -210,17 +226,146 @@ fn enosys_group() -> Result>> { fn eafnosupport_group() -> Result>> { let mut m = BTreeMap::new(); // socket(family, ...): block family >= 17. + // + // PLUS: inside AF_INET/AF_INET6, allow ONLY the protocols ring 1 can + // mediate. The family gate alone was a MEASURED egress bypass: ring 1 + // handles ConnectTcp/BindTcp, and `sk_is_tcp()` is FALSE for + // IPPROTO_MPTCP, so under `--profile strict` (`egress_tcp: []`, documented + // "deny-all TCP egress") a plain socket(AF_INET, SOCK_STREAM, + // IPPROTO_MPTCP) connected to a plain-TCP listener and delivered payload + // while IPPROTO_TCP got EACCES. Landlock is behaving as specified — + // erratum 1 deliberately narrowed the TCP rights to real TCP — so closing + // it is ring 2's job, which is exactly the §3 split. + // + // Expressed as an ALLOWLIST (protocol NOT IN {0, TCP, UDP}) rather than a + // denylist of {MPTCP, SCTP, DCCP, …}: a protocol denylist drifts every time + // the kernel gains a stream protocol, which is the failure mode the x32 + // guard exists to compensate for elsewhere. 0 is the family default. + // + // CONSEQUENCE, deliberate and wider than the reported bypass: this also + // denies IPPROTO_ICMP, so §2's ICMP residual closes for AF_INET/AF_INET6 + // and in-cage `ping` stops working. That follows from the principle — ring + // 1 cannot mediate ICMP either — rather than being an accident. Narrow it + // if the compatibility cost is unwanted. AF_UNIX and AF_NETLINK are + // untouched, so toolchains and getaddrinfo are unaffected. + // TRUNCATION: `protocol` is a 32-bit C type, so these MUST go through the + // `cond_*` helpers (ArgLen::Dword). A hand-rolled Qword compare here would + // reintroduce H2 — a child sets garbage upper bits, the kernel acts on the + // low 32, the Qword `Ne` fails to match and the wall falls open. Verified + // both ways: the truncation variants (0x1_0000_0106, low 32 = MPTCP; and + // high bits on the family arg) are all refused, and switching `cond_ne` to + // Qword makes `tests/seccomp_smt.rs` name this exact cell — + // "eafnosupport/socket arg2: verdict depends on the upper 32 bits". So the + // SMT gate does watch this rule. Note it only runs with `--features + // bpf-dump`, which `make check` does not pass; `make smt` is its own target, + // and CI runs that — the SMT gate is pure computation and needs no kernel. + const IPPROTO_DEFAULT: u64 = 0; + const IPPROTO_TCP_N: u64 = 6; + const IPPROTO_UDP_N: u64 = 17; + let inet_unmediated_proto = |family: u64| -> Result { + rule(vec![ + cond_eq(0, family)?, + cond_ne(2, IPPROTO_DEFAULT)?, + cond_ne(2, IPPROTO_TCP_N)?, + cond_ne(2, IPPROTO_UDP_N)?, + ]) + }; + + // THE TYPE AXIS. arg 1 was never examined, which made the rule above's + // stated contract false: `type` selects the transport INDEPENDENTLY of + // `protocol`. `inet_create` dispatches on `inetsw[sock->type]` + // (`net/ipv4/af_inet.c`), and with `protocol == 0` the first registrant for + // that type wins and rewrites protocol to its own. In 7.2-rc4 SCTP is the + // only registrant of SOCK_SEQPACKET on AF_INET/AF_INET6 + // (`net/sctp/protocol.c`), so `socket(AF_INET, SOCK_SEQPACKET, 0)` walked + // straight past a protocol allowlist that only ever saw arg 2. + // + // COMPLETE, not sampled. `__sys_socket_create` (`net/socket.c:1742`) rejects + // any bit outside `SOCK_TYPE_MASK | SOCK_CLOEXEC | SOCK_NONBLOCK` with + // EINVAL *before* dispatch, so the reachable domain for an allowed type is + // exactly {STREAM, DGRAM} x {0, NONBLOCK, CLOEXEC, both} = 8 spellings. + // Enumerating them with `cond_ne` is therefore exhaustive. (seccompiler has + // `MaskedEq` but no `MaskedNe`, and conditions AND within a rule, so + // "masked value NOT in {1,2}" is not expressible as a masked condition — + // the enumeration is the cheapest correct encoding, not a shortcut.) + // + // Dword comparison, deliberately, matching the protocol rule: the kernel + // truncates `type` to int, so a caller passing 0x1_0000_0001 gets + // SOCK_STREAM and this filter must agree with that. Widening to Qword makes + // `tests/seccomp_smt.rs` name the cell, which is the gate noticing. + // + // Over-block, stated: `socket(AF_INET, SOCK_STREAM|0x10, 0)` now returns + // EAFNOSUPPORT where the kernel would have said EINVAL — a call that already + // fails, failing differently. AF_UNIX is untouched (the family guard), which + // matters because AF_UNIX SOCK_SEQPACKET is common (systemd, D-Bus, logging). + const SOCK_STREAM_N: u64 = 1; + const SOCK_DGRAM_N: u64 = 2; + const SOCK_NONBLOCK_N: u64 = 0x800; + const SOCK_CLOEXEC_N: u64 = 0x8_0000; + let inet_unmediated_type = |family: u64| -> Result { + let mut conds = vec![cond_eq(0, family)?]; + for ty in [SOCK_STREAM_N, SOCK_DGRAM_N] { + for fl in [ + 0, + SOCK_NONBLOCK_N, + SOCK_CLOEXEC_N, + SOCK_NONBLOCK_N | SOCK_CLOEXEC_N, + ] { + conds.push(cond_ne(1, ty | fl)?); + } + } + rule(conds) + }; m.insert( libc::SYS_socket, - vec![rule(vec![cond_ge(0, AF_BLOCK_FROM)?])?], + vec![ + rule(vec![cond_ge(0, AF_BLOCK_FROM)?])?, + inet_unmediated_proto(libc::AF_INET as u64)?, + inet_unmediated_proto(libc::AF_INET6 as u64)?, + inet_unmediated_type(libc::AF_INET as u64)?, + inet_unmediated_type(libc::AF_INET6 as u64)?, + ], ); // socketpair walks past the socket() family wall: __sys_socketpair calls // sock_create() for the family — request_module("net-pf-%d") then // pf->create() — BEFORE checking whether the family implements // ->socketpair. Same family gate, symmetric with the rule above. + // + // The PROTOCOL gate belongs here for the same reason and was missing, which + // made this the more serious half of the type-axis gap. `sock_create()` runs + // to completion — including `request_module("net-pf-2-proto-%d-type-%d")` — + // for a call that can NEVER return a usable inet fd, because inet has no + // ->socketpair. So an unprivileged, sealed child could reach + // `socketpair(AF_INET, SOCK_SEQPACKET, IPPROTO_SCTP)` purely for its side + // effect: the kernel asks modprobe — a usermode helper running OUTSIDE the + // sandbox — to load `sctp.ko`, after which the child can open a real SCTP + // socket that ring 1 does not mediate (`sk_is_tcp()`/`sk_is_udp()` are both + // false for it). That is a self-serve path from inside the cage to an + // unmediated transport, and it removes the "sctp.ko must already be + // resident" precondition the finding was originally discounted for. + // + // MEASURED, unprivileged, on 7.2.0-rc4 — the errno proves the ordering: + // socketpair(AF_INET, SOCK_STREAM, IPPROTO_TCP) -> EOPNOTSUPP + // socketpair(AF_INET, SOCK_SEQPACKET, 132) -> ESOCKTNOSUPPORT + // `EOPNOTSUPP` is returned only when `sock->ops->socketpair` is NULL, i.e. + // AFTER `sock_create()` already succeeded — so the create path, and the + // autoload with it, ran. (The second errno comes from inside `inet_create`'s + // `inetsw[]` lookup, which is precisely what the autoload exists to satisfy; + // it fails here only because this box's `localmodconfig` kernel has no + // sctp.ko, which is a fact about THIS BUILD, not about Linux.) + // + // Denying the call outright is why this fix does not depend on whether + // modprobe would have succeeded under SELinux — unresolved at the time of + // writing. The syscall is refused before it enters the kernel path at all. m.insert( libc::SYS_socketpair, - vec![rule(vec![cond_ge(0, AF_BLOCK_FROM)?])?], + vec![ + rule(vec![cond_ge(0, AF_BLOCK_FROM)?])?, + inet_unmediated_proto(libc::AF_INET as u64)?, + inet_unmediated_proto(libc::AF_INET6 as u64)?, + inet_unmediated_type(libc::AF_INET as u64)?, + inet_unmediated_type(libc::AF_INET6 as u64)?, + ], ); Ok(m) } @@ -504,7 +649,11 @@ fn eval_cbpf(prog: &[libc::sock_filter; 7], nr: u32, arch: u32) -> u32 { } else if i.k == OFF_ARCH { arch } else { - 0 + // An offset this model does not know. Returning a value here + // would make the interpreter answer for a program it cannot + // read — see the panic on the opcode arm below for why that + // is worse than stopping. + panic!("eval_cbpf: unmodelled seccomp_data offset {}", i.k) }; pc += 1; } @@ -515,10 +664,31 @@ fn eval_cbpf(prog: &[libc::sock_filter; 7], nr: u32, arch: u32) -> u32 { pc += 1 + usize::from(if acc & i.k != 0 { i.jt } else { i.jf }); } BPF_RET_K => return i.k, - _ => return 0, + // An opcode this model does not implement. This MUST NOT return a + // value: `0` is `SECCOMP_RET_KILL_THREAD`, so the old `_ => return 0` + // reported "this program kills" for any program using an opcode the + // interpreter had never heard of. + // + // Measured 2026-07-28: mutating the guard's `JSET` to `JGT` — which is + // how anyone would naturally write "nr >= the x32 base" — produced a + // guard that leaked x32 `read`, `write`, `open`, `mmap`, `mprotect` and + // `ioctl` while `x32_guard_corners` failed with `left: 0` at the ALLOW + // corner. **The gate reported "the guard allows nothing" about a guard + // that allowed too much.** It was not detecting the mutant; it was + // detecting an unmodelled opcode, and it inverted the diagnosis on the + // way. Anyone completing this interpreter would have turned that whole + // class green. + // + // Panicking is also the right answer for Kani: the proof then covers + // exactly the opcodes modelled here, and an unmodelled one is a failed + // check rather than a silently-assumed KILL. + _ => panic!("eval_cbpf: unmodelled cBPF opcode {:#06x}", i.code), } } - 0 + // Fell off the end without a RET. The guard is 7 instructions and every path + // returns within 5; reaching here means the program under test is not the + // program this model was written for. + panic!("eval_cbpf: no RET within the instruction bound") } #[cfg(kani)] @@ -620,4 +790,112 @@ mod fv_tests { // arch=0 → KILL assert_eq!(eval_cbpf(&p, 0, 0), SECCOMP_RET_KILL_PROCESS); } + + /// The guard agrees with its specification over a DENSE table, not at four + /// corners. `BUILD-QUEUE.md` T1. + /// + /// **Why this exists, measured 2026-07-28.** A mutation sweep enumerated 89 + /// mutants of `x32_guard_program()` and scored every gate in the tree against + /// them. The result: + /// + /// | killed by | count | + /// |---|---| + /// | `x32_guard_corners`, with both `deny_x32` process cells green | 56 | + /// | `deny_x32` and nothing else | **1** | + /// | survived every gate | 9 | + /// + /// The single mutant the process cells caught was the one they were written + /// against. Two of the survivors matter: + /// + /// * `i4.k := 0xC0000000` passed the **entire** shipped gate — `make check` + /// rc=0, 135 tests — and was seen only by Kani, which is in no CI workflow. + /// * `i4 := JGT X32_SYSCALL_BIT|38` leaked x32 `read`, `write`, `open`, `mmap`, + /// `mprotect` and `ioctl` **in the kernel, measured**, while both `deny_x32` + /// cells stayed green — because `getpid` (39) and `ptrace` (101) both sit + /// ABOVE the threshold. Probing more individual numbers does not fix this: + /// the dominant leak family is monotone, so it always leaks the bottom of the + /// numbering, and a cell can only ever witness the points it names. + /// + /// This cell kills all 89 enumerated mutants and all 79 leaky survivors found + /// by a wider replacement search, in microseconds, with no kernel involvement — + /// so it runs everywhere `cargo test` runs, including the hosted CI job where + /// every process-level x32 cell takes the `require_or_skip` path and counts + /// green. + /// + /// **What it does NOT establish.** This is a property of oring's CONSTRUCTION — + /// the decision `x32_guard_program()` emits, evaluated by a model of the cBPF + /// VM. It is not a claim about the kernel's VM, and a table is not a proof over + /// the whole 2^32 numbering space: that is `x32_guard_total`'s claim and this + /// must never be cited in its place. The model↔kernel anchor is + /// `tests/deny_x32.rs`, which is the only thing here that runs a real syscall. + #[test] + fn x32_guard_matches_its_spec_over_a_dense_table() { + let p = x32_guard_program(); + // The specification, stated once: kill anything not x86_64, and anything + // carrying the x32 bit. Everything else defers to the errno filters. + let oracle = |nr: u32, arch: u32| -> u32 { + if arch != AUDIT_ARCH_X86_64 || (nr & X32_SYSCALL_BIT) != 0 { + SECCOMP_RET_KILL_PROCESS + } else { + SECCOMP_RET_ALLOW + } + }; + + let arches = [ + AUDIT_ARCH_X86_64, + 0x4000_0003, // i386 + 0xC000_00B7, // aarch64 + 0x4000_0028, // arm + 0, + u32::MAX, + ]; + + let mut checked: u64 = 0; + for &arch in &arches { + // Dense over the real syscall range, and over the same range with the + // x32 bit set — the two halves the guard must separate. + for nr in 0u32..=1024 { + for candidate in [nr, nr | X32_SYSCALL_BIT] { + let got = eval_cbpf(&p, candidate, arch); + let want = oracle(candidate, arch); + assert_eq!( + got, want, + "guard disagrees with its spec at nr={candidate:#010x} \ + arch={arch:#010x}: got {got:#010x}, want {want:#010x}" + ); + checked += 1; + } + } + // Boundary values around the x32 bit itself, where an off-by-one or a + // threshold comparison hides. + for candidate in [ + 0, + 1, + X32_SYSCALL_BIT - 1, + X32_SYSCALL_BIT, + X32_SYSCALL_BIT + 1, + X32_SYSCALL_BIT | 0x3FFF_FFFF, + 0x8000_0000, + 0xC000_0000, + u32::MAX, + ] { + let got = eval_cbpf(&p, candidate, arch); + let want = oracle(candidate, arch); + assert_eq!( + got, want, + "guard disagrees with its spec at boundary nr={candidate:#010x} \ + arch={arch:#010x}: got {got:#010x}, want {want:#010x}" + ); + checked += 1; + } + } + // Print what this DID, not what it intended: a table cell whose loops were + // mis-bounded would otherwise pass having checked nothing. + assert_eq!( + checked, + (arches.len() as u64) * (1025 * 2 + 9), + "table coverage count is not what the loops should have produced" + ); + eprintln!("x32_guard_matches_its_spec_over_a_dense_table: {checked} points checked"); + } } diff --git a/src/sys.rs b/src/sys.rs index 5adc453..86db694 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -8,10 +8,10 @@ //! reads exactly one module to see everything oring does at the kernel edge. #![allow(unsafe_code)] -use std::ffi::CString; +use std::ffi::{CStr, CString}; use std::io; use std::os::unix::ffi::OsStrExt; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::ptr; const CLOSE_RANGE_CLOEXEC: libc::c_uint = 1 << 2; @@ -133,6 +133,45 @@ pub fn access_executable(path: &Path) -> bool { unsafe { libc::access(c.as_ptr(), libc::X_OK) == 0 } } +/// The invoking user's home directory **from the passwd database**, not from +/// `$HOME`. +/// +/// `$HOME` is an ambient source, and this project's threat model says so +/// explicitly: the caller is trusted to *author* a policy, and the caller's +/// ambient state is not trusted to widen or override what was authored +/// (`CONSTRAINTS.md` CON-6). Anything that must remain correct when an untrusted +/// earlier step has set the environment cannot read `$HOME` and call it the +/// home directory — it can only read it and call it a hint. +/// +/// Returns `None` if the entry is absent or has no directory. Uses the +/// reentrant `getpwuid_r`; the fixed buffer is sized generously and a `pw_dir` +/// longer than it yields `None` rather than a truncated path, because a +/// truncated home would silently compare against the wrong prefix. +pub fn passwd_home() -> Option { + let mut pwd: libc::passwd = unsafe { std::mem::zeroed() }; + let mut buf = [0i8; 4096]; + let mut out: *mut libc::passwd = std::ptr::null_mut(); + // SAFETY: `pwd` and `buf` outlive the call and are sized as passed; `out` + // receives either NULL or a pointer to `pwd`. getpwuid_r writes only within + // the buffer it is given and never allocates. + let rc = unsafe { + libc::getpwuid_r( + libc::geteuid(), + &raw mut pwd, + buf.as_mut_ptr(), + buf.len(), + &raw mut out, + ) + }; + if rc != 0 || out.is_null() || pwd.pw_dir.is_null() { + return None; + } + // SAFETY: `pw_dir` is non-null and points into `buf`, which is still live. + let dir = unsafe { CStr::from_ptr(pwd.pw_dir) }; + let path = PathBuf::from(std::ffi::OsStr::from_bytes(dir.to_bytes())); + path.is_absolute().then_some(path) +} + /// Query the kernel's Landlock ABI via the version probe. Returns the ABI /// integer, or 0 if Landlock is unavailable (`ENOSYS`/error). pub fn landlock_abi_version() -> i32 { @@ -154,6 +193,43 @@ pub fn landlock_abi_version() -> i32 { } } +/// Query the kernel's Landlock **errata** bitmask — which behavioural fixes the +/// running kernel actually carries. +/// +/// Not the same question as [`landlock_abi_version`]. The ABI integer describes +/// the *interface*; the errata bitmask reports which **declared** errata the +/// running kernel carries. The two can disagree, because vendor kernels are +/// assembled by backporting — which is why the kernel exposes this at all. +/// +/// **It is a LOWER BOUND on behavioural difference, not a certificate.** The +/// mask enumerates only errata the kernel *declares*, and Landlock behaviour +/// changes have shipped without one: 7.2-rc4 mediates `MSG_FASTOPEN`'s implicit +/// connect at `security/landlock/net.c:354`, and no erratum covers it — the tree +/// declares exactly three (1 at ABI 4, 2 at ABI 6, 3 at ABI 1). So a full mask +/// means "carries the declared fixes", never "behaves as documented". +/// +/// Returns `None` when the probe is unsupported (a kernel predating the flag +/// returns `EINVAL`) or Landlock is absent — deliberately indistinguishable, +/// because both mean "the kernel did not tell us" and neither is a refusal. +pub fn landlock_errata() -> Option { + const LANDLOCK_CREATE_RULESET_ERRATA: libc::c_uint = 1 << 1; + // SAFETY: NULL attr + 0 size + ERRATA flag is the documented errata probe; + // like the version probe it creates no ruleset and only returns an integer. + let rc = unsafe { + libc::syscall( + libc::SYS_landlock_create_ruleset, + ptr::null::(), + 0usize, + LANDLOCK_CREATE_RULESET_ERRATA, + ) + }; + if rc < 0 { + None + } else { + Some(rc as u64) + } +} + /// Install a raw classic-BPF seccomp filter via `seccomp(SET_MODE_FILTER)`. /// Requires `NO_NEW_PRIVS` already set. Used for the x32 guard. pub fn install_raw_seccomp_filter(prog: &[libc::sock_filter]) -> io::Result<()> { diff --git a/tests/agent_survival.rs b/tests/agent_survival.rs index e3521af..f7afe25 100644 --- a/tests/agent_survival.rs +++ b/tests/agent_survival.rs @@ -99,8 +99,66 @@ fn git_workflow_survives_balanced() { eprintln!("SKIP: git/sh not found"); return; } - let proj = scratch("git"); - std::fs::create_dir_all(&proj).expect("mkdir"); + // HOME IS HARNESS-OWNED, and that is the point of this shape (T23). + // + // Until 2026-07-28 this test inherited the operator's real $HOME, so its + // inputs were whatever the box happened to contain. It passed for four days + // because `~/.config/git/ignore` did not exist here; unrelated tooling + // created that file and the test went red with no oring change behind it. + // A test whose result turns on undeclared host state cannot distinguish a + // regression from a Tuesday. + // + // Declaring HOME also makes the test EXERCISE the grant rather than merely + // coexist with it: `profile::home()` reads $HOME, so the profile's + // `~/.config/git/*` grants are computed against this directory, and the + // files below are the ones git then opens. Drop those grants and this goes + // red — which is the mutation, and it is not available when the files are + // absent, because absent is the case every tool already handles. + // AND HOME MUST SIT OUTSIDE EVERY TIER `balanced` GRANTS, or the test cannot + // fail — if git can read the config through some other grant, dropping the + // `~/.config/git` grants changes nothing and the mutation comes back green. + // + // `env::temp_dir()` is NOT a safe place for it, and the first version of + // this test used it. `balanced` grants `/tmp` write_noexec, so a $HOME under + // `/tmp` is readable anyway *and* trips oring's own "a built-in profile + // grants write over a tier containing $HOME" self-check — **rc=121, an oring + // REFUSAL, not git's 128**. That version passed only because this box sets + // `TMPDIR=/scratch`; with `TMPDIR` unset, which is the default everywhere + // else, it failed for a reason its own assertion message explicitly denied. + // The tier check below is the durable form: it is the *property* that makes + // the mutation meaningful, so a future profile change that grants this path + // fails loudly here instead of silently disarming the test. + let real_home = std::env::var_os("HOME") + .map(PathBuf::from) + .expect("HOME unset"); + let root = real_home.join(format!(".oring-test-{}-git", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let home = root.join("home"); + let proj = root.join("proj"); + let b = oring::profile::balanced(); + for grant in b + .landlock + .read_only + .iter() + .chain(b.landlock.read_exec.iter()) + .chain(b.landlock.write_noexec.iter()) + .chain(b.landlock.write_exec.iter()) + { + assert!( + !home.starts_with(grant), + "test precondition lost: the harness HOME {} sits under granted tier {} — \ + git would read its config through that grant, so dropping the \ + ~/.config/git grants would not turn this test red", + home.display(), + grant.display() + ); + } + let gitcfg = home.join(".config").join("git"); + std::fs::create_dir_all(&gitcfg).expect("mkdir home"); + std::fs::create_dir_all(&proj).expect("mkdir proj"); + std::fs::write(gitcfg.join("config"), "[core]\n\tpager = cat\n").expect("write config"); + std::fs::write(gitcfg.join("ignore"), "*.tmp\n").expect("write ignore"); + // init + stage + commit — the real /dev, /proc, .gitconfig-tolerant path. let script = format!( "cd {p} && git init -q && echo hi > f && \ @@ -108,9 +166,29 @@ fn git_workflow_survives_balanced() { git -c user.email=t@t -c user.name=t commit -q -m x", p = proj.display() ); - let code = run_under_balanced(&proj, &["sh", "-c", &script]); - let _ = std::fs::remove_dir_all(&proj); - assert_eq!(code, Some(0), "git init+commit must succeed under balanced"); + let code = Command::new(oring_bin()) + .current_dir(&proj) + .arg("--profile") + .arg("balanced") + .arg("--workdir") + .arg(&proj) + .arg("--") + .args(["sh", "-c", &script]) + .env("ORING_QUIET", "1") + .env("HOME", &home) + .env("XDG_CONFIG_HOME", home.join(".config")) + .status() + .expect("spawn oring") + .code(); + let _ = std::fs::remove_dir_all(&root); + assert_eq!( + code, + Some(0), + "git init+commit must succeed under balanced with a global config present. \ + rc=128 means git could access() its config and then failed to open it — the \ + ~/.config/git grants are missing (T23). rc=121 means ORING REFUSED to seal \ + and git never ran; read its stderr, do not read it as a grant problem." + ); } // NOTE: no JIT-runtime survival test here on purpose. The mprotect(PROT_EXEC) @@ -119,3 +197,249 @@ fn git_workflow_survives_balanced() { // mprotect-strict=Deny), so a node/JS-specific end-to-end test would only re- // prove that for one runtime while dragging a JS dependency into a Rust tool's // suite. cargo + git cover real multi-process toolchain survival. + +/// **CON-3's positive arm: the confined RESULT equals the unconfined result.** +/// +/// `cargo_build_survives_balanced` and `git_workflow_survives_balanced` above assert that +/// a confined tool **completes** — that is CON-8. This asserts something strictly +/// stronger and different: that it produces **the same output**. A tool can exit 0 under +/// confinement and still return a wrong answer, and that is the `DIVERGE` loss. +/// +/// **Why `git write-tree` is the observable.** It is content-addressed — the hash is a +/// function of file names, modes and blob contents and **nothing else**. A commit hash +/// would be useless here because it folds in the author and two timestamps, so the two +/// arms would differ for reasons that have nothing to do with confinement. `write-tree` +/// also needs no `user.email`, which removes a whole class of config divergence. +/// +/// **What this does NOT establish.** One tool, one operation, one profile. It is a +/// positive instance of CON-3 over a bounded domain, not the constraint — and the row +/// stays `unenforced` because the counterexample pinned in +/// `tests/residual_boundary.rs::access_says_readable_where_open_is_denied` is real and +/// unreported. Both directions matter: this arm would go green while that one stays red. +#[test] +fn the_confined_result_equals_the_unconfined_result() { + if !require_or_skip("the_confined_result_equals_the_unconfined_result") { + return; + } + let Some(git) = which("git") else { + eprintln!("SKIP: the_confined_result_equals_the_unconfined_result: no git"); + return; + }; + + // HOME is harness-owned and OUTSIDE every granted tier, same reasoning as the git + // test above: an inherited $HOME would let git read the operator's config through a + // grant, so a dropped config grant would not turn this red. + let real_home = std::env::var_os("HOME") + .map(PathBuf::from) + .expect("HOME unset"); + let root = real_home.join(format!(".oring-test-{}-effect", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let home = root.join("home"); + std::fs::create_dir_all(&home).unwrap(); + + // Two dirs with BYTE-IDENTICAL content, built by the same code so the inputs cannot + // drift between the arms. + let mut dirs = Vec::new(); + for tag in ["confined", "bare"] { + let d = root.join(tag); + std::fs::create_dir_all(d.join("sub")).unwrap(); + std::fs::write(d.join("a.txt"), b"alpha\n").unwrap(); + std::fs::write(d.join("sub/b.txt"), b"beta\n").unwrap(); + dirs.push(d); + } + + let tree_of = |dir: &Path, confined: bool| -> String { + let script = format!( + "cd {d} && {git} init -q && {git} add -A && {git} write-tree", + d = dir.display(), + git = git.display() + ); + let mut cmd = if confined { + let mut c = Command::new(oring_bin()); + c.args(["--profile", "balanced", "--workdir"]) + .arg(dir) + .arg("--") + .args(["sh", "-c", &script]); + c + } else { + let mut c = Command::new("sh"); + c.args(["-c", &script]); + c + }; + let out = cmd + .current_dir(dir) + .env("HOME", &home) + .env("ORING_QUIET", "1") + .env_remove("GIT_DIR") + .output() + .expect("spawn"); + assert!( + out.status.success(), + "the {} arm must succeed, or there is no result to compare: {}", + if confined { "confined" } else { "bare" }, + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout) + .lines() + .rfind(|l| !l.trim().is_empty()) + .unwrap_or_default() + .trim() + .to_string() + }; + + let confined = tree_of(&dirs[0], true); + let bare = tree_of(&dirs[1], false); + assert_eq!( + confined.len(), + 40, + "expected a 40-char tree hash from the confined arm, got {confined:?}" + ); + assert_eq!( + confined, bare, + "SAME inputs must produce the SAME tree. A difference here is the DIVERGE loss: \ + the confined tool ran, exited 0, and returned something else." + ); + let _ = std::fs::remove_dir_all(&root); +} + +// --------------------------------------------------------------------------- +// CON-8 for the 2026-07-29 UDP tightening. +// +// The rest of this file builds with `--offline`, deliberately — but that makes it +// STRUCTURALLY BLIND to the axis that tightening touched. `balanced` went from +// unrestricted UDP to `egress_udp: [53]`, and an offline build resolves no names, +// so every cell above would stay green if the DNS grant were wrong or absent. +// CON-8's own text says "every tightening is a candidate violation of this row"; +// these two cells are that check for this one, in both directions. +// --------------------------------------------------------------------------- + +/// IANA-reserved for documentation, so it is stable and belongs to nobody. +const DNS_NAME: &str = "example.com"; + +fn run_under(profile: &str, cmd: &[&str]) -> Option { + Command::new(oring_bin()) + .arg("--profile") + .arg(profile) + .arg("--") + .args(cmd) + .env("ORING_QUIET", "1") + .status() + .expect("spawn oring") + .code() +} + +/// Can this HOST resolve at all? Without the bare arm these cells cannot tell +/// "oring denied it" from "there is no resolver here" — and on this project's +/// own measurement box the corpus lane runs inside a netns with no route +/// anywhere, where that is the normal state. A host that cannot resolve must +/// SKIP rather than report the tightening as witnessed; `ORING_REQUIRE_NET=1` +/// makes the skip fatal, per the `make check-full` convention. +fn require_dns_or_skip(test: &str) -> bool { + let ok = which("getent").is_some_and(|g| { + Command::new(g) + .args(["hosts", DNS_NAME]) + .status() + .ok() + .and_then(|s| s.code()) + == Some(0) + }); + if ok { + return true; + } + let msg = format!("{test}: host cannot resolve {DNS_NAME} unconfined (no getent, or no DNS)"); + assert!(std::env::var_os("ORING_REQUIRE_NET").is_none(), "{msg}"); + eprintln!("SKIP: {msg}"); + false +} + +/// `balanced` must still RESOLVE NAMES — the legitimate work the DNS grant exists +/// for. Fails if `egress_udp` loses :53, or if `ingress_udp` loses port 0 (kernel +/// autobind of the source port is denied, and the grant sends nothing at all). +#[test] +fn name_resolution_survives_under_balanced() { + if !require_or_skip("name_resolution_survives_under_balanced") + || !require_dns_or_skip("name_resolution_survives_under_balanced") + { + return; + } + assert_eq!( + run_under("balanced", &["/usr/bin/getent", "hosts", DNS_NAME]), + Some(0), + "balanced must resolve {DNS_NAME}: it grants egress_udp :53 with ingress_udp 0 \ + for autobind, and a real toolchain cannot fetch anything without it" + ); +} + +/// Send one UDP datagram to `127.0.0.1:` using only bash's `/dev/udp` +/// redirection, which does socket+connect. `None` profile means no seal. +/// +/// bash rather than oring's own `__probe`: the probe binary lives under +/// `target/`, which no profile puts in a `read_exec` tier, so oring refuses the +/// run with 122 before the probe executes — a could-not-check that is easy to +/// misread as a denial. `/bin/bash` is granted by both profiles. +fn udp_open(profile: Option<&str>, port: u16) -> Option { + let script = format!("exec 3<>/dev/udp/127.0.0.1/{port}"); + match profile { + Some(p) => run_under(p, &["/bin/bash", "-c", &script]), + None => Command::new("/bin/bash") + .args(["-c", &script]) + .status() + .expect("spawn bash") + .code(), + } +} + +/// The COST of the same tightening, at the layer that can actually attribute it. +/// +/// **This cell replaced one asserting `getent` fails under `strict`, which could +/// not fail.** Measured: with `strict` mutated to grant :53, `getent` still +/// returned 2 — because `strict` does not grant `/etc/resolv.conf` (read → exit +/// 1), so name resolution is dead there for a SECOND, unrelated reason. The +/// assertion passed whatever the UDP grant did, which is the unfalsifiable-check +/// shape this project treats as the defect rather than the safeguard. +/// +/// So this asserts the mechanism instead: the datagram itself. It needs no +/// resolver config and no network, and the bare arm establishes that the port is +/// reachable when oring is absent — without it, a denial here attributes to +/// nothing in particular. +#[test] +fn the_profiles_udp_posture_holds_at_the_socket_layer() { + if !require_or_skip("the_profiles_udp_posture_holds_at_the_socket_layer") { + return; + } + for port in [53u16, 9] { + assert_eq!( + udp_open(None, port), + Some(0), + "control: :{port} must be reachable UNSEALED, or the cells below \ + attribute a denial to oring that something else produced" + ); + } + + // balanced: DNS out, nothing else. + assert_eq!( + udp_open(Some("balanced"), 53), + Some(0), + "balanced grants egress_udp :53 (with ingress_udp 0 for autobind)" + ); + assert_ne!( + udp_open(Some("balanced"), 9), + Some(0), + "balanced must deny every UDP port it does not name — the grant is per-port" + ); + + // strict: no datagrams at all, :53 included. + for port in [53u16, 9] { + let rc = udp_open(Some("strict"), port); + assert_ne!( + rc, + Some(0), + "strict sets egress_udp [] — no UDP leaves it, :{port} included" + ); + assert!( + !matches!(rc, Some(120..=122)), + "must be the CHILD denied, not oring refusing to seal \ + (120 confinement / 121 policy / 122 target); :{port} gave {rc:?}" + ); + } +} diff --git a/tests/citation_handles.rs b/tests/citation_handles.rs new file mode 100644 index 0000000..8cafd07 --- /dev/null +++ b/tests/citation_handles.rs @@ -0,0 +1,404 @@ +// SPDX-License-Identifier: BSD-3-Clause +//! `docs/DESIGN.md` counts its own unresolvable citations. This checks the count. +//! +//! oring cites two things a reader cannot open: `BUILD-QUEUE.md T` work-item +//! handles and `deep_research/…` notes. Both are disclosed in the `DESIGN.md` +//! preamble, which **states how many there are** — the right shape, because a +//! reader who greps and finds more than the document admits to has been misled +//! about the scale of the wart. Both counts were wrong on 2026-08-01: "nine" +//! was the `docs/` count presented as the tree count (actual 21), and "three +//! comments in `src/`" was actually zero. Neither could have been caught by +//! anything in the tree — counted claims about the repository, checked by nobody. +//! +//! **Establishes:** every count in the preamble equals the count obtained by +//! scanning the tree with the rules below, and the stated total is the sum of its +//! parts. Drift in either direction fails. +//! +//! **Does NOT establish** that a citation is appropriate, that the gap it names +//! is real, or that the surrounding sentence is accurate. It knows arithmetic. It +//! also cannot see a *new* unresolvable-citation convention cited by some token +//! neither rule matches. +//! +//! # Counting rules — a count is meaningless without its frame +//! +//! * **T-handle:** `BUILD-QUEUE`, optional `.md`, optional backtick/spaces, then +//! `T` and **at least one digit**. The digit requirement excludes the +//! disclosure's own meta-mention `BUILD-QUEUE.md T`, so the sentence does +//! not count itself. +//! * **Corpus note:** the literal `deep_research/notes/` — likewise excluding the +//! disclosure's own `deep_research/…`, which has no `notes/` segment. +//! * **Corpus comment (`tests/` only):** the prose spellings, because the +//! `src/`→`tests/` move went unnoticed precisely when the old count looked for +//! a path and the comments had stopped containing one. +//! +//! The counts are digits in the prose so this can parse them — a real cost paid +//! to the gate, recorded at the sentence rather than left looking like a lapse. + +use std::path::{Path, PathBuf}; + +fn root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn read(rel: &str) -> String { + let p = root().join(rel); + std::fs::read_to_string(&p).unwrap_or_else(|e| { + panic!( + "COULD NOT CHECK: {} is unreadable ({e}). A missing input must not read as a \ + clean count.", + p.display() + ) + }) +} + +/// This file, excluded from every scan below. +/// +/// **A counting gate must not count its own fixtures.** The red tests here +/// contain real handle spellings (`` `BUILD-QUEUE.md` T21 ``) and the prose +/// above names the maintainer's corpus repeatedly, including a literal list of +/// the three spellings the rule matches. **Measured on this file, not estimated: +/// 5 phantom T-handles and 11 phantom corpus mentions** — the first run failed +/// on numbers `DESIGN.md` had no way to be right about. (This comment said "4 +/// and 3" before either was counted, which is the same unmeasured-number defect +/// the gate exists to catch, committed inside the gate itself.) The exclusion is +/// the fix; `the_self_exclusion_still_matches_this_file` keeps a rename from +/// quietly re-polluting the count. +const SELF_FILE: &str = "citation_handles.rs"; + +/// Every `.rs` / `.md` file under `rel`, concatenated, except [`SELF_FILE`]. +/// Recursive; `target/` and `third_party/` are never under the directories this +/// gate scans. +fn read_tree(rel: &str) -> String { + fn walk(dir: &Path, out: &mut String) { + let mut entries: Vec<_> = std::fs::read_dir(dir) + .unwrap_or_else(|e| panic!("COULD NOT CHECK: {} unreadable ({e})", dir.display())) + .filter_map(Result::ok) + .map(|e| e.path()) + .collect(); + // Sorted so a failure message is reproducible run to run. + entries.sort(); + for p in entries { + if p.is_dir() { + walk(&p, out); + } else if p.file_name().and_then(|f| f.to_str()) == Some(SELF_FILE) { + // See SELF_FILE: this file's own fixtures are citations of the + // shapes it counts, and counting them makes the document wrong. + } else if matches!(p.extension().and_then(|e| e.to_str()), Some("rs" | "md")) { + if let Ok(s) = std::fs::read_to_string(&p) { + out.push_str(&s); + out.push('\n'); + } + } + } + } + let mut out = String::new(); + walk(&root().join(rel), &mut out); + out +} + +// --------------------------------------------------------------------------- +// Counting rules +// --------------------------------------------------------------------------- + +/// `BUILD-QUEUE` + optional `.md` + optional backtick/spaces + `T` + ≥1 digit. +/// +/// The digit requirement is load-bearing: the preamble's own meta-mention is +/// `BUILD-QUEUE.md T`, and `<` is not a digit, so the sentence announcing the +/// count is not itself counted. +fn count_t_handles(hay: &str) -> usize { + let mut n = 0; + let bytes = hay.as_bytes(); + let mut i = 0; + while let Some(off) = hay.get(i..).and_then(|s| s.find("BUILD-QUEUE")) { + let mut j = i + off + "BUILD-QUEUE".len(); + if hay.get(j..).is_some_and(|s| s.starts_with(".md")) { + j += 3; + } + while bytes.get(j).is_some_and(|&b| b == b'`' || b == b' ') { + j += 1; + } + if bytes.get(j) == Some(&b'T') && bytes.get(j + 1).is_some_and(u8::is_ascii_digit) { + n += 1; + } + i = i + off + "BUILD-QUEUE".len(); + } + n +} + +fn count_substr(hay: &str, needle: &str) -> usize { + hay.matches(needle).count() +} + +const CORPUS_PROSE: [&str; 3] = [ + "maintainer's corpus", + "maintainer's research corpus", + "private research corpus", +]; + +/// The three prose spellings, counted without double-counting: "maintainer's +/// research corpus" contains no other member as a substring, so a plain sum is +/// correct here — asserted rather than assumed, because a future fourth +/// spelling that nests inside another would silently inflate this. +fn count_corpus_prose(hay: &str) -> usize { + for (a, b) in [(0usize, 1usize), (1, 0), (0, 2), (2, 0), (1, 2), (2, 1)] { + assert!( + !CORPUS_PROSE[a].contains(CORPUS_PROSE[b]), + "COULD NOT CHECK: corpus spellings nest ({:?} contains {:?}), so summing them \ + double-counts. Rewrite the rule before trusting the number.", + CORPUS_PROSE[a], + CORPUS_PROSE[b] + ); + } + CORPUS_PROSE.iter().map(|s| count_substr(hay, s)).sum() +} + +// --------------------------------------------------------------------------- +// Reading the numbers the document claims +// --------------------------------------------------------------------------- + +/// The digits immediately preceding `needle` in `hay`, e.g. `7` from +/// ``7 in `src/` ``. Returns `None` if `needle` is absent — the caller must +/// treat that as could-not-check, never as agreement. +fn number_before(hay: &str, needle: &str) -> Option { + let at = hay.find(needle)?; + let head = hay.get(..at)?.trim_end(); + let digits: String = head + .chars() + .rev() + .take_while(char::is_ascii_digit) + .collect::>() + .into_iter() + .rev() + .collect(); + digits.parse().ok() +} + +/// The paragraph carrying the T-handle counts. +/// +/// **Whole LINE, not a slice from the anchor.** Slicing forward from +/// `"such citations exist"` drops the `21 ` that precedes it — the total would +/// then read as could-not-check on a correct document. `DESIGN.md` is one +/// paragraph per line, and the two disclosures are on different lines, so the +/// line is the sentence and the two identical ``N in `src/` `` shapes cannot be +/// confused with each other. +fn t_handle_claim(design: &str) -> String { + claim_line(design, "such citations exist") +} + +fn corpus_claim(design: &str) -> String { + claim_line(design, "The same applies to any") +} + +fn claim_line(design: &str, anchor: &str) -> String { + design + .lines() + .find(|l| l.contains(anchor)) + .unwrap_or_else(|| { + panic!( + "COULD NOT CHECK: docs/DESIGN.md has no line containing {anchor:?}. The \ + disclosure this gate checks is gone or reworded — an absent claim must \ + not read as an agreeing one." + ) + }) + .to_string() +} + +// --------------------------------------------------------------------------- +// The checks +// --------------------------------------------------------------------------- + +fn expect(claim: &str, needle: &str, actual: usize, what: &str) { + let stated = number_before(claim, needle).unwrap_or_else(|| { + panic!( + "COULD NOT CHECK: no number precedes {needle:?} in the DESIGN.md claim. The \ + sentence must state {what} as a digit for this gate to read it." + ) + }); + assert_eq!( + stated as usize, actual, + "docs/DESIGN.md says {stated} for {what}, the tree has {actual}. One of them moved \ + and the other did not: update the sentence, or the citations. This exact drift \ + (nine claimed, 21 actual) is why this gate exists." + ); +} + +#[test] +fn design_states_the_true_number_of_build_queue_handles() { + let design = read("docs/DESIGN.md"); + let claim = t_handle_claim(&design); + + // `docs/` as a TREE, not DESIGN.md and VERIFICATION.md by name. + // + // The named-file version was correct and became wrong the moment `docs/` gained a + // file. On 2026-08-01 §2 moved to `RESIDUALS.md`, taking 6 of DESIGN.md's 8 + // T-handles with it; a gate counting two files by name would have reported 3 where + // the tree had 9, and then demanded the document be "corrected" to match its own + // shrunken frame. A counting gate whose span is a hardcoded list of files loses + // coverage every time the project grows one. + let in_docs = count_t_handles(&read_tree("docs")); + let in_src = count_t_handles(&read_tree("src")); + let in_tests = count_t_handles(&read_tree("tests")); + + expect(&claim, "in `docs/`", in_docs, "T-handles in docs/"); + expect(&claim, "in `src/`", in_src, "T-handles in src/"); + expect(&claim, "in `tests/`", in_tests, "T-handles in tests/"); + + // The total is checked against the SUM, so a document cannot state three correct + // parts and an incoherent whole. + let total = in_docs + in_src + in_tests; + expect(&claim, "such citations exist", total, "the T-handle total"); +} + +#[test] +fn design_states_the_true_number_of_corpus_citations() { + let design = read("docs/DESIGN.md"); + let claim = corpus_claim(&design); + + let docs_notes = count_substr(&read_tree("docs"), "deep_research/notes/"); + let src_notes = count_substr(&read_tree("src"), "deep_research/notes/"); + let tests_comments = count_corpus_prose(&read_tree("tests")); + + expect( + &claim, + "citations in `docs/`", + docs_notes, + "corpus notes in docs/", + ); + expect(&claim, "in `src/`", src_notes, "corpus notes in src/"); + // `in `tests/`` rather than `comments in `tests/``: the claim is sliced to a + // single line, so the shorter needle is still unambiguous — and it survives + // the count going singular, which broke the longer one. + expect( + &claim, + "in `tests/`", + tests_comments, + "corpus comments in tests/", + ); +} + +/// The `src/` zero is asserted directly as well as through the claim, because it +/// is the number that rotted: the document said three, the tree had none, and a +/// gate that only compares two figures agrees with itself the moment someone +/// "fixes" the doc to match a wrong scan. +#[test] +fn no_deep_research_path_is_cited_from_src() { + let src = read_tree("src"); + assert_eq!( + count_substr(&src, "deep_research"), + 0, + "src/ cites the unpublished corpus by path. Either inline what the reader needs, \ + or move the comment and update the DESIGN.md preamble — which claimed three such \ + comments in src/ until 2026-08-01, when there were none." + ); +} + +/// A rename must not silently re-admit this file's fixtures to the scan. If +/// `SELF_FILE` stops naming a real file, the exclusion has become a no-op and +/// every count above is inflated by this file's own examples. +#[test] +fn the_self_exclusion_still_matches_this_file() { + let p = root().join("tests").join(SELF_FILE); + assert!( + p.exists(), + "SELF_FILE = {SELF_FILE:?} names no file under tests/. This gate was renamed and \ + the constant was not, so it now excludes nothing and counts its own red-test \ + fixtures as real citations." + ); +} + +// --------------------------------------------------------------------------- +// Red tests — every arm above, seen failing +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod red { + use super::*; + + const CLAIM: &str = "21 such citations exist: 8 in `DESIGN.md`, 1 in `VERIFICATION.md`, \ + 7 in `src/`, 5 in `tests/`"; + + #[test] + fn a_number_that_is_too_high_is_caught() { + let r = std::panic::catch_unwind(|| expect(CLAIM, "in `src/`", 6, "T-handles in src/")); + assert!(r.is_err(), "claim says 7, tree has 6 — must fail"); + } + + #[test] + fn a_number_that_is_too_low_is_caught() { + let r = std::panic::catch_unwind(|| expect(CLAIM, "in `src/`", 8, "T-handles in src/")); + assert!(r.is_err(), "claim says 7, tree has 8 — must fail"); + } + + #[test] + fn the_matching_number_passes() { + expect(CLAIM, "in `src/`", 7, "T-handles in src/"); + } + + #[test] + fn an_absent_claim_is_could_not_check_not_pass() { + let r = std::panic::catch_unwind(|| { + expect( + "no numbers here at all", + "in `src/`", + 7, + "T-handles in src/", + ) + }); + assert!( + r.is_err(), + "a missing sentence must fail loudly; a claim nobody can find is not a claim \ + everybody agrees with" + ); + } + + #[test] + fn a_missing_preamble_sentence_is_could_not_check() { + let r = std::panic::catch_unwind(|| t_handle_claim("a DESIGN.md with no disclosure")); + assert!(r.is_err(), "absent disclosure must be could-not-check"); + } + + #[test] + fn the_meta_mention_does_not_count_itself() { + assert_eq!( + count_t_handles("`BUILD-QUEUE.md T` is a work-item handle, not a link."), + 0, + "the sentence announcing the count must not be counted by it" + ); + } + + #[test] + fn real_handle_spellings_all_count() { + assert_eq!(count_t_handles("`BUILD-QUEUE.md` T21"), 1); + assert_eq!(count_t_handles("(BUILD-QUEUE T24)"), 1); + assert_eq!( + count_t_handles("BUILD-QUEUE.md T7 and `BUILD-QUEUE.md` T1"), + 2 + ); + } + + #[test] + fn the_disclosure_ellipsis_is_not_a_corpus_note() { + assert_eq!( + count_substr("any `deep_research/…` path", "deep_research/notes/"), + 0, + "the disclosure's own token has no notes/ segment and must not be counted" + ); + assert_eq!( + count_substr( + "see deep_research/notes/2026-07-28-t21.md", + "deep_research/notes/" + ), + 1 + ); + } + + #[test] + fn corpus_prose_spellings_do_not_double_count() { + assert_eq!(count_corpus_prose("the maintainer's research corpus"), 1); + assert_eq!(count_corpus_prose("a private research corpus"), 1); + assert_eq!( + count_corpus_prose("maintainer's corpus and private research corpus"), + 2 + ); + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 30cc49d..b866380 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -59,8 +59,9 @@ fn crate_floor() -> i32 { } /// Returns true if the kernel can run the confinement tests: Landlock present at -/// oring's floor (V7 / kernel 6.15). Below that oring itself refuses (exit 120), -/// so the tests must SKIP, not fail — GitHub runners are far below it. +/// oring's floor (V10 / kernel 7.2). Below that oring itself refuses (exit 120), +/// so the tests must SKIP, not fail — GitHub runners are far below it, and since +/// the V10 floor so is every pre-7.2 developer box. /// ORING_REQUIRE_KERNEL=1 turns a would-be skip into a hard failure (no silent /// green on a box that SHOULD meet the floor, §11d). pub fn require_or_skip(test: &str) -> bool { @@ -70,14 +71,16 @@ pub fn require_or_skip(test: &str) -> bool { } if std::env::var_os("ORING_REQUIRE_KERNEL").is_some() { panic!( - "{test}: needs Landlock V{} / kernel 6.15 (present={}, abi=V{abi})", + "{test}: needs Landlock V{} / kernel {}+ (present={}, abi=V{abi})", crate_floor(), + oring::landlock::FLOOR_KERNEL, landlock_present() ); } eprintln!( - "SKIP: {test}: needs Landlock V{} / kernel 6.15 (present={}, abi=V{abi})", + "SKIP: {test}: needs Landlock V{} / kernel {}+ (present={}, abi=V{abi})", crate_floor(), + oring::landlock::FLOOR_KERNEL, landlock_present() ); false @@ -99,6 +102,27 @@ pub fn policy_json(write_exec: &[&str], egress_tcp: Option<&[u16]>) -> String { /// read_exec always covers the oring bindir + broad system dirs + /proc so the /// (debug, dynamically-linked) child oring can exec + load libs + start. pub fn policy_json_sec(write_exec: &[&str], egress_tcp: Option<&[u16]>, seccomp: &str) -> String { + policy_json_ro(write_exec, &[], egress_tcp, seccomp) +} + +/// As [`policy_json_sec`], plus extra paths granted `read_only`. +/// +/// Exists for ONE reason and it is worth stating: a cell that asserts "a +/// `read_only` tier denies a write" is only evidence if DAC would have permitted +/// that write. Pointing such a cell at `/etc` makes it EACCES bare from DAC and +/// EACCES sealed — indistinguishable, and green even with ring 1's FS layer +/// mutated off. Granting a harness-owned directory `read_only` instead keeps the +/// claim (a read_only tier denies write) while making the denial attributable. +/// +/// The directory must sit outside every other granted tier. Landlock unions +/// rights along the path walk, so a subdirectory of the workdir or of `/tmp` +/// would inherit their write grants and this would silently test nothing. +pub fn policy_json_ro( + write_exec: &[&str], + read_only_extra: &[&str], + egress_tcp: Option<&[u16]>, + seccomp: &str, +) -> String { let mut read_exec = vec![ bindir(), "/usr".into(), @@ -110,6 +134,9 @@ pub fn policy_json_sec(write_exec: &[&str], egress_tcp: Option<&[u16]>, seccomp: read_exec.dedup(); let re = json_arr(&read_exec.iter().map(String::as_str).collect::>()); let wx = json_arr(write_exec); + let mut ro: Vec<&str> = vec!["/etc", "/usr/share", "/proc"]; + ro.extend_from_slice(read_only_extra); + let ro = json_arr(&ro); let egress = match egress_tcp { Some(ports) => { let p = ports @@ -124,7 +151,7 @@ pub fn policy_json_sec(write_exec: &[&str], egress_tcp: Option<&[u16]>, seccomp: format!( r#"{{"version":1,"landlock":{{ "read_exec":{re}, - "read_only":["/etc","/usr/share","/proc"], + "read_only":{ro}, "write_noexec":["/tmp","/dev/null","/dev/zero","/dev/urandom"], "write_exec":{wx}, "egress_tcp":{egress}, @@ -148,6 +175,26 @@ pub fn run_probe(policy: &str, op: &str, arg: &str) -> (Option, Option run_probe_proc(policy, op, arg, None) } +/// Run the probe payload with NO seal — the bare control. +/// +/// A sealed cell that asserts a denial only attributes that denial to oring if the +/// same operation is permitted unsealed. Where the environment might supply the +/// same denial (an inherited seccomp filter, a host MAC, DAC), the bare arm is the +/// only thing separating "oring did this" from "something did this". Cells that +/// need it should assert on it rather than assume it. +/// +/// Returns `(exit_code, signal)` exactly as [`run_probe`] does. +pub fn run_probe_unsealed(op: &str, arg: &str) -> (Option, Option) { + use std::os::unix::process::ExitStatusExt; + let mut cmd = Command::new(oring_bin()); + cmd.arg("__probe").arg(op); + if !arg.is_empty() { + cmd.arg(arg); + } + let out = cmd.output().expect("spawn bare probe"); + (out.status.code(), out.status.signal()) +} + /// Like `run_probe` but with an optional `--proc ` axis. pub fn run_probe_proc( policy: &str, @@ -258,8 +305,21 @@ fn raw_write(path: &[u8], data: &[u8]) -> std::io::Result<()> { Ok(()) } -/// A live sibling process holding a decoy secret in its environ — the target -/// for the peer-procfs residual cell. Killed on drop. Confirmed alive by pid. +/// A live OUT-OF-DOMAIN process holding a decoy secret in its environ. Killed on drop, +/// confirmed alive by pid. +/// +/// **Corrected 2026-07-29 — both halves of the old description were wrong, and neither was +/// dated, so both read as live.** It said *"a live **sibling** process"* and *"the target +/// for the peer-procfs **residual** cell"*. +/// +/// It is spawned **before the seal**, so it is **out-of-domain** — Landlock scopes +/// ancestor-or-equal and this process is neither. `tests/containment_matrix.rs` said so 120 +/// lines above the fixture while this comment said otherwise. And the cell it feeds has +/// been `Deny(EACCES)` since 2026-07-26, so it witnesses a **wall**, not a residual. +/// +/// The genuinely in-domain case — two siblings under ONE seal — is +/// `proc-indomain-env-{full,self}`, added 2026-07-29, and it needs no fixture here because +/// the probe spawns both processes itself. pub struct Sentinel { child: std::process::Child, pub pid: u32, @@ -365,3 +425,37 @@ pub fn plant_sig_ign(cmd: &mut Command) { }); } } + +/// Place an already-open, readable descriptor at `target_fd` in the spawned +/// child, so a test can observe whether the FD scrub removed it. (T10.) +/// +/// `dup2(2)` is used rather than passing the descriptor through, and that choice +/// is load-bearing: `dup2` CLEARS `FD_CLOEXEC` on the new descriptor, so the fd +/// survives the `execve` into oring and is present at seal time. That is what +/// makes it the same shape as the leak this scrub exists for +/// (CVE-2024-21626) — a descriptor the caller left open, not one oring made. +/// +/// The parent's copy is deliberately leaked with `into_raw_fd`: it must still be +/// open when `fork` happens, and the `Command` is consumed shortly after. +pub fn plant_open_fd(cmd: &mut Command, target_fd: i32, path: &std::path::Path) { + use std::os::unix::io::IntoRawFd; + use std::os::unix::process::CommandExt; + let raw = std::fs::File::open(path) + .unwrap_or_else(|e| { + panic!( + "COULD NOT CHECK: cannot open {} to plant ({e})", + path.display() + ) + }) + .into_raw_fd(); + // SAFETY: pre_exec runs in the post-fork child; `dup2(2)` is + // async-signal-safe and nothing here allocates. + unsafe { + cmd.pre_exec(move || { + if libc::dup2(raw, target_fd) < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } +} diff --git a/tests/constraint_register.rs b/tests/constraint_register.rs new file mode 100644 index 0000000..fa9add6 --- /dev/null +++ b/tests/constraint_register.rs @@ -0,0 +1,673 @@ +// SPDX-License-Identifier: BSD-3-Clause +//! Gate `CONSTRAINTS.md` — the register's status column, checked mechanically. +//! +//! Four register-shaped artifacts in this project drifted for one reason: +//! **nothing could check the status column.** A `confidence` field sat 94.7% in +//! its top two grades; an attack catalogue's `[unverified]` markers were unowned; +//! a queue's ranks were recomputed and never consumed; the §2 residual list went +//! stale twice in two days. A constraint's status is different because it is +//! *checkable* — does the row name an artifact, and does that artifact exist? +//! +//! **It is Rust, in this tree, deliberately.** It was python in a private corpus +//! reading this repository across a tree boundary, so four gates verified oring +//! and none of them ran here. As an integration test it needs no workflow change, +//! no kernel, no privileges and no features. No `regex` dependency either — a +//! dev-dependency is a supply-chain decision in a tree that runs `cargo deny`. +//! +//! **Does NOT establish** that a row is *true*. It cannot know whether a cited +//! test fails when the mechanism is removed, which is exactly what +//! `status: enforced` claims; only a mutation run supports that, and adversarial +//! review owns it. This checks presence, the closed status set, that named +//! artifacts resolve, that verification is dated, and that the derivation closes +//! in **both** directions. +//! +//! **"Nothing wrong" and "nothing examined" are kept apart.** The python original +//! separated exit 1 (a row violates the schema) from exit 2 (the register could +//! not be checked). A Rust test has only pass and panic, so the distinction lives +//! in the test *names*: `the_register_is_present_and_parses` is the old exit 2. +//! +//! **Failure paths:** status outside the closed set; a required field removed; an +//! `enforced-by` naming a path that does not exist; `last-verified` undated; a +//! statement rephrased as a prohibition; an orphaned loss; a `derives-from` +//! naming nothing; the stated fraction disagreeing with the status fields; +//! register absent; no rows parsed. All confirmed red. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +/// The closed set. A status outside it is a violation, not a new category. +const STATUSES: [&str; 4] = [ + "enforced", + "enforced-unpinned", + "unenforced", + "unenforced-by-decision", +]; + +const REQUIRED: [&str; 7] = [ + "derives-from", + "controller", + "enforced-by", + "observable-via", + "status", + "last-verified", + "would-change", +]; + +/// The three derivation levels. All must be present; a level the parser cannot find +/// is a could-not-check, never a clean result. +const LEVELS: [&str; 3] = ["Losses", "Hazards", "Feedback defects"]; + +/// A field may decline to name an artifact, but it must decline EXPLICITLY. This is the +/// defect that let `[unverified]` markers sit for days meaning nothing in particular. +const GAP_PREFIXES: [&str; 2] = ["*(gap", "*(none"]; + +fn register_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("CONSTRAINTS.md") +} + +struct Row { + id: String, + statement: String, + fields: BTreeMap, +} + +/// `- **key:** value` — the key must be lowercase-and-hyphens, mirroring the original +/// `^- \*\*([a-z-]+):\*\* (.+?)$`, so a bolded prose bullet is not mistaken for a field. +fn parse_field(line: &str) -> Option<(String, String)> { + let rest = line.strip_prefix("- **")?; + let (key, value) = rest.split_once(":** ")?; + if key.is_empty() || !key.chars().all(|c| c.is_ascii_lowercase() || c == '-') { + return None; + } + Some((key.to_string(), value.trim().to_string())) +} + +fn parse_rows(text: &str) -> Vec { + let mut rows: Vec = Vec::new(); + for line in text.lines() { + if let Some(rest) = line.strip_prefix("## CON-") { + if let Some((num, statement)) = rest.split_once(" — ") { + if num.chars().all(|c| c.is_ascii_digit()) && !num.is_empty() { + rows.push(Row { + id: format!("CON-{num}"), + statement: statement.trim().to_string(), + fields: BTreeMap::new(), + }); + continue; + } + } + } + // A new `## ` section that is not a row ends the current row's body, so later + // prose cannot contribute fields to the last constraint. + if line.starts_with("## ") { + if let Some(last) = rows.last_mut() { + last.fields.insert("__closed__".to_string(), String::new()); + } + continue; + } + if let Some(last) = rows.last_mut() { + if last.fields.contains_key("__closed__") { + continue; + } + if let Some((k, v)) = parse_field(line) { + last.fields.insert(k, v); + } + } + } + for r in &mut rows { + r.fields.remove("__closed__"); + } + rows +} + +/// Derivation entries, as `id -> level`. Each entry is `` - `ID` `` optionally followed +/// by an em-dash gloss; the gloss is deliberately not required and not parsed. The +/// original anchored on end-of-line here and silently found ZERO entries the day a +/// gloss was added — and `0 entries, 0 orphans` reads exactly like clean. +fn parse_derivation(text: &str) -> Result, String> { + let lines: Vec<&str> = text.lines().collect(); + let mut out = BTreeMap::new(); + for level in LEVELS { + let head = format!("### {level}"); + // Prefix match, not equality: a heading may carry a trailing annotation such as + // "(multipliers, not ranks)". An over-strict anchor turns a prose edit into a + // false alarm, which is how a gate stops being read. + let Some(start) = lines.iter().position(|l| l.starts_with(&head)) else { + return Err(format!( + "derivation section '{level}' is absent — the gate cannot check coverage \ + against a list it cannot find" + )); + }; + let mut found = 0usize; + for line in &lines[start + 1..] { + if line.starts_with("### ") || line.starts_with("---") { + break; + } + if let Some(rest) = line.strip_prefix("- `") { + if let Some((id, _)) = rest.split_once('`') { + out.insert(id.to_string(), level.to_string()); + found += 1; + } + } + } + if found == 0 { + return Err(format!( + "derivation section '{level}' parsed ZERO entries — an empty list would \ + make every loss name resolve vacuously" + )); + } + } + Ok(out) +} + +/// Tokens between backticks, odd-indexed on a split. +fn backticked(s: &str) -> Vec<&str> { + s.split('`').skip(1).step_by(2).collect() +} + +fn has_iso_date(s: &str) -> bool { + let b = s.as_bytes(); + b.windows(10).any(|w| { + w[..4].iter().all(u8::is_ascii_digit) + && w[4] == b'-' + && w[5..7].iter().all(u8::is_ascii_digit) + && w[7] == b'-' + && w[8..10].iter().all(u8::is_ascii_digit) + }) +} + +/// Leveson's distinction, made greppable. "The child must not escape" is not an +/// enforceable constraint; "every capability is named by the policy" is. +/// +/// **The carve-out is gone, and that is the interesting part.** This function used to +/// special-case the phrase `does not deny` for exactly one row, CON-4 — a +/// loss-avoidance goal that could not be phrased positively because it had no enforcer +/// to name. The comment here said that if CON-4's scope was ever settled the carve-out +/// should go with it. CON-4 was deleted on 2026-07-29 as out of scope, so it did. +/// +/// The check is now unconditional over all 8 statements, which makes it **strictly +/// stronger than the python original it was ported from**: that one still carries the +/// carve-out. A single-row exception in a schema check is a place a second row can hide +/// later, and it was only removable because the row was removable. +fn is_prohibition(statement: &str) -> bool { + let padded = format!(" {} ", statement.to_lowercase()); + [" must not ", " does not ", " never "] + .iter() + .any(|p| padded.contains(p)) +} + +fn stated_fraction(text: &str) -> Option<(usize, usize)> { + let line = text + .lines() + .find(|l| l.contains("Fraction enforced:") && l.contains(" of "))?; + let after = line.split("Fraction enforced:").nth(1)?; + let nums: Vec = after + .split(|c: char| !c.is_ascii_digit()) + .filter(|s| !s.is_empty()) + .filter_map(|s| s.parse().ok()) + .collect(); + match nums.as_slice() { + [n, d, ..] => Some((*n, *d)), + _ => None, + } +} + +fn load() -> String { + let p = register_path(); + std::fs::read_to_string(&p).unwrap_or_else(|e| { + panic!( + "COULD NOT CHECK: {} is unreadable ({e}). A register that is missing must \ + not read as a register that is clean.", + p.display() + ) + }) +} + +/// The old exit 2. Kept as its own test so a reader learns from the FAILING TEST NAME +/// whether the register was found wanting or simply not examined. +#[test] +fn the_register_is_present_and_parses() { + let text = load(); + let rows = parse_rows(&text); + assert!( + !rows.is_empty(), + "COULD NOT CHECK: no `## CON- — …` rows parsed out of CONSTRAINTS.md. The \ + parser found nothing, which is not the same as finding nothing wrong." + ); + let deriv = parse_derivation(&text).unwrap_or_else(|e| panic!("COULD NOT CHECK: {e}")); + println!(" rows parsed: {}", rows.len()); + println!(" derivation entries: {}", deriv.len()); +} + +/// The schema check, as a FUNCTION over the register's TEXT. +/// +/// Taking text rather than a path is what lets this be red-tested **in-process**: a +/// mutation test passes a modified `String` and asserts the specific violation comes +/// back. No temp files, no subprocess, and — the part that matters — **no mutation of a +/// tracked document**. The previous red-test lived outside this tree in `sh` and worked +/// by editing `CONSTRAINTS.md` in place and re-running `cargo test`; three of its anchors +/// silently went stale within hours because they were string literals in another repo. +/// Here the gate and its red-tests are the same file and cannot drift apart. +/// +/// `root` stays a parameter for artifact resolution only, so a mutated-text test still +/// resolves `tests/*.rs` against the real tree. +fn conformance_violations(text: &str, root: &Path) -> (Vec, usize, usize) { + let rows = parse_rows(text); + let deriv = parse_derivation(text).unwrap_or_else(|e| panic!("COULD NOT CHECK: {e}")); + + let mut violations: Vec = Vec::new(); + let mut checked_fields = 0usize; + let mut checked_paths = 0usize; + + for row in &rows { + for key in REQUIRED { + if row.fields.contains_key(key) { + checked_fields += 1; + } else { + violations.push(format!("{}: missing required field `{key}`", row.id)); + } + } + + if is_prohibition(&row.statement) { + violations.push(format!( + "{}: statement is phrased as a prohibition, not an enforceable \ + constraint: {:?}", + row.id, row.statement + )); + } + + let df = row + .fields + .get("derives-from") + .map(|v| { + v.split('(') + .next() + .unwrap_or("") + .trim() + .trim_matches('`') + .trim() + .to_string() + }) + .unwrap_or_default(); + if !df.is_empty() && !deriv.contains_key(&df) { + violations.push(format!( + "{}: `derives-from` names {df:?}, which is in no derivation section. A \ + row deriving from nothing is a rule someone invented.", + row.id + )); + } + + let st = row + .fields + .get("status") + .map(|v| v.trim().trim_matches('`').to_string()) + .unwrap_or_default(); + if !STATUSES.contains(&st.as_str()) { + violations.push(format!( + "{}: status {st:?} is not in the closed set {STATUSES:?}", + row.id + )); + } + + for key in ["enforced-by", "observable-via"] { + let Some(val) = row.fields.get(key) else { + continue; // already reported as a missing required field + }; + if GAP_PREFIXES.iter().any(|p| val.starts_with(p)) { + continue; + } + let toks = backticked(val); + let paths: Vec<&&str> = toks + .iter() + .filter(|t| t.ends_with(".rs") || t.ends_with(".md")) + .collect(); + if paths.is_empty() && toks.is_empty() { + violations.push(format!( + "{}: `{key}` names neither an artifact nor an explicit gap", + row.id + )); + continue; + } + for p in paths { + checked_paths += 1; + if !root.join(p).is_file() { + violations.push(format!( + "{}: `{key}` names {p} which does not exist in this tree", + row.id + )); + } + } + } + + if !row + .fields + .get("last-verified") + .is_some_and(|v| has_iso_date(v)) + { + violations.push(format!( + "{}: `last-verified` carries no date — a status with no date is a status \ + nobody can age", + row.id + )); + } + } + + (violations, checked_fields, checked_paths) +} + +#[test] +fn every_row_conforms_to_the_schema() { + let text = load(); + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let (violations, checked_fields, checked_paths) = conformance_violations(&text, &root); + + // Print what this DID, not what it intended. A driver that enumerates work must + // count what it fed, and it must print the FRAME it counted over. + println!( + " rows parsed: {} fields checked: {checked_fields} artifact paths resolved: \ + {checked_paths}", + parse_rows(&text).len() + ); + + assert!( + violations.is_empty(), + "constraint register: {} violation(s)\n {}", + violations.len(), + violations.join("\n ") + ); +} + +/// The half the first version of this gate could not see. It enumerated rows and asked +/// whether each named a loss, so **a loss with no constraint was invisible by +/// construction** and it returned clean — this project's own count-what-you-fed defect, +/// one level up. +fn derivation_orphans(text: &str) -> (Vec, usize, usize) { + let rows = parse_rows(text); + let deriv = parse_derivation(text).unwrap_or_else(|e| panic!("COULD NOT CHECK: {e}")); + + let claimed: BTreeSet = rows + .iter() + .filter_map(|r| r.fields.get("derives-from")) + .map(|v| { + v.split('(') + .next() + .unwrap_or("") + .trim() + .trim_matches('`') + .trim() + .to_string() + }) + .filter(|s| !s.is_empty()) + .collect(); + + let orphans: Vec = deriv + .iter() + .filter(|(id, _)| !claimed.contains(*id)) + .map(|(id, level)| { + let singular = level.strip_suffix('s').unwrap_or(level); + format!( + "{singular} {id:?} is named by no constraint — an unconstrained loss is \ + invisible to every other check in this file" + ) + }) + .collect(); + + let claimed_real = claimed.iter().filter(|c| deriv.contains_key(*c)).count(); + (orphans, deriv.len(), claimed_real) +} + +/// The half the first version of this gate could not see. +#[test] +fn the_derivation_closes_in_both_directions() { + let text = load(); + let (orphans, entries, claimed_real) = derivation_orphans(&text); + println!( + " derivation entries: {entries} claimed by a row: {claimed_real} orphaned: {}", + orphans.len() + ); + assert!( + orphans.is_empty(), + "derivation does not close: {} orphan(s)\n {}", + orphans.len(), + orphans.join("\n ") + ); +} + +/// The register states a fraction in prose. A hand-maintained count is exactly the kind +/// of row this project has already watched drift four times, so it is recomputed here +/// and the two must agree. +/// `Ok((enforced, total))` when prose and rows agree; `Err(message)` when they do not. +fn fraction_agreement(text: &str) -> Result<(usize, usize), String> { + let rows = parse_rows(text); + assert!(!rows.is_empty(), "COULD NOT CHECK: no rows parsed"); + + let statuses: Vec<(String, String)> = rows + .iter() + .map(|r| { + ( + r.id.clone(), + r.fields + .get("status") + .map(|v| v.trim().trim_matches('`').to_string()) + .unwrap_or_default(), + ) + }) + .collect(); + let enforced = statuses.iter().filter(|(_, s)| s == "enforced").count(); + + let detail = statuses + .iter() + .map(|(c, s)| format!("{c}={s}")) + .collect::>() + .join(", "); + println!( + " fraction enforced: {enforced} of {} ({detail})", + rows.len() + ); + + let (stated_n, stated_d) = stated_fraction(text).unwrap_or_else(|| { + panic!( + "COULD NOT CHECK: no `Fraction enforced: of ` line found. The register \ + promises the gate recomputes this number; without the line there is nothing \ + to compare against." + ) + }); + if (stated_n, stated_d) != (enforced, rows.len()) { + return Err(format!( + "the register states 'Fraction enforced: {stated_n} of {stated_d}' but the \ + status fields compute {enforced} of {}. The prose and the rows disagree — fix \ + whichever is wrong, and note that this number moves when SCOPE changes as \ + well as when work lands.", + rows.len() + )); + } + Ok((enforced, rows.len())) +} + +/// The register states a fraction in prose; a hand-maintained count has drifted four +/// times in this project, so it is recomputed and the two must agree. +#[test] +fn the_stated_fraction_enforced_matches_the_status_fields() { + let text = load(); + match fraction_agreement(&text) { + Ok((enforced, total)) => println!(" fraction enforced: {enforced} of {total}"), + Err(e) => panic!("{e}"), + } +} + +// --------------------------------------------------------------------------- +// RED-TESTS — can this gate fail at all? +// +// A gate never seen red is not known to work. These live in the SAME FILE as the checks +// they mutate, which is the point of taking text rather than a path: each case edits an +// in-memory `String` and asserts the SPECIFIC violation comes back. +// +// WHAT THIS REPLACES. The red-test used to live outside this tree, in `sh`, and worked by +// editing `CONSTRAINTS.md` in place and re-running `cargo test` — so it could only observe +// an EXIT CODE, it mutated a tracked document, and three of its anchors silently went +// stale within hours because they were string literals in a different repository. Every +// `expect_violation` below asserts on the message, so a case that stops mutating anything +// fails loudly here instead of passing quietly there. +#[cfg(test)] +mod red { + use super::*; + + fn real() -> (String, PathBuf) { + (load(), PathBuf::from(env!("CARGO_MANIFEST_DIR"))) + } + + /// Apply a mutation and REQUIRE that it changed something — a no-op mutation is a + /// stale anchor, and a stale anchor that still runs the check is a case testing + /// nothing while printing success. + fn mutate(text: &str, from: &str, to: &str) -> String { + assert!( + text.contains(from), + "STALE ANCHOR: {from:?} is not in CONSTRAINTS.md — this case tested nothing" + ); + text.replacen(from, to, 1) + } + + fn expect_violation(text: &str, root: &Path, needle: &str) { + let (v, _, _) = conformance_violations(text, root); + assert!( + v.iter().any(|m| m.contains(needle)), + "expected a violation containing {needle:?}; got {v:?}" + ); + } + + #[test] + fn the_unmutated_register_is_clean() { + let (text, root) = real(); + let (v, fields, paths) = conformance_violations(&text, &root); + assert!(v.is_empty(), "baseline must be clean; got {v:?}"); + assert!( + fields > 0 && paths > 0, + "and it must have checked something" + ); + assert!(derivation_orphans(&text).0.is_empty()); + assert!(fraction_agreement(&text).is_ok()); + } + + #[test] + fn a_status_outside_the_closed_set_is_caught() { + let (text, root) = real(); + let m = mutate(&text, "**status:** `enforced`", "**status:** `mostly`"); + expect_violation(&m, &root, "not in the closed set"); + } + + #[test] + fn a_missing_required_field_is_caught() { + let (text, root) = real(); + let m = text + .lines() + .filter(|l| !l.starts_with("- **controller:**")) + .collect::>() + .join("\n"); + assert_ne!(m, text, "STALE ANCHOR: no controller field to drop"); + expect_violation(&m, &root, "missing required field `controller`"); + } + + #[test] + fn an_artifact_that_does_not_exist_is_caught() { + let (text, root) = real(); + let m = mutate(&text, "`src/landlock.rs`", "`src/does_not_exist.rs`"); + expect_violation(&m, &root, "does not exist in this tree"); + } + + #[test] + fn an_undated_last_verified_is_caught() { + let (text, root) = real(); + // Anchored on the FIELD, not on the date's prose: the previous driver pinned + // "2026-07-29 — 134 tests" and went stale the same day the count changed. + let i = text + .find("- **last-verified:** 2") + .expect("STALE ANCHOR: no dated last-verified field"); + let end = text[i..].find('\n').map_or(text.len(), |n| i + n); + let m = format!( + "{}- **last-verified:** recently{}", + &text[..i], + &text[end..] + ); + expect_violation(&m, &root, "carries no date"); + } + + #[test] + fn a_prohibition_shaped_statement_is_caught() { + let (text, root) = real(); + let i = text.find("## CON-").expect("no rows"); + let end = text[i..].find('\n').map_or(text.len(), |n| i + n); + let m = format!( + "{}## CON-1 — The child must not obtain a capability{}", + &text[..i], + &text[end..] + ); + expect_violation(&m, &root, "phrased as a prohibition"); + } + + #[test] + fn a_derives_from_naming_nothing_real_is_caught() { + let (text, root) = real(); + let m = mutate( + &text, + "- **derives-from:** `LAUNDER`", + "- **derives-from:** `NOT_A_LOSS`", + ); + expect_violation(&m, &root, "is in no derivation section"); + } + + /// The reverse direction — a loss no row claims. Anchored on the HEADING so it + /// survives any loss being renamed or retired. + #[test] + fn a_derivation_entry_no_row_claims_is_caught() { + let (text, _) = real(); + let m = mutate( + &text, + "### Losses\n", + "### Losses\n- `STARVE` — an invented loss that no row claims\n", + ); + let (orphans, _, _) = derivation_orphans(&m); + assert!( + orphans.iter().any(|o| o.contains("STARVE")), + "an unclaimed loss must be reported; got {orphans:?}" + ); + } + + /// Anchored on the numerator only — the DENOMINATOR moves when a row is retired, + /// which is exactly what went stale in the external driver. + #[test] + fn a_stated_fraction_that_disagrees_is_caught() { + let (text, _) = real(); + let (n, d) = stated_fraction(&text).expect("no fraction line"); + let m = mutate( + &text, + &format!("Fraction enforced: {n} of {d}"), + &format!("Fraction enforced: {} of {d}", n + 2), + ); + let err = fraction_agreement(&m).expect_err("a disagreeing fraction must be an Err"); + assert!(err.contains("disagree"), "got {err:?}"); + } + + /// The old exit 2: no rows at all. A parser that finds nothing has not found nothing + /// wrong. + #[test] + fn a_register_with_no_rows_parses_to_nothing() { + let (text, _) = real(); + let m = text + .lines() + .map(|l| { + if l.starts_with("## CON-") { + format!("#{l}") + } else { + l.to_string() + } + }) + .collect::>() + .join("\n"); + assert_ne!(m, text, "STALE ANCHOR: no CON- rows to disable"); + assert!( + parse_rows(&m).is_empty(), + "with every row heading demoted, the parser must find none — which the \ + presence test turns into a failure" + ); + } +} diff --git a/tests/containment_matrix.rs b/tests/containment_matrix.rs index 10be82b..38170ff 100644 --- a/tests/containment_matrix.rs +++ b/tests/containment_matrix.rs @@ -1,14 +1,41 @@ // SPDX-License-Identifier: BSD-3-Clause -//! Containment matrix (§11 / NCC F4): ONE committed table pinning oring's entire -//! allow/deny/residual surface per profile. A single test runs every cell, -//! collects ALL mismatches, and renders an aligned diff. Any silent widening — -//! a block regressing to allow, OR a documented §2 residual quietly closing — -//! fails loud and names the DESIGN §2 line to fix. +//! Containment matrix (§11 / NCC F4): ONE committed table pinning oring's whole +//! allow/deny/residual surface per profile. One test runs every cell, collects +//! ALL mismatches, and renders an aligned diff. Any silent widening — a block +//! regressing to allow, or a documented residual quietly closing — fails loud. //! -//! Construction-safe: every payload targets only harness-owned throwaway -//! resources (tempdir / loopback / $HOME sentinel socket / decoy sibling) with -//! null-or-benign primitives, so a containment REGRESSION cannot harm the host. -//! (The live-destructive cells behind the userns/netns jail are a separate file.) +//! Construction-safe: every payload targets harness-owned throwaway resources +//! with null-or-benign primitives, so a containment regression cannot harm the +//! host. The live-destructive cells behind the userns/netns jail are elsewhere. +//! +//! # Attribution — checked against a bare control, every run (T21) +//! +//! A cell asserting an errno does not establish that *oring* produced the +//! denial: where DAC or a host MAC already forbids the operation, a `Deny` cell +//! passes on the other layer's authority. The failure is silent and +//! one-directional — a Residual cell that starts being denied fails loudly, but a +//! `Deny` cell denied for the wrong reason passes forever. +//! +//! **Every denial cell runs BARE first and the pair is compared** with the same +//! `verdict_ok` the sealed arm uses, so "indistinguishable" means exactly what +//! PASS means. Such a cell is reported UNATTRIBUTABLE, not PASS, and must appear +//! in `UNATTRIBUTABLE_CELLS` — checked in both directions, so the list can only +//! shrink. It is currently **empty: 21 of 21 attributed.** +//! +//! **Why this replaced a hand-check rather than supplementing one.** The control +//! was run by hand on two dates, and a manual pass does not survive people adding +//! cells: the T19 pair landed the day after the last one and was never covered. +//! A dated note describes the tree on the day it was written; this describes it on +//! the run. Recomputing reproduced the hand-check exactly before the one +//! unattributable cell was **fixed rather than declared** — it now writes into a +//! harness-owned `read_only` grant that DAC permits, with `fs-read-readonly-tier` +//! as the positive control, because "write denied in a read_only tier" and "write +//! denied because the path was never granted" are otherwise indistinguishable +//! (measured: dropping the grant leaves the write cell passing). +//! +//! **Still not established:** which layer produced an *attributed* denial. +//! Bare-succeeds shows the environment does not supply it; not that ring 1 rather +//! than ring 2 did. Stamping host MAC state into the report is the other half. #![cfg(target_os = "linux")] mod common; use common::*; @@ -16,6 +43,20 @@ use common::*; const SIGSYS: i32 = 31; const PORTS_443: &[u16] = &[443]; +/// T21: denial cells whose bare arm produces the SAME observation as the sealed +/// arm, so the denial cannot be attributed to oring. +/// +/// **This list may only shrink**, and the check below fails in both directions: +/// a cell that becomes unattributable and is not listed is new debt, and a +/// listed cell that becomes attributable is a stale entry. Its length is the +/// size of the gap, computed every run rather than hand-checked on a date. +/// +/// A cell here is NOT wrong — the sealed behaviour is still correct. What it +/// cannot do is serve as *evidence*, because the environment already produces +/// the same answer. Prefer re-targeting the cell at something DAC permits over +/// adding a name here. +const UNATTRIBUTABLE_CELLS: [(&str, &str); 0] = []; + #[derive(Clone, Copy)] enum Expect { Deny(i32), // ring blocked with this errno; sig None @@ -33,6 +74,9 @@ enum Arg { AbstractName, // name of the harness-bound abstract-namespace socket WorkFile, // a file in the write_exec workdir TmpFile, // a file in /tmp (write_noexec tier) + RoFile, // a NEW file in a harness-owned dir granted READ_ONLY, outside every + // other tier — DAC permits the write, Landlock denies it + RoFileExists, // an EXISTING file in that same dir, for the positive control } struct Row { @@ -50,7 +94,13 @@ struct Row { #[rustfmt::skip] const ROWS: &[Row] = &[ // --- FS --- - Row{id:"fs-write-readonly", taxon:"FS", op:"fs-write", arg:Arg::S("/etc/oring-matrix"), profile:"balanced", proc_mode:None, connect:None, expect:Expect::Deny(EACCES), doc:""}, + Row{id:"fs-write-readonly", taxon:"FS", op:"fs-write", arg:Arg::RoFile, profile:"balanced", proc_mode:None, connect:None, expect:Expect::Deny(EACCES), doc:""}, + // POSITIVE CONTROL for the row above, and the reason it is not optional: without + // it, "write denied in a read_only tier" is indistinguishable from "write denied + // because the path was never granted". This cell shows the grant is LIVE — the + // same directory is readable under the same policy — so the write denial is the + // tier's semantics rather than an absent rule. + Row{id:"fs-read-readonly-tier", taxon:"FS", op:"fs-read", arg:Arg::RoFileExists, profile:"balanced", proc_mode:None, connect:None, expect:Expect::Allow, doc:""}, Row{id:"fs-write-workdir", taxon:"FS", op:"fs-write", arg:Arg::WorkFile, profile:"balanced", proc_mode:None, connect:None, expect:Expect::Allow, doc:""}, Row{id:"wx-noexec-tier", taxon:"FS", op:"write-exec-file", arg:Arg::TmpFile, profile:"balanced", proc_mode:None, connect:None, expect:Expect::Deny(EACCES), doc:""}, Row{id:"wx-exec-workdir", taxon:"FS", op:"write-exec-file", arg:Arg::WorkFile, profile:"balanced", proc_mode:None, connect:None, expect:Expect::Allow, doc:"§2 in-tree W^X residual"}, @@ -58,7 +108,13 @@ const ROWS: &[Row] = &[ // --- NET --- Row{id:"tcp-unlisted-port", taxon:"NET", op:"net-connect", arg:Arg::S("22"), profile:"balanced", proc_mode:None, connect:Some(PORTS_443), expect:Expect::Deny(EACCES), doc:""}, Row{id:"tcp-allowed-port", taxon:"NET", op:"net-connect", arg:Arg::S("443"), profile:"balanced", proc_mode:None, connect:Some(PORTS_443), expect:Expect::NotBlocked, doc:"§2 any-IP on allowed port"}, - Row{id:"udp-egress", taxon:"NET", op:"udp-send", arg:Arg::S("127.0.0.1:53"), profile:"balanced", proc_mode:None, connect:Some(PORTS_443), expect:Expect::NotBlocked, doc:"§2 UDP/DNS uncovered"}, + // NotBlocked here is the UNHANDLED-field residual, NOT the shipped profile: every + // net row runs a policy synthesized by `policy_json_sec`, whose `profile` argument + // selects the SECCOMP profile only and which never emits `egress_udp`. The shipped + // `balanced` grants :53 and denies every other UDP port (2026-07-29), so reading this + // cell as "the balanced profile leaves UDP open" is wrong in both directions. The + // profile's own posture is pinned in `src/profile.rs`, not here. + Row{id:"udp-egress", taxon:"NET", op:"udp-send", arg:Arg::S("127.0.0.1:53"), profile:"balanced", proc_mode:None, connect:Some(PORTS_443), expect:Expect::NotBlocked, doc:"§2 residual of an UNHANDLED egress_udp — synthesized policy, not the profile"}, // --- SC (syscall LPE) --- Row{id:"ptrace", taxon:"SC", op:"sc-ptrace", arg:Arg::None, profile:"balanced", proc_mode:None, connect:None, expect:Expect::Deny(EPERM), doc:""}, @@ -79,8 +135,9 @@ const ROWS: &[Row] = &[ // --- IPC (AF_UNIX connect — walled by two complementary mechanisms) --- // Pathname sockets: RESOLVE_UNIX (V9, handled + granted nowhere) -> EACCES. - // Requires the `resolve-unix` feature AND kernel ABI>=9 (the wall); the runner - // skips these otherwise, where the connect is the §2 unmediated residual. + // V9 is the hard floor as of v0.2, so these are unconditional proof cells: + // every kernel oring agrees to run on has the wall, and the sub-floor kernels + // where this connect was the §2 unmediated residual are refused outright. Row{id:"unix-connect-balanced", taxon:"IPC", op:"unix-connect", arg:Arg::UnixPath, profile:"balanced", proc_mode:None, connect:None, expect:Expect::Deny(EACCES), doc:""}, Row{id:"unix-connect-strict", taxon:"IPC", op:"unix-connect", arg:Arg::UnixPath, profile:"strict", proc_mode:None, connect:None, expect:Expect::Deny(EACCES), doc:""}, // Abstract sockets: RESOLVE_UNIX is path-based and does NOT cover them; IPC @@ -89,16 +146,126 @@ const ROWS: &[Row] = &[ Row{id:"unix-abstract-strict", taxon:"IPC", op:"unix-connect-abstract", arg:Arg::AbstractName, profile:"strict", proc_mode:None, connect:None, expect:Expect::Deny(EPERM), doc:""}, // --- PROC (peer procfs + --proc mitigation) --- - // Peer SECRET (environ) is a RESIDUAL at the V7 floor: with /proc granted - // (--proc full) the read is NOT blocked on kernel 6.15 (measured). A newer - // kernel's ptrace_access_check hook was seen to deny it on 7.1.3, but that is - // not a floor guarantee — so the honest expectation here is NotBlocked. Peer - // METADATA (cmdline) likewise leaks under --proc full; --proc self closes the - // /proc PATH for both. Own environ under --proc self is readable (control). - Row{id:"proc-peer-env-full", taxon:"PROC", op:"proc-read", arg:Arg::SentinelPid, profile:"balanced", proc_mode:Some("full"), connect:None, expect:Expect::NotBlocked, doc:"§2 peer environ read is a residual (not walled at the V7 floor)"}, + // CORRECTED 2026-07-26. This block used to say peer environ "was a RESIDUAL + // at the old V7 floor ... NOT blocked on kernel 6.15 (measured) ... seen to + // deny it on 7.1.3, which at a V7 floor was a version bonus, not a + // guarantee". That framing is wrong, and the chain of docs built on it has + // been retracted (DESIGN §2/§4/D-30, ATTACKS row 12, ROADMAP G11). + // + // The denier is hook_ptrace_access_check, which consults NO handled access + // right — domain_scope_le() compares domain hierarchies — and the rule + // predates the 5.13 merge, so the V9 floor bought none of it. The outcome + // turns on domain ancestry-or-EQUALITY (siblings under one seal read each + // other), and ALSO on privilege: CAP_PERFMON bypasses it entirely (6.15+). + // A peer forked BEFORE the seal is denied; a child forked AFTER it is + // readable. The old 6.15 NotBlocked result is UNEXPLAINED — not + // evidence of a kernel change — it is what this cell reports when its + // target sits inside the reader's own domain. + // + // THIS CELL IS SOUND FOR THE RIGHT REASON: the sentinel is spawned by the + // harness, OUTSIDE the cage, which is the case that matters. If that ever + // changes, the cell silently stops measuring anything. + // + // Measured on Fedora 44 / 7.1.4 / ABI V9: + // the confined peer-environ read returns EACCES. Ruled out SELinux as the + // denier rather than assuming — the same read succeeds BARE on that host + // (rc=0) under the identical security context, `deny_ptrace` is off, and no + // AVC is involved; and it is peer-SPECIFIC, since own environ still reads + // (proc-self-env-self below). So the denial is oring's ring on a >=7.1 kernel. + // + // SCOPE OF THE CLAIM: `environ` is what this cell probes. `maps`/`mem` pass + // through the same ptrace_may_access gate and are expected to follow, but are + // NOT directly witnessed here — do not widen the claim without a cell. + // + // Peer METADATA (cmdline) still leaks under --proc full (world-readable, + // ptrace-independent) — that residual stands. --proc self closes the /proc + // PATH for both. Own environ under --proc self is readable (control). + Row{id:"proc-peer-env-full", taxon:"PROC", op:"proc-read", arg:Arg::SentinelPid, profile:"balanced", proc_mode:Some("full"), connect:None, expect:Expect::Deny(EACCES), doc:"peer environ walled by hook_ptrace_access_check — sentinel is OUTSIDE our domain; rule predates 5.13, not bought by the V9 floor"}, Row{id:"proc-peer-cmd-full", taxon:"PROC", op:"proc-cmdline", arg:Arg::SentinelPid, profile:"balanced", proc_mode:Some("full"), connect:None, expect:Expect::NotBlocked, doc:"§2 peer metadata (cmdline) leak"}, Row{id:"proc-peer-cmd-self", taxon:"PROC", op:"proc-cmdline", arg:Arg::SentinelPid, profile:"balanced", proc_mode:Some("self"), connect:None, expect:Expect::Deny(EACCES), doc:"--proc self closes the /proc path"}, Row{id:"proc-self-env-self", taxon:"PROC", op:"proc-read", arg:Arg::S("self"), profile:"balanced", proc_mode:Some("self"), connect:None, expect:Expect::NotBlocked, doc:""}, + // IN-DOMAIN, added 2026-07-29 (BUILD-QUEUE T19). The four rows above all target the + // Sentinel — spawned BEFORE the seal, so OUT-OF-DOMAIN — or `self`. None was in-domain, + // and the mitigation the project publishes is about exactly + // that case: *"use --proc self if you need environ isolation between your own + // subprocesses"*. It rested on one hand-run measurement on one kernel and was pinned by + // nothing here, so nothing would have noticed if a kernel change closed the read or if + // --proc self stopped denying it. + // + // The probe spawns two SIBLINGS under one seal. Siblings and not parent->child on + // purpose: `ptrace_may_access` permits an ancestor to read a descendant regardless of + // Landlock, so a parent reading its own child would pass for reasons unrelated to the + // domain relation. + // + // TWO DIFFERENT MECHANISMS, which SECURITY.md had to separate on 2026-07-28 after + // presenting them as one controlled pair — the ids say which is which: + // full: Landlock scopes ancestor-or-equal, so siblings share a domain and the ptrace + // hook PERMITS the read. A documented residual, not a defect. + // self: the sibling's /proc/ is not a granted path, so it fails at the Landlock + // FS TIER *before* ptrace_may_access is consulted. Different layer entirely. + Row{id:"proc-indomain-env-full", taxon:"PROC", op:"proc-indomain-env", arg:Arg::S("x"), profile:"balanced", proc_mode:Some("full"), connect:None, expect:Expect::NotBlocked, doc:"§2 in-domain peer environ: two processes under one seal share a domain"}, + Row{id:"proc-indomain-env-self", taxon:"PROC", op:"proc-indomain-env", arg:Arg::S("x"), profile:"balanced", proc_mode:Some("self"), connect:None, expect:Expect::Deny(EACCES), doc:"§2: --proc self isolates a subprocess's environ — denied at the FS tier, not by the domain relation"}, +]; + +/// A COMPOSITION cell: several probes under ONE policy, all of which must hold. +/// +/// **Why this is a separate shape and not a wider `Row`.** A `Row` carries exactly one +/// `op`, and that is not an oversight to be patched — a per-mechanism cell and a +/// composition cell answer different questions and need different invariants. Widening +/// `Row` would have meant rewriting all 30 existing cells in a safety-critical table to +/// express something none of them needs. `CONSTRAINTS.md` CON-1 records the gap this +/// closes: *"every suite is per-mechanism and nothing exercises a pair."* +/// +/// **Two shape rules are enforced at run time, below, because a composition cell is easy +/// to write badly:** +/// +/// 1. **At least two probes.** One probe is a `Row`; putting it here claims something it +/// does not test. +/// 2. **At least one non-denial expectation.** A conjunction of denials is satisfied by a +/// seal that refuses *everything*, so without a reachability control the cell can pass +/// while proving nothing. My first hand-written composition test needed exactly this +/// control, which is why the rule is encoded rather than remembered. +struct Composition { + id: &'static str, + /// The documented dependency being exercised. Required prose: a composition cell that + /// cannot name the pair it tests is two assertions in a row wearing one id. + dependency: &'static str, + profile: &'static str, + proc_mode: Option<&'static str>, + connect: Option<&'static [u16]>, + probes: &'static [(&'static str, Arg, Expect)], +} + +#[rustfmt::skip] +const COMPOSITIONS: &[Composition] = &[ + // §4: ring 1's TCP gate is "port-only, and dependent on the ring-2 MSG_FASTOPEN block + // for the wall to hold". `connect(2)` is refused by ring 1 at + // security_socket_connect; `sendto(.., MSG_FASTOPEN, &sa)` performs an implicit + // connect INSIDE the send path that never reaches that hook, so only ring 2's + // arg-masked rule can stop it. Port 9 is what the fastopen probe targets, and + // `connect: [443]` switches ring 1's net gate ON while leaving 9 unlisted — a policy + // shape `deny_syscall::msg_fastopen_is_blocked` does not cover, since it runs with + // `egress_tcp: None` while testing the route that exists to bypass ring 1. + // + // WHAT THIS CELL ACTUALLY WITNESSES ON A 7.2 HOST, measured rather than assumed. + // Dropping ring 2's rule makes the fastopen probe return EACCES (13), not a success: + // mainline 33cb713db016 fixed the Landlock fastopen gap in 7.2, so on this kernel + // ring 1 catches the route by itself and the wall holds WITHOUT ring 2. So the + // EOPNOTSUPP expectation below asserts LAYERING — that ring 2 gets there first — and + // is load-bearing only at the floor (7.1.x, pre-fix), which is exactly where oring + // still supports and no local run exercises. The cell is honest either way: it fails + // if ring 2 stops denying, and the failure line shows 13 instead of 95, which is the + // signal that ring 1 covered for it. + Composition{ + id: "tcp-wall-both-rings", + dependency: "§4: ring 1 TCP port gate depends on the ring-2 MSG_FASTOPEN block", + profile: "balanced", proc_mode: None, connect: Some(&[443]), + probes: &[ + ("net-connect", Arg::S("9"), Expect::Deny(EACCES)), // ring 1 + ("sc-msg-fastopen", Arg::None, Expect::Deny(EOPNOTSUPP)), // ring 2 + ("net-connect", Arg::S("443"), Expect::NotBlocked), // vacuity control + ], + }, ]; fn verdict_ok(e: Expect, code: Option, sig: Option) -> bool { @@ -126,15 +293,17 @@ fn fmt_obs(code: Option, sig: Option) -> String { #[test] fn containment_matrix() { // The matrix pins EXACT per-kernel allow/deny behavior, so it is only valid - // on a CLEAN, controlled kernel. A bare shared CI runner is V7 but dirty - // (nested namespaces, a populated /tmp), which perturbs individual cells. - // Run only in a controlled-kernel context — the CI virtme-ng/KVM job sets + // on a CLEAN, controlled kernel. A bare shared CI runner is below the floor + // (and, when it is not, dirty: nested namespaces, a populated /tmp), which + // perturbs individual cells. + // Run only in a controlled-kernel context. NO CI job sets this: the vng jobs + // that would have were deleted 2026-08-02 having never run. What sets // ORING_REQUIRE_KERNEL, as does a developer who opts in. deny_*/agent_survival // cover the bare host; this matrix is the controlled witness. if std::env::var_os("ORING_REQUIRE_KERNEL").is_none() { eprintln!( "SKIP containment_matrix: needs a controlled kernel \ - (set ORING_REQUIRE_KERNEL=1 on a clean >=6.15 host)" + (set ORING_REQUIRE_KERNEL=1 on a clean >=7.1 host)" ); return; } @@ -144,26 +313,38 @@ fn containment_matrix() { // Shared throwaway fixtures — the whole safety story. let sentinel = Sentinel::spawn(); let sock = UnixSock::bind(); + // A harness-owned directory granted READ_ONLY, in $HOME so it is outside the + // workdir and outside /tmp — both of which carry write grants that Landlock + // would union in, silently turning the read_only cell into a no-op. + let ro_dir = std::path::PathBuf::from(std::env::var("HOME").expect("HOME set")) + .join(format!(".oring-ro-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&ro_dir); + std::fs::create_dir_all(&ro_dir).expect("create read_only fixture dir"); + std::fs::write(ro_dir.join("readable"), "ro\n").expect("seed read_only fixture"); let abstract_sock = AbstractSock::bind(); let wd = tmp_workdir("matrix"); // write_exec workdir // RESOLVE_UNIX (pathname-socket wall) needs the `resolve-unix` feature AND a - // kernel ABI>=9. When either is absent the right is not applied and pathname - // connect is the unmediated residual — skip those cells rather than assert a - // deny that only holds with the wall (the abstract/scope cells are V6, always - // run). + // kernel ABI>=9. Since the V10 floor BOTH are guaranteed here: the feature by + // the compile_error! in src/landlock.rs, the kernel by require_or_skip above + // (which gates on the crate's FLOOR_ABI = 10). So the pathname-unix cells are + // unconditional proof cells now, not build-gated ones that auto-skip — assert + // that rather than branching on it, so a floor regression fails the matrix + // instead of quietly reverting these to skips. let host_abi = landlock_abi(); - let wall_active = cfg!(feature = "resolve-unix") && host_abi >= 9; + assert!( + cfg!(feature = "resolve-unix") && host_abi >= 9, + "post-floor invariant: the RESOLVE_UNIX wall must be active here \ + (feature={}, host_abi=V{host_abi}) — the V10 floor should have refused earlier", + cfg!(feature = "resolve-unix") + ); let mut rows_out: Vec = Vec::new(); let mut fails: Vec = Vec::new(); - let mut skipped_cells: Vec<&str> = Vec::new(); + + let mut unattributable_cells: Vec<(&str, String)> = Vec::new(); for row in ROWS { - if row.op == "unix-connect" && !wall_active { - skipped_cells.push(row.id); - continue; - } // Resolve the payload arg to a concrete, harness-owned target. TmpFile // must be under /tmp specifically — a write_noexec tier in the test // policy (NOT $TMPDIR, which may be /scratch and ungranted). @@ -181,19 +362,73 @@ fn containment_matrix() { Arg::AbstractName => abstract_sock.name.clone(), Arg::WorkFile => work_target.to_string_lossy().into_owned(), Arg::TmpFile => tmp_target.to_string_lossy().into_owned(), + Arg::RoFile => ro_dir + .join(format!("ro-{}", row.id)) + .to_string_lossy() + .into_owned(), + Arg::RoFileExists => ro_dir.join("readable").to_string_lossy().into_owned(), }; // Target-path lint (load-bearing guardrail): any filesystem target MUST // be under the tempdir / workdir / $HOME sentinel socket — never a real // resource — so a mis-authored cell can't damage the host. - if matches!(row.arg, Arg::WorkFile | Arg::TmpFile | Arg::UnixPath) { + if matches!( + row.arg, + Arg::WorkFile | Arg::TmpFile | Arg::UnixPath | Arg::RoFile + ) { let safe = arg.starts_with("/tmp/") || arg.starts_with(wd.to_str().unwrap_or("\0")) + || arg.starts_with(ro_dir.to_str().unwrap_or("\0")) || arg == sock.path.to_string_lossy(); assert!(safe, "unsafe target for cell {}: {arg}", row.id); } - let policy = policy_json_sec(&[wd.to_str().unwrap()], row.connect, row.profile); + // ---- T21: the BARE control, computed rather than hand-checked ---------- + // + // A `Deny` cell only attributes its denial to oring if the SAME operation + // is permitted unsealed. Where the environment supplies the same denial — + // DAC, a host MAC, an inherited filter — the cell is green whatever oring + // does, and `fs-write-readonly` is a measured instance: it writes + // `/etc/oring-matrix`, which is EACCES bare and EACCES sealed, and stays + // green with ring 1's FS layer mutated off. + // + // This ran twice as a hand-check, on 2026-07-24 and 2026-07-28, and a + // manual pass does not survive people adding cells: the T19 in-domain + // pair landed afterwards and was never covered by one. Computing it here + // means a cell added tomorrow is checked tomorrow. + // + // Bare runs FIRST so its side effects are cleaned before the sealed arm + // sees the target — for `fs-write` cells the file would otherwise already + // exist. Only denial cells need it: `Allow` and `NotBlocked` assert + // reachability, which a bare run cannot make more or less credible. + let bare = match row.expect { + Expect::Deny(_) | Expect::Kill(_) => { + let (bc, bs) = run_probe_unsealed(row.op, &arg); + let _ = std::fs::remove_file(&tmp_target); + // The bare arm of an RoFile cell SUCCEEDS by design — that is + // what makes it attributable — so it leaves a real file behind. + if matches!(row.arg, Arg::RoFile) { + let _ = std::fs::remove_file(&arg); + } + // If the bare run satisfies the very expectation the sealed run + // asserts, the two are indistinguishable and the cell proves + // nothing about oring. + Some((verdict_ok(row.expect, bc, bs), fmt_obs(bc, bs))) + } + Expect::Allow | Expect::NotBlocked => None, + }; + if let Some((unattributable, obs)) = &bare { + if *unattributable { + unattributable_cells.push((row.id, obs.clone())); + } + } + + let ro_grant: Vec<&str> = if matches!(row.arg, Arg::RoFile | Arg::RoFileExists) { + vec![ro_dir.to_str().unwrap_or("")] + } else { + vec![] + }; + let policy = policy_json_ro(&[wd.to_str().unwrap()], &ro_grant, row.connect, row.profile); let (code, sig) = run_probe_proc(&policy, row.op, &arg, row.proc_mode); let _ = std::fs::remove_file(&tmp_target); @@ -224,27 +459,166 @@ fn containment_matrix() { } } - let table = rows_out.join("\n"); - if std::env::var_os("ORING_MATRIX_REPORT").is_some() { - eprintln!( - "\n=== oring containment matrix ({} cells) ===\n{table}", - ROWS.len() + // ---- COMPOSITION cells ------------------------------------------------------ + // + // Several probes under ONE policy, every expectation required. The two shape rules + // documented on `Composition` are asserted here rather than trusted, because a + // composition cell that fails them passes while proving nothing. + let mut comps_run = 0usize; + for c in COMPOSITIONS { + assert!( + c.probes.len() >= 2, + "composition {}: fewer than two probes — a single probe is a Row, and filing \ + it here claims a pair was exercised when none was", + c.id ); - } - if !skipped_cells.is_empty() { - // Never silently drop coverage (§ no-silent-caps): a pre-V9 host can't - // exercise the RESOLVE_UNIX wall — say so. - eprintln!( - "oring: NOTE skipped {} pathname-unix cell(s) — host Landlock ABI V{host_abi} < 9 \ - (RESOLVE_UNIX unavailable): {}", - skipped_cells.len(), - skipped_cells.join(", ") + assert!( + c.probes + .iter() + .any(|(_, _, e)| matches!(e, Expect::Allow | Expect::NotBlocked)), + "composition {}: every probe expects a denial, so a seal that refuses \ + EVERYTHING satisfies this cell. Add a reachability control.", + c.id + ); + assert!( + !c.dependency.is_empty(), + "composition {}: must NAME the documented dependency it exercises", + c.id ); + let policy = policy_json_sec(&[wd.to_str().unwrap()], c.connect, c.profile); + for (op, arg_spec, expect) in c.probes { + let arg: String = match arg_spec { + Arg::None => String::new(), + Arg::S(v) => (*v).to_string(), + Arg::SentinelPid => sentinel.pid.to_string(), + Arg::UnixPath => sock.path.to_string_lossy().into_owned(), + Arg::AbstractName => abstract_sock.name.clone(), + Arg::WorkFile => wd + .join(format!("cf-{}", c.id)) + .to_string_lossy() + .into_owned(), + Arg::TmpFile => std::path::PathBuf::from("/tmp") + .join(format!("oring-c-{}-{}", std::process::id(), c.id)) + .to_string_lossy() + .into_owned(), + // Compositions build ONE policy for all their probes + // (`policy_json_sec`, no read_only extra), so an `RoFile` probe + // here would be granted nothing and would fail for the wrong + // reason — a denial from an absent grant rather than from the + // read_only tier under test. Refused loudly rather than + // resolved to a path that would quietly mean something else. + Arg::RoFile | Arg::RoFileExists => panic!( + "composition {}: Arg::RoFile needs a read_only grant that \ + composition policies do not build. Use policy_json_ro here \ + first, or keep the read_only cell in ROWS.", + c.id + ), + }; + let (code, sig) = run_probe_proc(&policy, op, &arg, c.proc_mode); + let pass = verdict_ok(*expect, code, sig); + let exp = match expect { + Expect::Deny(e) => format!("Deny({e})"), + Expect::Kill(sg) => format!("Kill({sg})"), + Expect::Allow => "Allow".into(), + Expect::NotBlocked => "NotBlocked".into(), + }; + let cell = format!("{}/{op}", c.id); + let dep = format!(" [{}]", c.dependency); + rows_out.push(format!( + "{:<24} {:<5} {:<9} {:<5} {:<12} {:<10} {}{}", + cell, + "COMP", + c.profile, + c.proc_mode.unwrap_or("-"), + exp, + fmt_obs(code, sig), + if pass { "PASS" } else { "FAIL" }, + dep + )); + if !pass { + fails.push(format!("{}/{op}", c.id)); + } + comps_run += 1; + } + } + + // ---- T21: attribution verdict, in both directions ------------------------- + // + // Printed unconditionally, because a count with no frame cannot be wrong — + // it can only be published. + let denial_cells = ROWS + .iter() + .filter(|r| matches!(r.expect, Expect::Deny(_) | Expect::Kill(_))) + .count(); + eprintln!( + " attribution: {} denial cell(s) run bare; {} attributed, {} UNATTRIBUTABLE \ + (declared: {})", + denial_cells, + denial_cells - unattributable_cells.len(), + unattributable_cells.len(), + UNATTRIBUTABLE_CELLS.len() + ); + for (id, obs) in &unattributable_cells { + eprintln!(" · {id}: bare {obs} matches the sealed expectation — proves nothing"); + } + + let declared: Vec<&str> = UNATTRIBUTABLE_CELLS.iter().map(|(id, _)| *id).collect(); + let new_debt: Vec<&str> = unattributable_cells + .iter() + .map(|(id, _)| *id) + .filter(|id| !declared.contains(id)) + .collect(); + let found: Vec<&str> = unattributable_cells.iter().map(|(id, _)| *id).collect(); + let stale: Vec<&str> = declared + .iter() + .copied() + .filter(|id| !found.contains(id)) + .collect(); + + assert!( + new_debt.is_empty(), + "{} denial cell(s) are UNATTRIBUTABLE and not declared: {:?}\nThe bare run produced \ + the same observation as the sealed run, so the cell is green whatever oring does. \ + Re-target it at something DAC permits, or add it to UNATTRIBUTABLE_CELLS with the \ + reason — but a cell listed there is not evidence.", + new_debt.len(), + new_debt + ); + assert!( + stale.is_empty(), + "STALE: {:?} are declared UNATTRIBUTABLE but the bare run now distinguishes them. \ + Remove them from UNATTRIBUTABLE_CELLS — a list that does not shrink when the work \ + is done stops measuring anything.", + stale + ); + + let table = rows_out.join("\n"); + + // ALWAYS print the frame, pass or fail. It used to print only under + // ORING_MATRIX_REPORT, so a green run said nothing about WHAT ran — and when two new + // cells landed, the only way to show they had executed was to mutate their + // expectations and watch the failure. A count with no printed frame cannot be wrong; + // it can only be published. + eprintln!( + " matrix: {} per-mechanism cells + {} composition probes ({} composition{}) = {} \ + checks, {} failed", + ROWS.len(), + comps_run, + COMPOSITIONS.len(), + if COMPOSITIONS.len() == 1 { "" } else { "s" }, + rows_out.len(), + fails.len() + ); + if std::env::var_os("ORING_MATRIX_REPORT").is_some() { + eprintln!("\n=== oring containment matrix ===\n{table}"); } + // Denominator is rows_out.len(), NOT ROWS.len(). Those diverged the moment + // composition probes joined the table, and reporting "N of 30" over a 33-line table + // is a correct count over the wrong span. assert!( fails.is_empty(), "containment matrix mismatches ({} of {}):\n{table}", fails.len(), - ROWS.len() + rows_out.len() ); } diff --git a/tests/control_efficacy.rs b/tests/control_efficacy.rs new file mode 100644 index 0000000..4845076 --- /dev/null +++ b/tests/control_efficacy.rs @@ -0,0 +1,357 @@ +// SPDX-License-Identifier: BSD-3-Clause +//! CON-7: run a documented control against the residual it is named for. +//! +//! `CONSTRAINTS.md` CON-7 is `unenforced` — *"no fixture runs a documented +//! control against the residual it is named for"*. The row exists because the +//! workdir-poisoning advice was **retracted twice in two days** (C-10, C-11): +//! first "run the first build inside the cage", which laundered a bounded hazard +//! into a persistent cross-project one; then "review it with `git status`" — +//! which *is* the execution, because `core.fsmonitor` runs on index refresh. A +//! control that fires the hazard it detects is worse than none, because it is +//! performed with confidence. +//! +//! **Establishes**, for two controls: the planted hazard is live under the +//! retracted control (the vacuity arm); the retracted control does not report it; +//! the documented control detects it; and the documented control leaves the +//! payload unfired. For `--explain` the "unfired" half is that it executes +//! nothing, checked against a positive control that runs the same command +//! without the flag. +//! +//! **Does NOT establish** that the workdir recipe catches every shape of +//! poisoning — it catches the seven `.git/config` keys and non-sample hooks. +//! `Makefile`, `build.rs` and `package.json` scripts are named in the same +//! residual and are **not** covered. Nor anything about the other controls. +//! +//! **Both arms live in one test on purpose.** Split, one could rot while the +//! other stayed green, and the failure message would not say which half lied. + +mod common; +use common::*; + +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// The seven `.git/config` keys the documented recipe greps for, verbatim from +/// `docs/RESIDUALS.md`. If the recipe there changes, this must change with it — +/// a fixture that tests a different command than the one documented proves +/// nothing about what an operator will actually run. +const RECIPE_PATTERN: &str = + r"fsmonitor|pager|sshCommand|textconv|hooksPath|askpass|^[[:space:]]*\[alias\]"; + +fn have(bin: &str) -> bool { + Command::new(bin) + .arg("--version") + .output() + .is_ok_and(|o| o.status.success()) +} + +fn git(dir: &Path, home: &Path, args: &[&str]) -> (bool, String) { + let out = Command::new("git") + .current_dir(dir) + // HOME is redirected so the maintainer's global git config cannot + // change what this measures — a global `core.fsmonitor`, a template + // hooks dir, or an alias would all silently alter both arms. + .env("HOME", home) + .env("GIT_CONFIG_GLOBAL", home.join("gitconfig-none")) + .env("GIT_CONFIG_SYSTEM", home.join("gitconfig-none")) + .args(args) + .output() + .unwrap_or_else(|e| panic!("COULD NOT CHECK: git {args:?} did not run ({e})")); + ( + out.status.success(), + format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ), + ) +} + +fn firings(sentinel: &Path) -> usize { + std::fs::read_to_string(sentinel) + .map(|s| s.lines().filter(|l| !l.is_empty()).count()) + .unwrap_or(0) +} + +#[test] +fn the_documented_workdir_review_detects_the_poisoning_without_firing_it() { + if !have("git") { + eprintln!("SKIP: git is not installed, so neither arm can run"); + return; + } + + let root = std::env::temp_dir().join(format!("oring-con7-{}", std::process::id())); + let repo = root.join("repo"); + let home = root.join("home"); + let sentinel = root.join("SENTINEL"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&repo).expect("create repo"); + std::fs::create_dir_all(&home).expect("create home"); + + // A workdir an agent has touched: a real repository with one commit. + git(&repo, &home, &["init", "-q", "."]); + git(&repo, &home, &["config", "user.email", "t@example.invalid"]); + git(&repo, &home, &["config", "user.name", "t"]); + std::fs::write(repo.join("file.txt"), "hi\n").expect("write file"); + git(&repo, &home, &["add", "file.txt"]); + git(&repo, &home, &["commit", "-qm", "init"]); + + // THE POISONING, exactly as C-11 describes: a hook inside `.git/` plus a + // config key that runs it. Both are within what a `write_exec` workdir + // grant permits, which is the whole point — oring cannot deny this without + // refusing to grant the repo root at all. + // + // The sentinel lives OUTSIDE the worktree. Inside it, the file itself shows + // up as untracked and `git status` stops looking clean, which would hide the + // property arm A exists to demonstrate. + let hook = repo.join(".git/fsm.sh"); + std::fs::write( + &hook, + format!("#!/bin/sh\necho fired >> {}\nexit 0\n", sentinel.display()), + ) + .expect("write hook"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)) + .expect("chmod hook"); + } + git( + &repo, + &home, + &["config", "core.fsmonitor", &hook.display().to_string()], + ); + + // ---- ARM A — the RETRACTED control, and the vacuity check ---------------- + let (_, status_out) = git(&repo, &home, &["status", "--porcelain"]); + let (_, diff_out) = git(&repo, &home, &["diff"]); + let fired = firings(&sentinel); + + assert!( + fired > 0, + "VACUOUS: the planted payload did not fire under `git status`/`git diff`, so arm B \ + would report a working control over an experiment that proved nothing. Either this \ + git ignores a path-valued core.fsmonitor, or the hook is not executable, or the \ + sentinel path is not writable. Measured firing on git 2.55.0." + ); + let named_by_git = status_out.contains("fsmonitor") + || status_out.contains("fsm.sh") + || diff_out.contains("fsmonitor") + || diff_out.contains("fsm.sh"); + assert!( + !named_by_git, + "the retracted control REPORTED the poisoning, which contradicts C-11. If git now \ + surfaces it, the residual's account is stale and needs re-measuring before this \ + fixture is trusted.\n status: {status_out:?}\n diff: {diff_out:?}" + ); + println!(" arm A (retracted control): payload fired {fired}x, and git named it: false"); + + // ---- ARM B — the DOCUMENTED control ------------------------------------- + let _ = std::fs::remove_file(&sentinel); + + let grep = Command::new("grep") + .current_dir(&repo) + .args(["-nE", RECIPE_PATTERN, ".git/config"]) + .output() + .expect("grep did not run"); + let detected = String::from_utf8_lossy(&grep.stdout).to_string(); + + let find = Command::new("find") + .current_dir(&repo) + .args([".git/hooks", "-type", "f", "!", "-name", "*.sample"]) + .output() + .expect("find did not run"); + let hooks_listed = String::from_utf8_lossy(&find.stdout).to_string(); + + let fired_after = firings(&sentinel); + + assert!( + detected.contains("fsmonitor"), + "the documented control did NOT detect the poisoning. Recipe: grep -nE {RECIPE_PATTERN:?} \ + .git/config\n got: {detected:?}\nIf the recipe in docs/RESIDUALS.md changed, change \ + RECIPE_PATTERN with it — a fixture testing a different command than the documented \ + one proves nothing about what an operator will run." + ); + assert_eq!( + fired_after, 0, + "THE DOCUMENTED CONTROL FIRED THE PAYLOAD {fired_after}x. This is the C-11 defect \ + recurring in the replacement recipe: a control that fires the hazard it detects is \ + worse than no control. Retract it from docs/RESIDUALS.md before anything else." + ); + println!( + " arm B (documented control): detected {} line(s), hooks listed {}, payload fired {}x", + detected.lines().count(), + hooks_listed.lines().filter(|l| !l.is_empty()).count(), + fired_after + ); + + let _ = std::fs::remove_dir_all(&root); +} + +/// The recipe this fixture runs must be the recipe the document tells operators +/// to run. Without this, the two drift and the fixture silently starts proving +/// something about a command nobody will type. +#[test] +fn the_fixture_runs_the_recipe_the_document_publishes() { + let doc = std::fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("docs/RESIDUALS.md"), + ) + .expect("COULD NOT CHECK: docs/RESIDUALS.md unreadable"); + + for key in [ + "fsmonitor", + "pager", + "sshCommand", + "textconv", + "hooksPath", + "askpass", + "alias", + ] { + assert!( + RECIPE_PATTERN.contains(key), + "RECIPE_PATTERN is missing {key:?}, which the document's recipe names" + ); + assert!( + doc.contains(key), + "docs/RESIDUALS.md no longer names {key:?} in its workdir-review recipe, but \ + RECIPE_PATTERN still greps for it — the fixture and the document have drifted" + ); + } + assert!( + doc.contains("find .git/hooks -type f ! -name '*.sample'"), + "the document's hooks half of the recipe changed; update this fixture with it" + ); +} + +// --------------------------------------------------------------------------- +// CONTROL 2 — `oring --explain`, for the access()/open() divergence residual. +// --------------------------------------------------------------------------- + +/// The divergence residual's control is `oring --explain`, and its whole point is +/// that the *failure* points away from oring: a tool that pre-flights with +/// `access()` gets told the path is readable, opens it, fails, and reports an +/// error naming neither the file nor the sandbox. +/// +/// **Establishes:** +/// +/// * the divergence is **live** under the policy used here — `access()` says +/// readable where `open()` is denied (arm A, the vacuity control); +/// * `--explain` **discriminates** — it names a path the policy granted and does +/// not name the withheld one, so an operator can see which side of the tier +/// boundary their path fell on (arm B); +/// * `--explain` **leaves the payload unfired** — it does not execute the +/// target. That is the CON-7 "unfired" half for this control: the operator is +/// diagnosing a cage, and a diagnostic that runs the command would be the +/// C-11 defect in a new place. +/// +/// **Does NOT establish** that an operator will *think* to run it. The residual +/// says plainly that nothing in the failure suggests `--explain`, and that gap +/// is a documentation problem no fixture closes. +#[test] +fn explain_discriminates_the_withheld_path_and_runs_nothing() { + if !require_or_skip("explain_discriminates_the_withheld_path_and_runs_nothing") { + return; + } + + let dir = tmp_workdir("con7-explain"); + let withheld = dir.join("withheld.conf"); + std::fs::write(&withheld, "secret=1\n").expect("write withheld"); + let granted = dir.join("granted"); + std::fs::create_dir_all(&granted).expect("mkdir granted"); + + // A policy granting `granted/` and NOT the file beside it. `/etc` is granted + // read-only so the child has something real to succeed on. + let policy = policy_json(&[granted.to_str().expect("utf8")], Some(&[])); + + // ---- ARM A — the divergence is live under this exact policy ------------- + let (code, _) = run_probe( + &policy, + "fs-access-vs-open", + withheld.to_str().expect("utf8"), + ); + assert_eq!( + code, + Some(10), + "VACUOUS: expected exit 10 (access says READABLE, open DENIED) for the withheld \ + path, got {code:?}. 0 = both succeeded (the path is granted, so there is no \ + divergence to diagnose and arm B proves nothing); 30 = both failed (ordinary DAC, \ + which is not this residual). Without the divergence there is nothing for --explain \ + to be the control for." + ); + + // ---- ARM B — the documented control ------------------------------------- + // The sentinel lives INSIDE the granted tier so the target can actually + // create it; anywhere else and Landlock would deny the write, making the + // positive control below pass for the wrong reason. + let sentinel = granted.join("EXPLAIN_RAN_THE_TARGET"); + let touch = format!("touch {}", sentinel.display()); + + // POSITIVE CONTROL for the "unfired" half. Without --explain the very same + // command must RUN and create the sentinel. Skipping this would let + // `!sentinel.exists()` pass whenever the target is broken — a missing + // /bin/sh, an unwritable path, a seal that denies the write — and report + // "--explain executed nothing" over a command that could never have run. + let _ = std::fs::remove_file(&sentinel); + let ran = Command::new(oring_bin()) + .env("ORING_POLICY", &policy) + .env("ORING_QUIET", "1") + .args(["--"]) + .arg("/bin/sh") + .arg("-c") + .arg(&touch) + .status() + .expect("spawn oring (positive control)"); + assert!( + ran.success() && sentinel.exists(), + "VACUOUS: without --explain the target did not run (exit {:?}, sentinel {}). The \ + 'runs nothing' assertion below would then pass over a command that could never \ + have executed.", + ran.code(), + sentinel.exists() + ); + + let _ = std::fs::remove_file(&sentinel); + let out = Command::new(oring_bin()) + .env("ORING_POLICY", &policy) + .env("ORING_QUIET", "1") + .args(["--explain", "--"]) + .arg("/bin/sh") + .arg("-c") + .arg(&touch) + .output() + .expect("spawn oring --explain"); + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + + assert!( + out.status.success(), + "--explain did not exit 0: {:?}\n{text}", + out.status.code() + ); + assert!( + text.contains(granted.to_str().expect("utf8")), + "--explain did not name the GRANTED path, so it cannot be used to tell granted from \ + withheld. This is the discrimination half; an --explain that printed nothing useful \ + would pass a mere absence check.\n{text}" + ); + assert!( + !text.contains(withheld.to_str().expect("utf8")), + "--explain named the WITHHELD path as granted, which would send an operator looking \ + in the wrong place.\n{text}" + ); + assert!( + !sentinel.exists(), + "--explain EXECUTED THE TARGET. A diagnostic that runs the command it is diagnosing \ + is the C-11 defect in a new place: the operator reaches for it precisely when they \ + do not yet know what the command will do." + ); + + println!( + " arm A: divergence live (access ok / open EACCES). arm B: --explain named the \ + granted tier, withheld absent, target not executed" + ); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/tests/decoy_home.rs b/tests/decoy_home.rs new file mode 100644 index 0000000..95e64b3 --- /dev/null +++ b/tests/decoy_home.rs @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: BSD-3-Clause +//! T25: a decoyed `$HOME` must not silence the `shared_cache_write=` disclosure. +//! +//! `$HOME` is **ambient** — `docs/RESIDUALS.md` names it beside `ORING_POLICY` +//! and `$TMPDIR` as a source the caller's environment is not trusted to control +//! (CON-6). `Policy::writable_shared_caches` read it and nothing else, so +//! `HOME=/tmp/decoy` made every cache path resolve where no tier could match and +//! the seal line reported `none` over a policy granting write on the real +//! `~/.cargo/registry`. The confinement was never affected; the operator's view +//! of it was, which is `MISREPORT` — worse than an opaque field, because `none` +//! reads as a positive result. +//! +//! **Establishes:** with `HOME` decoyed, and separately unset, the grant is still +//! reported. +//! +//! **The controls are the load-bearing part.** "Still reported" is what passes +//! when the decoy never took effect, so the same run asserts a policy granting no +//! cache reports `none` (the field is not reporting unconditionally) and that an +//! honest `HOME` reports the grant (the policy grants what the test assumes). +//! +//! **Does NOT establish** that every ambient-source disclosure is safe. One +//! field, one variable; `unhandled=` is derived by set difference and is not +//! exposed this way. +mod common; +use common::*; + +use std::process::Command; + +/// Run oring with a chosen `HOME` and return the `shared_cache_write=` field of +/// the seal line. `ORING_QUIET` is deliberately unset — it suppresses the line. +fn shared_cache_field(policy: &str, home: Option<&str>) -> String { + let mut cmd = Command::new(oring_bin()); + cmd.env("ORING_POLICY", policy); + match home { + Some(h) => { + cmd.env("HOME", h); + } + None => { + cmd.env_remove("HOME"); + } + } + let out = cmd + .arg("--") + .arg(oring_bin()) + .arg("__probe") + .arg("fs-read") + .arg("/etc/hostname") + .output() + .expect("spawn oring"); + let stderr = String::from_utf8_lossy(&out.stderr); + stderr + .lines() + .find(|l| l.contains("SEALED")) + .and_then(|l| { + l.split_whitespace() + .find_map(|t| t.strip_prefix("shared_cache_write=")) + }) + .unwrap_or_else(|| panic!("COULD NOT CHECK: no shared_cache_write= field:\n{stderr}")) + .to_string() +} + +#[test] +fn a_decoyed_home_cannot_silence_the_shared_cache_disclosure() { + if !require_or_skip("a_decoyed_home_cannot_silence_the_shared_cache_disclosure") { + return; + } + let Some(real) = std::env::var_os("HOME").map(std::path::PathBuf::from) else { + eprintln!("SKIP: HOME unset in the harness, so there is no real home to decoy away from"); + return; + }; + + let decoy = tmp_workdir("t25-decoy"); + let real_cache = real.join(".cargo").join("registry"); + + // A policy granting write on the REAL home's cargo registry, named + // absolutely — exactly what a hand-written `--policy` would contain. + let granting = policy_json(&[real_cache.to_str().expect("utf8")], Some(&[])); + // A policy granting nothing of the sort, for the negative control. + let clean = policy_json(&[decoy.join("work").to_str().expect("utf8")], Some(&[])); + + // ---- NEGATIVE CONTROL: the field can say `none`, so `-`/`none` is meaningful + let clean_field = shared_cache_field(&clean, Some(real.to_str().expect("utf8"))); + assert_eq!( + clean_field, "none", + "VACUOUS: a policy granting no cache reported {clean_field:?} rather than `none`, so \ + this field reports something regardless and the assertions below prove nothing." + ); + + // ---- POSITIVE CONTROL: with an honest HOME the grant IS reported --------- + let honest = shared_cache_field(&granting, Some(real.to_str().expect("utf8"))); + assert!( + honest.contains(".cargo/registry"), + "VACUOUS: with an honest HOME the granting policy reported {honest:?}, which does not \ + name the cache. The policy is not granting what this test assumes, so decoying HOME \ + below would prove nothing." + ); + + // ---- THE DEFECT: a decoyed HOME must not change the answer --------------- + let decoyed = shared_cache_field(&granting, Some(decoy.to_str().expect("utf8"))); + assert!( + decoyed.contains(".cargo/registry"), + "T25: with HOME decoyed to {} the disclosure reported {decoyed:?} — the write grant on \ + {} is live and unreported. An ambient variable must not be able to silence a \ + disclosure field; the confinement is unchanged either way, but the operator's view \ + of it is not.", + decoy.display(), + real_cache.display() + ); + + // ---- and with HOME removed entirely -------------------------------------- + let unset = shared_cache_field(&granting, None); + assert!( + unset.contains(".cargo/registry"), + "T25: with HOME unset the disclosure reported {unset:?}. Absence of the variable is \ + the cheapest decoy of all." + ); + + println!(" none={clean_field} honest={honest} decoyed={decoyed} unset={unset}"); + let _ = std::fs::remove_dir_all(&decoy); +} diff --git a/tests/denial_attribution.rs b/tests/denial_attribution.rs new file mode 100644 index 0000000..1750adb --- /dev/null +++ b/tests/denial_attribution.rs @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: BSD-3-Clause +//! Assert the MECHANISM, not the placement — §8 / D-4b, ATTACKS row 11. +//! +//! `containment_matrix`'s `unix-connect-*` cells assert `EACCES`, which at least +//! two things produce: `RESOLVE_UNIX` refusing the lookup, or an ordinary path +//! walk failing. So they witness "oring denied it", not "the V9 wall denied it" — +//! leaving the floor's own evidence weaker than the claim resting on it. +//! +//! The kernel names its own mechanism. A Landlock denial is +//! `AUDIT_LANDLOCK_ACCESS` (type 1423, distinct from SELinux's AVC 1400) and +//! carries `blockers=` with the right that refused. Each cell asserts that NAME — +//! `fs.resolve_unix` — not merely the errno. +//! +//! **Establishes:** for this run, the kernel attributed the denial to +//! `fs.resolve_unix`, for both `connect(2)` and addressed `sendmsg(2)` — the +//! second being the half the UAPI documents and oring's `connect`-shaped prose +//! never witnessed. +//! +//! **Does NOT establish** that the wall is complete, or that no other path +//! reaches a pathname socket. Two calls, one kernel. +//! +//! **The `read_only` grant on the socket's directory makes the cell legible, not +//! valid** — and it is easy to write the stronger sentence by mistake. Measured: +//! revoking it does not change the outcome. Landlock has no directory-*traverse* +//! right, so resolving the path needs no FS grant and `RESOLVE_UNIX` is the only +//! right that can refuse this operation. The confound the matrix was suspected of +//! does not exist here; `blockers=` is what makes the cell evidence. +//! +//! **Correlation errs closed.** `--denials` joins records on the kernel-generated +//! domain id, unique per `restrict_self`. On pid reuse `.rev()` takes the newer +//! domain, so a recycled pid makes this **fail**, never falsely pass; a missing +//! record names no right and also fails. +//! +//! **Skip loudly, never green.** These cells need a journal carrying audit +//! records. Absent one the honest outcome is "not witnessed" — so SKIP with the +//! reason on stderr, and hard failure under `ORING_REQUIRE_AUDIT=1`. +#![cfg(target_os = "linux")] +mod common; +use common::*; + +use std::io::Write as _; +use std::os::unix::net::{UnixDatagram, UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Env var carrying the payload's target socket into the sealed child. +/// Passed with `--env-keep`, since oring's default env allowlist would drop it. +const TARGET_VAR: &str = "ORING_FERRULE_TARGET"; +/// Which call the payload should make: `connect` or `sendto`. +const MODE_VAR: &str = "ORING_FERRULE_MODE"; + +// --- the payload ----------------------------------------------------------- + +/// Runs INSIDE the cage, as the sealed child. `#[ignore]` so it never executes +/// as part of an ordinary run — a test that does nothing would be noise, and a +/// payload that fires by accident would be worse. +/// +/// Exits with the raw errno so the parent can read the verdict from the exit +/// code, the same contract `oring __probe` uses. +#[test] +#[ignore = "payload: re-executed as the sealed child, not a test"] +fn payload() { + let target = std::env::var(TARGET_VAR).expect("payload spawned without a target"); + let mode = std::env::var(MODE_VAR).unwrap_or_default(); + let r = match mode.as_str() { + // connect(2) to a pathname AF_UNIX socket. + "connect" => UnixStream::connect(&target).map(|_| ()), + // sendmsg(2) with an explicit recipient address — the half with no cell. + "sendto" => UnixDatagram::unbound().and_then(|d| d.send_to(b"x", &target).map(|_| ())), + other => panic!("unknown payload mode {other:?}"), + }; + let code = match r { + Ok(()) => 0, + Err(e) => e.raw_os_error().unwrap_or(255), + }; + std::process::exit(code); +} + +// --- harness --------------------------------------------------------------- + +/// Is there an audit-carrying journal to read? Fatal under `ORING_REQUIRE_AUDIT`. +fn audit_or_skip(test: &str) -> bool { + // `journalctl -n 0 ` exits 0 even when NOTHING matches — verified: + // `_TRANSPORT=nonexistent_xyz` also returns 0. Success therefore proved + // journalctl exists, not that audit records reach the journal, so this gate + // returned true on any systemd host and the "SKIP by default" contract + // below was a fiction: a journald box with auditd off got a hard failure + // with no env var set. Ask for one record and require it. + let ok = Command::new("journalctl") + .args(["-n", "1", "_TRANSPORT=audit", "-o", "cat"]) + .output() + .is_ok_and(|o| o.status.success() && !o.stdout.is_empty()); + if ok { + return true; + } + assert!( + std::env::var_os("ORING_REQUIRE_AUDIT").is_none(), + "{test}: ORING_REQUIRE_AUDIT=1 but no audit records are readable from \ + journald — the denial attribution cells cannot witness anything. Is \ + auditd running, and does journald carry _TRANSPORT=audit?" + ); + eprintln!( + "SKIP: {test}: no readable audit journal (set ORING_REQUIRE_AUDIT=1 to \ + make this fatal)" + ); + false +} + +struct Cage { + dir: PathBuf, + policy: PathBuf, + sock: PathBuf, +} + +impl Cage { + /// A socket in a directory the policy grants `read_only` — legibility, not + /// isolation; see the module docs for what that grant does and does not buy. + /// + /// The policy file lives OUTSIDE every tier: `check_self_protection` refuses + /// otherwise, which is not a hypothetical — it refused this fixture twice + /// while it was being written. + fn build(tag: &str) -> Self { + let dir = tmp_workdir(&format!("attrib-{tag}")); + let sock = dir.join("s.sock"); + let _ = std::fs::remove_file(&sock); + + // The policy file must not sit under any granted tier, and `dir` is + // granted, so park it beside the test binary instead. + let policy = std::env::current_exe() + .expect("current_exe") + .parent() + .expect("exe parent") + .join(format!("ferrule-attrib-{}-{tag}.json", std::process::id())); + + let exe_dir = std::env::current_exe() + .unwrap() + .parent() + .unwrap() + .to_owned(); + let bin_dir = bindir(); + let json = format!( + r#"{{"version":1, + "landlock":{{ + "read_only":["/usr","/etc","/lib","/lib64","/proc","{d}"], + "read_exec":["/usr","/lib","/lib64","{x}","{b}"], + "write_noexec":[], + "write_exec":[], + "scope_ipc":true}}, + "seccomp":{{"profile":"balanced"}}}}"#, + d = dir.display(), + x = exe_dir.display(), + b = bin_dir, + ); + let mut f = std::fs::File::create(&policy).expect("write policy"); + f.write_all(json.as_bytes()).expect("write policy"); + Self { dir, policy, sock } + } + + /// Seal a child running `payload` in the given mode; return (exit code, pid). + fn run(&self, mode: &str) -> (i32, u32) { + let exe = std::env::current_exe().expect("current_exe"); + let out = Command::new(oring_bin()) + .args(["--policy", self.policy.to_str().unwrap()]) + .args(["--env-keep", TARGET_VAR, "--env-keep", MODE_VAR]) + .arg("--") + .arg(&exe) + .args(["--exact", "payload", "--ignored", "--nocapture"]) + .env(TARGET_VAR, &self.sock) + .env(MODE_VAR, mode) + .output() + .expect("spawn oring"); + + let stderr = String::from_utf8_lossy(&out.stderr); + let pid = stderr + .split_whitespace() + .find_map(|t| t.strip_prefix("pid=")) + .and_then(|p| p.trim().parse::().ok()) + .unwrap_or_else(|| panic!("no seal line in oring stderr:\n{stderr}")); + (out.status.code().unwrap_or(-1), pid) + } + + /// `oring --denials `, retried: journald ingests asynchronously. + fn denials(pid: u32) -> String { + for _ in 0..40 { + let out = Command::new(oring_bin()) + .args(["--denials", &pid.to_string()]) + .output() + .expect("spawn oring --denials"); + let s = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + if s.contains("blockers") || s.contains("fs.") { + return s; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + String::new() + } +} + +impl Drop for Cage { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.policy); + let _ = std::fs::remove_file(&self.sock); + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +fn listener(path: &Path) -> UnixListener { + UnixListener::bind(path).expect("bind pathname socket") +} + +// --- the cells ------------------------------------------------------------- + +/// `connect(2)`: denied, and the KERNEL says the denying right was +/// `fs.resolve_unix` — not an ungranted path, whose directory this policy +/// deliberately grants. +#[test] +fn unix_connect_denial_names_resolve_unix() { + if !require_or_skip("unix_connect_denial_names_resolve_unix") { + return; + } + if !audit_or_skip("unix_connect_denial_names_resolve_unix") { + return; + } + let cage = Cage::build("connect"); + let _l = listener(&cage.sock); + + let (code, pid) = cage.run("connect"); + assert_eq!( + code, EACCES, + "connect to a pathname socket was not EACCES (got {code})" + ); + + let report = Cage::denials(pid); + assert!( + report.contains("fs.resolve_unix"), + "denial was not attributed to RESOLVE_UNIX. The socket's directory IS \ + granted read_only, so a path-walk denial (fs.read_dir/fs.read_file) \ + would mean the cell is measuring the wrong thing. Report:\n{report}" + ); + assert!( + report.contains(cage.sock.to_str().unwrap()), + "record names a right but not this socket — wrong denial correlated:\n{report}" + ); +} + +/// `sendmsg(2)` with an explicit recipient address — ATTACKS row 11's missing +/// half. The UAPI says the right covers it; nothing had witnessed it. +#[test] +fn addressed_sendmsg_denial_names_resolve_unix() { + if !require_or_skip("addressed_sendmsg_denial_names_resolve_unix") { + return; + } + if !audit_or_skip("addressed_sendmsg_denial_names_resolve_unix") { + return; + } + let cage = Cage::build("sendto"); + let d = UnixDatagram::bind(&cage.sock).expect("bind datagram socket"); + let _ = d; + + let (code, pid) = cage.run("sendto"); + assert_eq!( + code, EACCES, + "addressed sendmsg to a pathname socket was not EACCES (got {code}) — \ + if this is 0, the wall is connect-only and DESIGN §4 overstates it" + ); + + let report = Cage::denials(pid); + assert!( + report.contains("fs.resolve_unix"), + "sendmsg denial not attributed to RESOLVE_UNIX:\n{report}" + ); + // Ties the record to THIS run's socket. Without it the cell's only + // assertion is a substring that any concurrent oring run would satisfy, so + // a broken pid→domain join inside `--denials` would leave this cell green + // while the connect cell (which has always had this assertion) went red. + // Demonstrated: inverting the join at `denials.rs:179` killed connect and + // left this one passing. The module docs lean on `--denials` erring closed; + // that is a property of another file, and a fixture must not assume it. + assert!( + report.contains(cage.sock.to_str().unwrap()), + "record names a right but not this run's socket — wrong denial correlated:\n{report}" + ); +} diff --git a/tests/deny_net.rs b/tests/deny_net.rs index 1301459..e3d5d31 100644 --- a/tests/deny_net.rs +++ b/tests/deny_net.rs @@ -1,5 +1,5 @@ // SPDX-License-Identifier: BSD-3-Clause -//! Ring 1 TCP-connect enforcement (§11c). Net is V4, guaranteed at the V7/6.15 floor. +//! Ring 1 TCP-connect enforcement (§11c). Net is V4, guaranteed well below the V10/7.2 floor. #![cfg(target_os = "linux")] mod common; use common::*; diff --git a/tests/deny_strict.rs b/tests/deny_strict.rs index 692f47a..1860614 100644 --- a/tests/deny_strict.rs +++ b/tests/deny_strict.rs @@ -1,5 +1,5 @@ // SPDX-License-Identifier: BSD-3-Clause -//! strict profile: true-W^X — `mprotect(PROT_EXEC)` blocked (§13 v2). +//! strict profile: true-W^X — `mprotect(PROT_EXEC)` blocked. #![cfg(target_os = "linux")] mod common; use common::*; diff --git a/tests/deny_syscall.rs b/tests/deny_syscall.rs index 69869ca..48adcd5 100644 --- a/tests/deny_syscall.rs +++ b/tests/deny_syscall.rs @@ -99,9 +99,15 @@ fn prctl_guard_not_bypassable_via_upper_arg_bits() { /// `sendto(..., MSG_FASTOPEN, &sa)` establishes a full bidirectional session. /// /// Landlock fixed this in mainline `33cb713db016` (merged 2026-07-01, ships in -/// 7.2, Cc: stable) — but oring's floor is V7/6.15 and every supported kernel -/// through 7.1.x still has the gap, so ring 2 closes it. The floor raise did not -/// retire this guard: 7.1.x sits AT the floor and is still pre-fix. +/// 7.2, Cc: stable), and the floor is now 7.2 — so the reason this cell used to +/// give ("every supported kernel through 7.1.x still has the gap") has expired. +/// +/// **The cell stays, on a different footing.** No declared erratum covers that +/// fix (`sys::landlock_errata` — the tree declares three, none of them this), so a +/// vendor kernel reporting ABI V10 can lack the behaviour and be indistinguishable +/// from a fixed one. Ring 2 is therefore what makes `strict`'s "no TCP egress" +/// hold regardless of kernel assembly, and this cell is what keeps ring 2 honest. +/// See the longer note at `MSG_FASTOPEN` in `src/seccomp.rs`. #[test] fn msg_fastopen_is_blocked() { if !require_or_skip("msg_fastopen_is_blocked") { @@ -198,3 +204,37 @@ fn mount_and_quota_variants_are_blocked() { assert_eq!(code, Some(EPERM), "{op} must be EPERM"); } } + +/// T14: `setns` is denied, and the denial is attributable to oring. +/// +/// The other namespace-family entries cannot be tested this way and +/// `docs/RESIDUALS.md` says why: unprivileged, the seven `CLONE_NEW*` flags +/// return `EPERM` **bare**, so a sealed `EPERM` proves nothing, and +/// `mount`/`pivot_root` are `EPERM` at every level. `setns` is the exception — +/// bare it fails `EBADF` on the invalid fd, before any permission check, so the +/// sealed `EPERM` can only have come from ring 2. +#[test] +fn setns_is_denied_and_the_denial_is_attributable() { + if !require_or_skip("setns_is_denied_and_the_denial_is_attributable") { + return; + } + const EBADF: i32 = 9; + + let (bare, _) = run_probe_unsealed("sc-setns", ""); + assert_eq!( + bare, + Some(EBADF), + "VACUOUS: expected EBADF from `setns(-1, 0)` without a seal, got {bare:?}. If the \ + bare call already returns EPERM, the sealed result below is unattributable and this \ + cell must not claim otherwise." + ); + + let policy = policy_json(&[], Some(&[])); + let (sealed, _) = run_probe(&policy, "sc-setns", ""); + assert_eq!( + sealed, + Some(EPERM), + "expected EPERM from a sealed `setns`, got {sealed:?}. One variable changed from the \ + bare run: the seal." + ); +} diff --git a/tests/deny_x32.rs b/tests/deny_x32.rs index 5754c63..bf54803 100644 --- a/tests/deny_x32.rs +++ b/tests/deny_x32.rs @@ -2,6 +2,17 @@ //! x32-ABI KILL guard (§5/§11c). The guard must SIGSYS-kill ANY x32-numbered //! syscall before the kernel dispatches it — regardless of whether the kernel //! even supports x32. +//! +//! **Does NOT establish: that x32 is closed.** That claim is universally +//! quantified over the numbering space and belongs to the Kani harness +//! `x32_guard_total` (`src/seccomp.rs`); the dense-table cell +//! `x32_guard_matches_its_spec_over_a_dense_table` beside it covers the reachable +//! range without a kernel. **These cells witness the guard at specific numbers on +//! one kernel, and nothing more** — a mutation sweep on 2026-07-28 measured a +//! mutant that leaked x32 `read`/`write`/`open`/`mmap`/`mprotect`/`ioctl` while +//! every process cell here stayed green. This file must never be cited as if it +//! were the proof. Its value is being the only thing in the tree that runs a real +//! syscall against a real kernel: the model↔kernel anchor. #![cfg(all(target_os = "linux", target_arch = "x86_64"))] mod common; use common::*; @@ -21,3 +32,94 @@ fn x32_call_is_killed() { a clean exit means the guard was BYPASSED" ); } + +/// The guard must kill x32 at the BOTTOM of the numbering, not only at the one +/// number every cell probed until 2026-07-28. `BUILD-QUEUE.md` T1. +/// +/// **Why `0` and not something dangerous-sounding.** The first version of this +/// cell used `ptrace`, on the reasoning that it is a number-keyed deny the guard +/// exists to keep unreachable. That reasoning was wrong twice over. `x32|101` is +/// not the x32 `ptrace` number at all — `syscall_64.tbl` puts `ptrace` in the +/// `64` ABI column, so on a kernel with x32 enabled `x32|101` returns `ENOSYS` +/// exactly as it does here; the real x32 `ptrace` is 521. And as a MUTATION PROBE +/// it was close to the worst available choice. Measured over 79 leaky guard +/// mutants: +/// +/// | probe | leaky mutants caught | +/// |---|---| +/// | `x32\|0` (this cell) | **79 / 79** | +/// | `x32\|1` | 77 | +/// | `x32\|101` (the original) | **1 / 79** | +/// +/// The dominant leak family is monotone — a `JGT`/`JGE` threshold, which is how +/// anyone would naturally write "nr >= the x32 base" — so it always leaks the +/// BOTTOM of the numbering and always spares the high numbers a cell is tempted +/// to probe. `x32|0` strictly dominates. +/// +/// **This cell is an anchor, not coverage.** Probing individual numbers cannot +/// close the class: `x32_guard_matches_its_spec_over_a_dense_table` in +/// `src/seccomp.rs` does that against the model, and `x32_guard_total` proves it. +/// What only this cell can do is tie the model to a real kernel. +/// +/// **Attribution.** Ring 2 emits `SECCOMP_RET_KILL_PROCESS` from exactly two +/// places, both inside `x32_guard_program()`; every errno sub-filter is built +/// with `Allow`/`Errno` and cannot kill. So a `SIGSYS` under seal is the +/// x32/arch guard on any kernel configuration — a structural fact, not a +/// measurement. Corroborating measurement on this host (7.2.0-rc4, +/// `CONFIG_X86_X32_ABI` not set — see `/lib/modules/$(uname -r)/build/.config`, +/// as `/proc/config.gz` and `/boot/config-*` are both absent here): the probe run +/// BARE exits `rc=0` and is **not signalled**. The kernel *does* reject the x32 +/// numbering — with `ENOSYS` — but it does not SIGNAL, and the probe discards the +/// return value. `x32_bare_is_not_signalled` below pins that precondition so it +/// cannot rot silently. +#[test] +fn x32_call_is_killed_at_the_bottom_of_the_numbering() { + if !require_or_skip("x32_call_is_killed_at_the_bottom_of_the_numbering") { + return; + } + let wd = tmp_workdir("x32b"); + let policy = policy_json(&[wd.to_str().unwrap()], None); + let (code, sig) = run_probe(&policy, "x32-call", "0"); + assert_eq!( + sig, + Some(SIGSYS), + "x32-numbered syscall 0 must be SIGSYS-killed by the guard (code={code:?}). \ + The guard matches the x32 BIT, not a range and not a single number — a \ + clean exit here with `x32_call_is_killed` still green is a THRESHOLD \ + mutant: the guard kills the high numbers both cells name and leaks the \ + entire bottom of the numbering, measured to include read, write, open, \ + mmap, mprotect and ioctl." + ); +} + +/// The precondition every cell in this file rests on: **unsealed, the x32 probe +/// is not signalled.** +/// +/// Without this, the whole file can pass for the wrong reason. Seccomp filters +/// are inherited across `fork` and `execve`, so a `cargo test` started under an +/// ambient filter that kills x32 — exactly what `systemd`'s +/// `SystemCallArchitectures=native` installs, and several container runtime +/// profiles — sees `SIGSYS` whether or not oring's guard works. Measured +/// 2026-07-28 under such a filter: every cell here stayed green **and the T1 +/// mutant survived**, because the outer filter killed the probe on oring's +/// behalf. +/// +/// The attribution argument was previously a sentence in a comment that nothing +/// re-checked. This makes it a cell that fails loudly instead, which is the same +/// argument T1 made for probing a second number. +#[test] +fn x32_bare_is_not_signalled() { + if !require_or_skip("x32_bare_is_not_signalled") { + return; + } + let (code, sig) = run_probe_unsealed("x32-call", "0"); + assert_eq!( + sig, None, + "the x32 probe was SIGNALLED while running UNSEALED (code={code:?}, \ + sig={sig:?}). Something outside oring is killing x32 calls — most likely \ + an inherited seccomp filter such as systemd's \ + `SystemCallArchitectures=native`. Every other cell in this file is \ + UNATTRIBUTABLE in this environment: they would pass on the ambient \ + filter's authority even with oring's guard removed entirely." + ); +} diff --git a/tests/design_residuals.rs b/tests/design_residuals.rs new file mode 100644 index 0000000..0b3163b --- /dev/null +++ b/tests/design_residuals.rs @@ -0,0 +1,574 @@ +// SPDX-License-Identifier: BSD-3-Clause +//! Every `docs/RESIDUALS.md` residual names a loss and a disclosure status. +//! That file is `DESIGN.md` §2; it moved out on 2026-08-01 and kept its number. +//! +//! §2 was measured holding two items with **no adversary in them** — the +//! `access()`/`open()` divergence, filed as "not a containment hole", and the +//! workdir-poisoning advice, retracted twice in two days — and each was +//! mis-ranked until a measurement forced it. **An adversary-scoped section cannot +//! see a loss that occurs with nobody attacking.** The fix is not prose: every +//! residual states which loss it belongs to and whether an instrument reports it +//! at run time. The `disclosure` half is the one this project keeps paying for — +//! a residual an operator can look up differs from one they cannot discover from +//! a run at all, and `silent` says so out loud. +//! +//! **Does NOT establish** whether a tag is *true* — whether the named loss is the +//! right loss, or whether an instrument reports what the tag claims. That is +//! adversarial review's and stays with people. This checks the field is present, +//! the loss resolves against `CONSTRAINTS.md`'s derivation, and the disclosure +//! value is in the closed set. +//! +//! # The boundary error this gate committed in itself +//! +//! The python original required a **following** heading and scanned only the +//! **first** match. When §2 gained a second subsection it reported "10 of 10 +//! tagged" while **seven residuals went unexamined** — a clean result over an +//! incomplete frame, which is the defect this gate exists to catch. So sections +//! terminate on the next heading **or end-of-file**, and the count is printed. +//! +//! The derivation parser is a second, independent implementation of the one in +//! `tests/constraint_register.rs`. If they ever disagree about what +//! `CONSTRAINTS.md` declares, believe neither until you know why. +//! +//! **Failure paths, all confirmed red:** an untagged live residual; a loss absent +//! from the derivation; a disclosure outside the closed set; a section with no +//! `**Claim.**`; a residual planted last in the file; strikethrough outside a +//! claim; all sections removed; the derivation emptied (a vacuous derivation +//! would make every loss name resolve, so it is could-not-check, not pass). + +use std::collections::BTreeMap; +use std::path::PathBuf; + +const DISCLOSURE: [&str; 3] = ["stated", "documented-only", "silent"]; +const LEVELS: [&str; 3] = ["Losses", "Hazards", "Feedback defects"]; + +fn root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn read(rel: &str) -> String { + let p = root().join(rel); + std::fs::read_to_string(&p).unwrap_or_else(|e| { + panic!( + "COULD NOT CHECK: {} is unreadable ({e}). A missing input must not read as a \ + clean section.", + p.display() + ) + }) +} + +/// Name and body of EVERY `## Residual — …` section. The body runs to the next `## ` +/// heading, the next `# ` heading, or a `---` rule — never by requiring a following +/// heading to exist. +/// +/// **The shape changed on 2026-08-01 and the gate had to change with it.** §2 moved to +/// `docs/RESIDUALS.md` and each residual became its own section with named fields, where +/// it used to be a `- ` bullet under one of two `### Residual — …` subsections. That is +/// not a cosmetic difference here: the old parser collected bullets, and against the new +/// document it would have found **zero of them under every heading** and reported a clean +/// run over an unexamined file. The failure would have looked exactly like a pass, which +/// is why `the_residual_sections_and_the_derivation_are_both_readable` asserts the parser +/// found something before anything else runs. +fn residual_sections(text: &str) -> Vec<(String, String)> { + let lines: Vec<&str> = text.lines().collect(); + let mut out = Vec::new(); + for (i, line) in lines.iter().enumerate() { + let Some(name) = line.strip_prefix("## Residual — ") else { + continue; + }; + let mut body = String::new(); + for l in &lines[i + 1..] { + if l.starts_with("## ") || l.starts_with("# ") || l.trim_end() == "---" { + break; + } + body.push_str(l); + body.push('\n'); + } + out.push((name.trim().to_string(), body)); + } + out +} + +/// A CLOSED residual is one whose `**Claim.**` line is struck through, retained as a +/// dated record and not required to carry a tag. +/// +/// Anchored on the claim line specifically, not on `~~` anywhere in the body: a live +/// residual is free to mention a struck-through neighbour, and a body-wide test would +/// silently excuse it from needing a tag. +fn claim_is_struck(body: &str) -> bool { + body.lines() + .find(|l| l.trim_start().starts_with("**Claim.**")) + .is_some_and(|l| l.contains("~~")) +} + +/// CON-7's `observable-via`, as a ratchet: residuals that name a control and +/// cite no fixture for it. +/// +/// **This list may only shrink.** The gate below fails in BOTH directions — a +/// residual that names a control and is absent from this list is new debt, and +/// an entry here that has since gained a fixture is a stale exemption. Neither +/// can sit unnoticed, and the length of this array is the size of the gap. +/// +/// **What this does NOT establish, and it is the important half.** Citing a +/// fixture is not the same as demonstrating a control. Measured 2026-08-01: of +/// the 6 residuals naming a control, 2 cited a `tests/` path and **both +/// citations were of the residual, not of the control** — +/// `the_under_coverage_disclosure_is_discardable_by_the_caller` pins the +/// *limitation*, which is the opposite of showing a mitigation works. So a +/// citation check alone scores 2 of 6 and both passes are false. That is the +/// `unhandled=` weakness CON-5 already records: a field that repeats the +/// request cannot disagree with it. CON-7 stays `unenforced` until the +/// *efficacy* fixtures exist; this gate only stops the gap growing. +/// +/// **Two of these are not demonstrable in this tree at all** and will end as +/// stated exemptions rather than fixtures: a host firewall is a companion +/// control outside oring, and runtime PID-ns + `maskedPaths` needs a container +/// runtime. When the demonstrable ones are done, that decision is `would-change` +/// on the CON-7 row and belongs in `CONSTRAINTS.md`, not here. +const CONTROLS_WITHOUT_AN_EFFICACY_FIXTURE: [&str; 1] = [ + // NOT demonstrable in this tree: the control is a host firewall or filtering + // proxy, which is a companion control outside oring by construction. This + // one ends as a stated exemption rather than a fixture, and saying so is + // `would-change` on the CON-7 row. + "outbound TCP to any IP on an allowed port", +]; + +/// Does this residual body cite anything that looks like a fixture? +/// +/// Deliberately generous — a `tests/` path, a `foo::tests::bar` module path, or +/// a bare `fn`-shaped test name. A generous reader here makes the ratchet +/// *harder* to satisfy by accident and the exemption list *shorter* than the +/// real gap, which is the safe direction for a number this project will quote. +fn cites_a_fixture(body: &str) -> bool { + body.contains("tests/") || body.contains("::tests::") || body.contains("tests::") +} + +fn parse_derivation(text: &str) -> BTreeMap { + let lines: Vec<&str> = text.lines().collect(); + let mut out = BTreeMap::new(); + for level in LEVELS { + let head = format!("### {level}"); + let Some(start) = lines.iter().position(|l| l.starts_with(&head)) else { + continue; + }; + for line in &lines[start + 1..] { + if line.starts_with("### ") || line.starts_with("---") { + break; + } + if let Some(rest) = line.strip_prefix("- `") { + if let Some((id, _)) = rest.split_once('`') { + out.insert(id.to_string(), level.to_string()); + } + } + } + } + out +} + +struct Tag { + losses: Vec, + disclosure: String, +} + +/// `[loss: … · disclosure: …]`. The separator is U+00B7, as written in §2. +fn parse_tag(bullet: &str) -> Option { + let start = bullet.find("[loss:")?; + let rest = &bullet[start + "[loss:".len()..]; + let end = rest.find(']')?; + let inner = &rest[..end]; + // The separator may be followed by a LINE BREAK. This is not hypothetical + // tolerance: exactly one §2 tag is wrapped as `[loss: BREACH ·\n disclosure: …]`, + // and the first version of this port looked for the literal `"· disclosure:"` and + // reported that residual UNTAGGED. The python original spanned it (`\s*` under + // `re.DOTALL`) and was right. Two independent implementations disagreed by exactly + // one residual, and the disagreement is what found it — the same way this project's + // python/Rust librarian pair once disagreed by one site. + let (loss_field, disc_part) = inner.split_once('·')?; + let disc_field = disc_part.trim_start().strip_prefix("disclosure:")?; + + // Uppercase mnemonics: `[A-Z][A-Z-]{2,}`. `NONE` is a deliberate, valid answer — + // "this is the grant, not a residual" — and is not required to resolve. + let losses = loss_field + .split(|c: char| !(c.is_ascii_uppercase() || c == '-')) + .filter(|t| t.len() >= 3 && t.starts_with(|c: char| c.is_ascii_uppercase())) + .filter(|t| *t != "NONE") + .map(str::to_string) + .collect(); + + // Case-insensitive on purpose: an author will reasonably write **SILENT** for + // emphasis on the worst case, and a gate that fails on TYPOGRAPHY rather than + // substance is noise — that mistake was made twice already, with over-strict + // heading and tag anchors, and caught both times by the gate firing on its own + // author's edit. + let disclosure = disc_field + .trim() + .trim_start_matches('*') + .trim() + .chars() + .take_while(|c| c.is_ascii_alphabetic() || *c == '-') + .collect::() + .to_lowercase(); + + Some(Tag { losses, disclosure }) +} + +/// The could-not-check half, kept as its own test so the FAILING TEST NAME says which +/// kind of failure it was. +#[test] +fn the_residual_sections_and_the_derivation_are_both_readable() { + let design = read("docs/RESIDUALS.md"); + let sections = residual_sections(&design); + assert!( + !sections.is_empty(), + "COULD NOT CHECK: no '## Residual — …' section found in docs/RESIDUALS.md. The \ + parser found nothing, which is not the same as finding nothing wrong." + ); + let deriv = parse_derivation(&read("CONSTRAINTS.md")); + assert!( + !deriv.is_empty(), + "COULD NOT CHECK: CONSTRAINTS.md yielded no derivation entries. Without them \ + every loss name would resolve VACUOUSLY and this gate would pass by knowing \ + nothing." + ); + println!(" residual subsections scanned: {}", sections.len()); + println!(" derivation entries available: {}", deriv.len()); +} + +/// Counted results of the residual check, over TEXT rather than paths. +/// +/// Text in, findings out — which is what lets the red-tests below run in-process against +/// a mutated `String` instead of editing `docs/DESIGN.md` on disk and re-running the +/// suite from another repository. The gate and its mutation tests are the same file, so +/// an anchor that stops matching fails here rather than passing quietly elsewhere. +struct Residuals { + violations: Vec, + sections: usize, + live: usize, + tagged: usize, + closed: usize, + silent: Vec, +} + +fn audit_residuals(design: &str, register: &str) -> Residuals { + let deriv = parse_derivation(register); + let sections = residual_sections(design); + assert!( + !sections.is_empty(), + "COULD NOT CHECK: no residual subsections" + ); + assert!(!deriv.is_empty(), "COULD NOT CHECK: empty derivation"); + + let mut violations: Vec = Vec::new(); + let mut live = 0usize; + let mut closed = 0usize; + let mut tagged = 0usize; + let mut silent: Vec = Vec::new(); + + for (name, body) in §ions { + let first: String = name.chars().take(64).collect(); + + // Every residual states a claim. Checked before the strike-through test, because + // `claim_is_struck` answers "false" for a section that has no claim line at all, + // and a malformed section would otherwise be counted as live-and-untagged with a + // confusing message instead of the real one. + if !body + .lines() + .any(|l| l.trim_start().starts_with("**Claim.**")) + { + violations.push(format!( + "residual has no **Claim.** line — {first:?}. Every section must state \ + what is claimed before the gate can say anything about it." + )); + continue; + } + + if claim_is_struck(body) { + closed += 1; + continue; + } + live += 1; + let Some(tag) = parse_tag(body) else { + violations.push(format!( + "residual carries no [loss: … · disclosure: …] tag — {first:?}" + )); + continue; + }; + tagged += 1; + let unknown: Vec<&String> = tag + .losses + .iter() + .filter(|l| !deriv.contains_key(*l)) + .collect(); + if !unknown.is_empty() { + violations.push(format!( + "residual names loss(es) {unknown:?} absent from CONSTRAINTS.md's \ + derivation — {first:?}" + )); + } + if !DISCLOSURE.contains(&tag.disclosure.as_str()) { + violations.push(format!( + "disclosure {:?} not in {DISCLOSURE:?} — {first:?}", + tag.disclosure + )); + } else if tag.disclosure == "silent" { + silent.push(first); + } + } + + Residuals { + violations, + sections: sections.len(), + live, + tagged, + closed, + silent, + } +} + +#[test] +fn every_live_residual_names_a_loss_and_a_disclosure_status() { + let r = audit_residuals(&read("docs/RESIDUALS.md"), &read("CONSTRAINTS.md")); + + // Print what this DID, not what it intended — including the FRAME, because a total + // is checkable only against a span you have also printed. + println!(" residual subsections scanned: {}", r.sections); + println!( + " live residuals: {} tagged: {} closed/struck (tag not required): {}", + r.live, r.tagged, r.closed + ); + println!(" residuals tagged `silent`: {}", r.silent.len()); + for s in &r.silent { + println!(" · SILENT — an operator cannot discover this from a run: {s:?}"); + } + + assert!( + r.violations.is_empty(), + "design §2 residuals: {} violation(s)\n {}", + r.violations.len(), + r.violations.join("\n ") + ); +} + +/// CON-7 ratchet: every residual naming a control either cites a fixture or is +/// a declared exemption, and no exemption is stale. +#[test] +fn every_named_control_cites_a_fixture_or_is_a_declared_exemption() { + let doc = read("docs/RESIDUALS.md"); + let sections = residual_sections(&doc); + assert!( + !sections.is_empty(), + "COULD NOT CHECK: no residual sections parsed" + ); + + let mut named = 0usize; + let mut cited = 0usize; + let mut new_debt: Vec = Vec::new(); + let mut still_missing: Vec<&str> = Vec::new(); + + for (name, body) in §ions { + if !body.contains("**Control.**") { + continue; + } + named += 1; + let exempt = CONTROLS_WITHOUT_AN_EFFICACY_FIXTURE.contains(&name.as_str()); + if cites_a_fixture(body) { + cited += 1; + assert!( + !exempt, + "STALE EXEMPTION: {name:?} now cites a fixture but is still listed in \ + CONTROLS_WITHOUT_AN_EFFICACY_FIXTURE. Remove it — an exemption list that \ + does not shrink when the work is done stops measuring anything." + ); + } else { + still_missing.push( + CONTROLS_WITHOUT_AN_EFFICACY_FIXTURE + .iter() + .find(|e| **e == name.as_str()) + .copied() + .unwrap_or(""), + ); + if !exempt { + new_debt.push(name.clone()); + } + } + } + + // Print the frame, not just the verdict. + println!(" residuals naming a control: {named}"); + println!(" of those, citing a fixture: {cited}"); + println!( + " declared exemptions: {}", + CONTROLS_WITHOUT_AN_EFFICACY_FIXTURE.len() + ); + println!(" NOTE: citing a fixture is not demonstrating a control — CON-7 stays `unenforced`"); + + assert!( + new_debt.is_empty(), + "{} residual(s) name a control and cite no fixture, and are not declared \ + exemptions:\n {}\nEither add the fixture that runs the control against the \ + residual, or add the name to CONTROLS_WITHOUT_AN_EFFICACY_FIXTURE with the reason \ + it cannot be demonstrated in this tree.", + new_debt.len(), + new_debt.join("\n ") + ); + + // A shrinking list is the point; assert it never silently grows past what is declared. + assert_eq!( + still_missing.len(), + CONTROLS_WITHOUT_AN_EFFICACY_FIXTURE.len(), + "the exemption list and the measured gap disagree — {} uncited, {} declared. An \ + entry naming a residual that no longer exists is as stale as one that gained a \ + fixture.", + still_missing.len(), + CONTROLS_WITHOUT_AN_EFFICACY_FIXTURE.len() + ); +} + +// --------------------------------------------------------------------------- +// RED-TESTS — in-process, over mutated text. See `audit_residuals` for why text. +#[cfg(test)] +mod red { + use super::*; + + fn mutate(text: &str, from: &str, to: &str) -> String { + assert!( + text.contains(from), + "STALE ANCHOR: {from:?} is not in the document — this case tested nothing" + ); + text.replacen(from, to, 1) + } + + fn expect(design: &str, register: &str, needle: &str) { + let v = audit_residuals(design, register).violations; + assert!( + v.iter().any(|m| m.contains(needle)), + "expected a violation containing {needle:?}; got {v:?}" + ); + } + + #[test] + fn the_unmutated_section_is_clean() { + let r = audit_residuals(&read("docs/RESIDUALS.md"), &read("CONSTRAINTS.md")); + assert!(r.violations.is_empty(), "baseline: {:?}", r.violations); + // A floor, not the exact count: the number moves when a residual is added, and a + // gate that fails on that is noise. What it forbids is the parser silently + // matching one or two sections after a heading-shape change. + assert!( + r.sections >= 15, + "expected the full residual set, got {} section(s) — the parser is matching a \ + fraction of the document", + r.sections + ); + assert!( + r.live > 0 && r.tagged == r.live, + "every live residual must be tagged" + ); + } + + /// The defect that motivated the gate: a residual with no tag at all. + #[test] + fn an_untagged_live_residual_is_caught() { + let design = read("docs/RESIDUALS.md"); + let i = design + .find("[loss:") + .expect("STALE ANCHOR: no tagged residual"); + let end = design[i..].find(']').map_or(design.len(), |n| i + n + 1); + let m = format!("{}{}", &design[..i], &design[end..]); + expect(&m, &read("CONSTRAINTS.md"), "carries no [loss:"); + } + + #[test] + fn a_loss_absent_from_the_derivation_is_caught() { + let design = read("docs/RESIDUALS.md"); + let m = mutate(&design, "[loss: BREACH", "[loss: NOTALOSS"); + expect(&m, &read("CONSTRAINTS.md"), "absent from CONSTRAINTS.md"); + } + + #[test] + fn a_disclosure_outside_the_closed_set_is_caught() { + let design = read("docs/RESIDUALS.md"); + let m = mutate( + &design, + "· disclosure: stated", + "· disclosure: mostly-stated", + ); + expect(&m, &read("CONSTRAINTS.md"), "not in"); + } + + /// The boundary error this gate committed in itself: it once scanned only the FIRST + /// residual subsection and reported "10 of 10 tagged" while seven residuals in the + /// second went unexamined. A residual planted in the LAST section of the file must + /// still be seen — the end of the document is where a truncating parser stops. + #[test] + fn a_residual_late_in_the_file_is_really_scanned() { + let design = read("docs/RESIDUALS.md"); + let at = design + .rfind("## Residual — ") + .expect("STALE ANCHOR: no residual sections at all"); + let m = format!( + "{}## Residual — a planted residual, last in the file\n\n**Claim.** Planted.\n\n\ + **[loss: NOTALOSS · disclosure: stated]**\n\n{}", + &design[..at], + &design[at..] + ); + expect(&m, &read("CONSTRAINTS.md"), "absent from CONSTRAINTS.md"); + } + + /// A section with fields but no `**Claim.**` must fail rather than be skipped. + /// Without this, a malformed residual is invisible: `claim_is_struck` answers false, + /// the tag check may still pass, and nothing says the section was never really read. + #[test] + fn a_residual_with_no_claim_line_is_caught() { + let design = read("docs/RESIDUALS.md"); + let at = design + .rfind("## Residual — ") + .expect("STALE ANCHOR: no residual sections at all"); + let m = format!( + "{}## Residual — a section with no claim\n\n**Mechanism.** Nothing states what \ + is claimed.\n\n**[loss: BREACH · disclosure: stated]**\n\n{}", + &design[..at], + &design[at..] + ); + expect(&m, &read("CONSTRAINTS.md"), "no **Claim.** line"); + } + + /// A live residual must not be excused from carrying a tag just because it mentions a + /// struck-through neighbour. This is why `claim_is_struck` anchors on the claim line. + #[test] + fn strikethrough_elsewhere_in_a_body_does_not_excuse_the_tag() { + let design = read("docs/RESIDUALS.md"); + let i = design + .find("[loss:") + .expect("STALE ANCHOR: no tagged residual"); + let end = design[i..].find(']').map_or(design.len(), |n| i + n + 1); + // Remove the tag AND plant a strikethrough in the body — but not on the claim. + let m = format!( + "{}\n**Mechanism.** See the ~~closed~~ case above.\n{}", + &design[..i], + &design[end..] + ); + expect(&m, &read("CONSTRAINTS.md"), "carries no [loss:"); + } + + /// A vacuous derivation would make every loss name resolve, so it must be a + /// could-not-check rather than a pass. + #[test] + #[should_panic(expected = "COULD NOT CHECK")] + fn an_emptied_derivation_is_a_could_not_check() { + let register = read("CONSTRAINTS.md"); + let m: String = register + .lines() + .filter(|l| !l.starts_with("- `")) + .collect::>() + .join("\n"); + assert_ne!(m, register, "STALE ANCHOR: no derivation entries to remove"); + let _ = audit_residuals(&read("docs/RESIDUALS.md"), &m); + } + + #[test] + #[should_panic(expected = "COULD NOT CHECK")] + fn removing_every_residual_subsection_is_a_could_not_check() { + let design = read("docs/RESIDUALS.md").replace("## Residual — ", "## NotAResidual — "); + let _ = audit_residuals(&design, &read("CONSTRAINTS.md")); + } +} diff --git a/tests/diag_injection.rs b/tests/diag_injection.rs new file mode 100644 index 0000000..54c3c1d --- /dev/null +++ b/tests/diag_injection.rs @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: BSD-3-Clause +//! T26: caller-supplied bytes must not reach the operator's terminal raw. +//! +//! `src/denials.rs` escaped them and **13 of ~14 other print sites did not**. A +//! forged `SEALED` line was injectable **through argv alone**, under +//! `--profile balanced`, with no policy file. `docs/RESIDUALS.md` already +//! documents the seal line as forgeable by the **child**, which shares stderr; +//! this was forgeable by the **caller**, before the child exists. +//! +//! **Establishes:** no raw `ESC` or `CR` from an attacker-chosen argument reaches +//! stderr, across the refusal path (argv) and the warning path (`$TMPDIR`), with +//! a guard that the payload appears *escaped* rather than merely absent — so a +//! cell cannot pass over output that never contained the argument. +//! +//! **Does NOT establish** that the output is unspoofable: a path that is long, +//! renders right-to-left, or is a homoglyph is still misleading. Nor does it +//! constrain the child after the seal. +mod common; +use common::*; + +use std::process::Command; + +/// The payload: erase the current line, return to column 0, print a plausible +/// seal line. On a terminal that honours it, the real diagnostic disappears. +const FORGERY: &str = "\u{1b}[2K\rooring: SEALED v9.9.9+deadbee grants=99/99 nnp=1"; + +fn stderr_of(args: &[&str], env: &[(&str, &str)]) -> String { + let mut cmd = Command::new(oring_bin()); + for (k, v) in env { + cmd.env(k, v); + } + let out = cmd.args(args).output().expect("spawn oring"); + String::from_utf8_lossy(&out.stderr).into_owned() +} + +fn assert_no_raw_control(what: &str, s: &str) { + for (name, ch) in [("ESC", '\u{1b}'), ("CR", '\r')] { + assert!( + !s.contains(ch), + "{what}: a raw {name} reached stderr, so an attacker-chosen argument can repaint \ + the operator's terminal — erase a REFUSE, forge a SEALED, hide a granted tier. \ + Output: {s:?}" + ); + } + // The forgery must be VISIBLE as text rather than absent: swallowing it + // silently would also pass the checks above while hiding that oring was + // asked to print something hostile. + assert!( + s.contains("\\x1b") || s.contains("SEALED v9.9.9"), + "{what}: the payload neither appeared escaped nor as text — this cell may be \ + asserting over output that never contained the argument at all. Output: {s:?}" + ); +} + +#[test] +fn a_forged_seal_line_in_argv_cannot_repaint_the_terminal() { + // The refusal path: an unresolvable target is echoed back in the message. + let target = format!("/nonexistent/{FORGERY}"); + let s = stderr_of( + &["--profile", "balanced", "--", &target], + &[("ORING_QUIET", "1")], + ); + assert!( + s.contains("REFUSE") || s.contains("not found") || s.contains("122"), + "expected a refusal for an unresolvable target; got {s:?}" + ); + assert_no_raw_control("refusal path (target from argv)", &s); +} + +#[test] +fn a_forged_path_in_an_ambient_variable_cannot_repaint_the_terminal() { + // The warning path, and the cheapest injection surface there is: TMPDIR is + // a plain environment variable, echoed back by the INFO line that reports + // oring is ignoring it. + if !require_or_skip("a_forged_path_in_an_ambient_variable_cannot_repaint_the_terminal") { + return; + } + let dir = tmp_workdir("t26-tmpdir"); + let policy = policy_json(&[dir.to_str().expect("utf8")], Some(&[])); + let s = stderr_of( + &[ + "--", + &oring_bin().to_string_lossy(), + "__probe", + "fs-read", + "/etc/hostname", + ], + &[ + ("ORING_POLICY", policy.as_str()), + ("TMPDIR", &format!("/tmp/{FORGERY}")), + ], + ); + assert!( + s.contains("ignoring inherited TMPDIR"), + "expected the TMPDIR INFO line; got {s:?}" + ); + assert_no_raw_control("warning path (TMPDIR from the environment)", &s); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/tests/fd_scrub.rs b/tests/fd_scrub.rs new file mode 100644 index 0000000..be0a07e --- /dev/null +++ b/tests/fd_scrub.rs @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: BSD-3-Clause +//! The FD scrub: an inherited descriptor at fd 3+ is gone, and fds 0–2 are not. +//! (`BUILD-QUEUE.md` T10.) +//! +//! `docs/RESIDUALS.md` lists *"inherited leaked FDs at fd 3 and above"* among the +//! guarantees, citing CVE-2024-21626, and records via correction C-5 that it was +//! published **unqualified** until measurement showed fds 0–2 are deliberately +//! preserved. Both halves had no test at either end. +//! +//! **Establishes**, with the seal as the only variable: a planted fd is readable +//! bare and `EBADF` sealed; fd 0 and fd 2 stay readable sealed. The cells sit at +//! the **boundary** — fd 2 highest preserved, fd 3 lowest scrubbed — because +//! `close_range_cloexec(3)` off by one moves exactly one of them and cells at 0 +//! and 7 stay green through it. +//! +//! **Does NOT establish** that every leakable descriptor is closed (one fd, four +//! numbers), nor that `close_range` is the mechanism — a `close()` loop would +//! look identical from the child. +//! +//! **The vacuity trap is sharper here than elsewhere.** "fd 7 is gone" is what a +//! harness that never planted it reports. The bare arm is the whole defence, and +//! it is why `plant_open_fd` uses `dup2`, which clears `FD_CLOEXEC` so the +//! descriptor survives `execve` the way a caller's leak does. +mod common; +use common::*; + +use std::process::Command; + +const EBADF: i32 = 9; + +/// Run the `fd-read` probe with a descriptor planted at `target_fd`, optionally +/// under a seal. Returns the probe's exit code: 0 = the fd was readable. +fn probe_fd(target_fd: i32, planted: &std::path::Path, policy: Option<&str>) -> Option { + let mut cmd = Command::new(oring_bin()); + if let Some(p) = policy { + cmd.env("ORING_POLICY", p).env("ORING_QUIET", "1"); + cmd.arg("--").arg(oring_bin()); + } + cmd.arg("__probe").arg("fd-read").arg(target_fd.to_string()); + plant_open_fd(&mut cmd, target_fd, planted); + cmd.output().expect("spawn probe").status.code() +} + +#[test] +fn an_inherited_fd_above_two_is_scrubbed_and_stdio_is_not() { + if !require_or_skip("an_inherited_fd_above_two_is_scrubbed_and_stdio_is_not") { + return; + } + + let dir = tmp_workdir("t10-fd-scrub"); + let planted = dir.join("planted.txt"); + std::fs::write(&planted, "content\n").expect("write planted file"); + + // ---- THE VACUITY CONTROL, first because everything rests on it ---------- + let bare7 = probe_fd(7, &planted, None); + assert_eq!( + bare7, + Some(0), + "VACUOUS: fd 7 was not readable even WITHOUT a seal (got {bare7:?}; 9 = EBADF). \ + The descriptor was never planted, never inherited, or kept FD_CLOEXEC — so the \ + sealed assertion below would report EBADF over a leak that never existed. Fix the \ + harness before believing any result here." + ); + + let policy = policy_json(&[], Some(&[])); + + // ---- fd 3 and above: scrubbed ------------------------------------------- + let sealed7 = probe_fd(7, &planted, Some(&policy)); + assert_eq!( + sealed7, + Some(EBADF), + "expected EBADF on the sealed child's fd 7, got {sealed7:?}. One variable changed \ + from the bare run above: the seal." + ); + + // ---- THE BOUNDARY, which is where the bug would actually live ----------- + // + // `src/preexec.rs` calls `close_range_cloexec(3)`. fd 2 is the highest + // PRESERVED descriptor and fd 3 the lowest SCRUBBED one, so an off-by-one — + // `close_range_cloexec(4)`, or a `2` — moves exactly one of these and + // nothing else. Cells at 0 and 7 confirm the interior and would both stay + // green through either mistake, which is the "correct count over the wrong + // span" trap in the test written to pin a *bounding* guarantee. + for (fd, label) in [(2, "highest preserved"), (3, "lowest scrubbed")] { + let bare = probe_fd(fd, &planted, None); + assert_eq!( + bare, + Some(0), + "VACUOUS: fd {fd} ({label}) was not readable bare (got {bare:?}), so the sealed \ + result for it says nothing." + ); + } + + let sealed2 = probe_fd(2, &planted, Some(&policy)); + assert_eq!( + sealed2, + Some(0), + "fd 2 was NOT readable under the seal (got {sealed2:?}). fd 2 is the HIGHEST \ + preserved descriptor: if the scrub has moved to `close_range_cloexec(2)` it is now \ + taking stderr, which breaks every child that reports anything. fds 0-2 are \ + deliberately excluded — correction C-5 records that this hole was published as a \ + closed guarantee until it was measured — so this is a compatibility break, not a \ + hardening win." + ); + + let sealed3 = probe_fd(3, &planted, Some(&policy)); + assert_eq!( + sealed3, + Some(EBADF), + "fd 3 SURVIVED the seal (got {sealed3:?}). fd 3 is the LOWEST scrubbed descriptor, \ + so this is the off-by-one signature: `close_range_cloexec(4)` instead of `(3)` \ + leaves exactly this descriptor open and leaves every cell at fd 7 green." + ); + + // ---- fd 0: the far end of the preserved range --------------------------- + let bare0 = probe_fd(0, &planted, None); + assert_eq!( + bare0, + Some(0), + "VACUOUS: fd 0 was not readable bare (got {bare0:?}), so the sealed result below \ + says nothing about the seal." + ); + let sealed0 = probe_fd(0, &planted, Some(&policy)); + assert_eq!( + sealed0, + Some(0), + "fd 0 was NOT readable under the seal (got {sealed0:?}). fds 0-2 are deliberately \ + excluded from the scrub — a child without stdio is broken." + ); + + println!( + " preserved: fd0 readable, fd2 readable (boundary). scrubbed: fd3 EBADF \ + (boundary), fd7 EBADF. all four readable bare." + ); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/tests/gate_coverage.rs b/tests/gate_coverage.rs new file mode 100644 index 0000000..d31f3e0 --- /dev/null +++ b/tests/gate_coverage.rs @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: BSD-3-Clause +//! Gate coverage: make a *requested but absent* gate fail loudly. +//! +//! `ORING_REQUIRE_SMT` has a structural blind spot. The assertion it fires lives +//! *inside* `tests/seccomp_smt.rs`, which is `#![cfg(feature = "bpf-dump")]` — +//! without the feature that file compiles to nothing, so the variable has no code +//! left to fire from. Cargo reports `ok. 0 passed`, and a caller who set it +//! precisely to make the gate mandatory is told the run was clean. Measured: +//! `--features resolve-unix` collects 0 tests there; adding `bpf-dump` collects 4. +//! +//! This file is compiled UNCONDITIONALLY, which is the whole point — it is the +//! one place that can still speak when the feature-gated file cannot. +//! +//! **Establishes:** `ORING_REQUIRE_SMT` set ⟹ `feature = "bpf-dump"` compiled in. +//! The predicate is `!required || compiled` over two booleans — four cases, which +//! makes it **total**, checkable by inspection rather than sampling. All four were +//! run: (1,0) fails, the rest pass. +//! +//! **Does NOT establish** that the SMT gate runs, or that the truncation property +//! holds — the harness proving it is exactly what is absent in the case this +//! fires on. It converts a silent zero into a loud failure, nothing more. +//! +//! **Scope:** `make check` has run a separate SMT step since 2026-07-26, so the +//! blind spot is `make test` and bare `cargo test`, not the everyday gate. +//! +//! **Known weakness, stated rather than hidden.** A test that catches a missing +//! gate is strictly weaker than not having a gate that can go missing. The +//! structural fix is to stop gating analysis-only code behind a feature every +//! invocation must remember to pass. This is a guard rail on a road that should +//! have been straightened — a workaround with a stated expiry, not a design. + +/// `ORING_REQUIRE_SMT=1` must not report success when the harness it refers to +/// was never compiled in. +/// +/// Fires only when the caller asked for the gate *and* the feature is absent — +/// so it is inert in every existing invocation: `make check` (neither set), CI's +/// dedicated step (both set), and a bare `cargo test` (neither). +#[test] +fn require_smt_without_the_feature_is_not_silently_satisfied() { + let required = std::env::var_os("ORING_REQUIRE_SMT").is_some(); + let compiled = cfg!(feature = "bpf-dump"); + + assert!( + !required || compiled, + "ORING_REQUIRE_SMT is set but tests/seccomp_smt.rs was not compiled: it \ + is #![cfg(feature = \"bpf-dump\")] and this run passed only the default \ + feature set, so the truncation-invariance gate collected ZERO tests and \ + still reported ok. Re-run with `--features bpf-dump,resolve-unix`. \ + (The in-file ORING_REQUIRE_SMT check guards a MISSING SOLVER; it cannot \ + guard a missing harness, because it is inside the harness.)" + ); +} + +// --------------------------------------------------------------------------- +// Every `ORING_REQUIRE_*` variable a test READS must be SET by some make target. +// +// The check above is total over one variable and cannot generalise: it works +// because `seccomp_smt.rs` is feature-gated, so "the harness is absent" is a +// `cfg!` this file can evaluate. The other three variables are not feature-gated, +// so the analogous defect is different in kind and needs a different instrument. +// +// THE DEFECT (`BUILD-QUEUE.md` T7): `ORING_REQUIRE_AUDIT` and `ORING_REQUIRE_UDP` +// appear ONLY inside the test files that read them. Nothing set them, so a green +// gate did not mean those suites ran — and because `require_*_or_skip` returns +// EARLY, each skipped cell counted as PASSED. Measured on this tree: with the UDP +// requirement raised above this host's ABI, `udp_wall` reports `ok. 9 passed; 0 +// failed` having executed ONE of nine cells. A clean-looking nine. +// +// This test enforces the property that was missing: a variable a test consults is +// a promise to an operator, and a promise nothing can fulfil is worse than no +// promise. A variable may be deliberately kept out of the everyday gate — that is +// a real decision, because `ORING_REQUIRE_UDP` needs Landlock V10 and would make +// `make check` FAIL on 7.1 — which WAS the minimum kernel oring shipped for; the +// floor is 7.2 since 2026-07-29, so that cost is gone. So the excused set +// is explicit, carries its reason, and must itself be satisfiable by `make +// check-full`. +// +// AND AN UNUSED EXCUSE IS A FAILURE, not a spare. A stale allowlist is how this +// kind of guard rots into a rubber stamp — the same clause the corpus's COLLAB +// repair declarations carry, for the same reason. +// +// Instrument, stated: variables are found by scanning `tests/` and `src/` for the +// token, and classified against the `Makefile` by whether the occurrence sits +// inside a `REQUIRE_EXTRA=` assignment (strict-only) or elsewhere (everyday gate). + +/// Variables deliberately NOT set by `make check`, each with the reason it cannot +/// be. Anything here must be set by `make check-full` instead. +const EXCUSED_FROM_EVERYDAY_GATE: &[(&str, &str)] = &[ + ( + "ORING_REQUIRE_UDP", + "STALE EXCUSE, KEPT ONLY BECAUSE PROMOTING IT IS A GATE-POLICY DECISION THE \ + MAKEFILE ESCALATES RATHER THAN MAKES. It read: needs Landlock V10 (kernel \ + 7.2); setting it in `check` would fail the gate on 7.1, the minimum kernel \ + oring supports and ships for. FLOOR_ABI is 10 as of 2026-07-29, so 7.1 is no \ + longer supported and `check` already fails there via ORING_REQUIRE_KERNEL=1 \ + at the floor — this excuse can therefore cost nothing and buy nothing. \ + Promoting it into the everyday gate would close the documented \ + clean-looking-nine (udp_wall reporting 9 passed having run 0 cells)", + ), + ( + "ORING_REQUIRE_NET", + "needs a working DNS resolver, which is host configuration and not kernel \ + version. This project's own measurement box runs its corpus lane inside a \ + netns with NO ROUTE ANYWHERE, where resolving nothing is the correct state, \ + and any offline builder or container is the same — so `check` would fail on \ + an absence that says nothing about oring", + ), + ( + "ORING_REQUIRE_AUDIT", + "needs a readable _TRANSPORT=audit journal, which is host configuration \ + rather than kernel version, so it would fail in any container without \ + journald+auditd", + ), +]; + +fn read_tree_file(rel: &str) -> String { + let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(rel); + std::fs::read_to_string(&p) + .unwrap_or_else(|e| panic!("COULD NOT CHECK: {} unreadable ({e})", p.display())) +} + +/// Every `ORING_REQUIRE_` token in a blob of source. +fn require_vars(text: &str) -> std::collections::BTreeSet { + const PREFIX: &str = "ORING_REQUIRE_"; + let mut out = std::collections::BTreeSet::new(); + let b = text.as_bytes(); + let mut i = 0; + while let Some(hit) = text[i..].find(PREFIX) { + let start = i + hit; + let mut end = start + PREFIX.len(); + while end < b.len() && (b[end].is_ascii_uppercase() || b[end] == b'_') { + end += 1; + } + if end > start + PREFIX.len() { + out.insert(text[start..end].to_string()); + } + i = end.max(start + 1); + } + out +} + +#[test] +fn every_require_variable_a_test_reads_is_set_by_some_make_target() { + // READ BY: every variable any test or source file consults. + let mut read = std::collections::BTreeSet::new(); + for dir in ["tests", "src"] { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(dir); + let mut stack = vec![root]; + while let Some(d) = stack.pop() { + let Ok(rd) = std::fs::read_dir(&d) else { + continue; + }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + stack.push(p); + } else if p.extension().is_some_and(|x| x == "rs") { + // This file NAMES the variables to reason about them; exclude + // it, or the guard would treat its own prose as a consumer. + if p.file_name().is_some_and(|n| n == "gate_coverage.rs") { + continue; + } + if let Ok(s) = std::fs::read_to_string(&p) { + read.extend(require_vars(&s)); + } + } + } + } + } + assert!( + !read.is_empty(), + "COULD NOT CHECK: no ORING_REQUIRE_* variable found in tests/ or src/. The \ + scan found nothing, which is not the same as finding nothing wrong." + ); + + // SET BY: classified against the Makefile. + let mk = read_tree_file("Makefile"); + let strict_only: std::collections::BTreeSet = mk + .lines() + .filter(|l| l.contains("REQUIRE_EXTRA='") || l.contains("REQUIRE_EXTRA=\"")) + .flat_map(require_vars) + .collect(); + let set_anywhere = require_vars(&mk); + let excused: std::collections::BTreeSet = EXCUSED_FROM_EVERYDAY_GATE + .iter() + .map(|(v, _)| (*v).to_string()) + .collect(); + + println!(" read by tests/src: {read:?}"); + println!(" set somewhere in the Makefile: {set_anywhere:?}"); + println!(" set only under REQUIRE_EXTRA (strict runs): {strict_only:?}"); + + let mut problems: Vec = Vec::new(); + + for v in &read { + if !set_anywhere.contains(v) { + problems.push(format!( + "{v} is read by a test but SET BY NOTHING in the Makefile. An operator \ + who sets it gets no guarantee, and the cells it governs skip while \ + counting as PASSED. Set it in `check`, or add it to \ + EXCUSED_FROM_EVERYDAY_GATE with the reason it cannot be." + )); + } else if strict_only.contains(v) && !excused.contains(v) { + problems.push(format!( + "{v} is set only by the strict run and is not listed in \ + EXCUSED_FROM_EVERYDAY_GATE. Either the everyday gate should set it, \ + or the reason it must not needs writing down." + )); + } + } + for (v, _) in EXCUSED_FROM_EVERYDAY_GATE { + if !read.contains(*v) { + problems.push(format!( + "{v} is excused but no test reads it — an unused excuse is how an \ + allowlist rots into a rubber stamp. Remove it." + )); + } + if !strict_only.contains(*v) { + problems.push(format!( + "{v} is excused from `check` on the promise that `check-full` covers \ + it, and `check-full` does NOT set it. The excuse is unbacked." + )); + } + } + + assert!( + problems.is_empty(), + "ORING_REQUIRE_* coverage: {} problem(s)\n {}", + problems.len(), + problems.join("\n ") + ); +} diff --git a/tests/nnp.rs b/tests/nnp.rs new file mode 100644 index 0000000..c0317ec --- /dev/null +++ b/tests/nnp.rs @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: BSD-3-Clause +//! `NO_NEW_PRIVS` — measured on the sealed child, and checked against the seal +//! line's literal. (`BUILD-QUEUE.md` T15.) +//! +//! NNP is one of the guarantees `docs/RESIDUALS.md` lists under *what oring +//! stops*, and until now **no test exercised it**. Worse, the seal line's `nnp=1` +//! is a **literal** — the one field not derived from run state — which +//! `RESIDUALS.md` records as a `silent` residual: falsifiable by a refactor and +//! never by a run. This file is what lets a run falsify it. +//! +//! **Establishes:** bare, `NoNewPrivs 0 / Seccomp 0` (the vacuity control — on a +//! host that already sets NNP the sealed result is unattributable); sealed, +//! `1 / 2`, with the seal as the only variable; and the seal line's `nnp=` +//! agrees with what the kernel reports for that same run. +//! +//! **Does NOT establish** that privilege escalation is prevented. That needs a +//! setuid binary and a payload; what is checked is that the bit the guarantee +//! rests on is set and that oring's report of it is not lying. +//! +//! **Cannot establish** that `preexec::set_no_new_privs()` is load-bearing, and +//! that is a property of the system rather than a gap here: `seccompiler` +//! sets `PR_SET_NO_NEW_PRIVS` itself before installing the filter +//! (`seccompiler-0.5.0/src/lib.rs:344`), and no `SeccompProfile` installs no +//! filter — so every path sets NNP twice and no observation of the child can +//! tell the setters apart. Do not read a green run as licence to delete that +//! call. It also means `RESIDUALS.md`'s "double invariant" has three legs, one +//! of them unreachable. +mod common; +use common::*; + +/// `/proc/self/status` encoding from the `nnp-status` probe: `NoNewPrivs * 10 + +/// Seccomp`. `execve` preserves both, so this is the sealed child's own state. +const BARE: i32 = 0; // NNP 0, seccomp 0 +const SEALED: i32 = 12; // NNP 1, seccomp 2 (filter) + +#[test] +fn no_new_privs_and_seccomp_are_set_on_the_sealed_child_and_not_bare() { + if !require_or_skip("no_new_privs_and_seccomp_are_set_on_the_sealed_child_and_not_bare") { + return; + } + + let (bare, _) = run_probe_unsealed("nnp-status", ""); + assert_eq!( + bare, + Some(BARE), + "VACUOUS: expected NoNewPrivs 0 / Seccomp 0 before the seal, got {bare:?} \ + (encoding: NoNewPrivs*10 + Seccomp; 200+ means /proc/self/status was unreadable \ + or unparseable). If this host already sets NO_NEW_PRIVS — a sandboxed CI runner, \ + a container with the bit inherited — then the sealed result below is not \ + attributable to oring and this suite must not claim it is." + ); + + let policy = policy_json(&[], Some(&[])); + let (sealed, _) = run_probe(&policy, "nnp-status", ""); + assert_eq!( + sealed, + Some(SEALED), + "expected NoNewPrivs 1 / Seccomp 2 on the sealed child, got {sealed:?}. One \ + variable changed between this and the bare run above: the seal." + ); +} + +/// The seal line says `nnp=1` without measuring it. This checks the literal +/// against the kernel, for the same run. +#[test] +fn the_seal_lines_nnp_literal_agrees_with_the_kernel() { + if !require_or_skip("the_seal_lines_nnp_literal_agrees_with_the_kernel") { + return; + } + + let policy = policy_json(&[], Some(&[])); + + // The measurement: what the kernel says about the sealed child. + let (measured, _) = run_probe(&policy, "nnp-status", ""); + let measured_nnp = measured + .map(|c| c / 10) + .unwrap_or_else(|| panic!("COULD NOT CHECK: probe did not exit normally")); + + // The claim: what the seal line prints. ORING_QUIET is deliberately NOT set + // here — it is what suppresses the line this test reads. + let out = std::process::Command::new(oring_bin()) + .env("ORING_POLICY", &policy) + .arg("--") + .arg(oring_bin()) + .arg("__probe") + .arg("nnp-status") + .output() + .expect("spawn oring"); + let stderr = String::from_utf8_lossy(&out.stderr); + let line = stderr + .lines() + .find(|l| l.contains("SEALED")) + .unwrap_or_else(|| panic!("COULD NOT CHECK: no SEALED line on stderr; got:\n{stderr}")); + let claimed = line + .split_whitespace() + .find_map(|t| t.strip_prefix("nnp=")) + .unwrap_or_else(|| panic!("COULD NOT CHECK: no nnp= field in the seal line:\n{line}")); + + assert_eq!( + claimed, + measured_nnp.to_string(), + "the seal line claims nnp={claimed} while the kernel reports NoNewPrivs \ + {measured_nnp} for the same configuration. The field is a literal, so it cannot \ + disagree with the request — only with reality, which is what this catches." + ); + println!(" seal line nnp={claimed}, kernel NoNewPrivs={measured_nnp} — agree"); +} diff --git a/tests/policy_props.rs b/tests/policy_props.rs index 1b4a865..89841fb 100644 --- a/tests/policy_props.rs +++ b/tests/policy_props.rs @@ -117,6 +117,7 @@ fn mk(landlock: Landlock) -> Policy { }, user_authored: false, require_all_grants: false, + require_full_coverage: false, } } @@ -153,6 +154,8 @@ fn any_landlock() -> impl Strategy { write_exec, egress_tcp: None, ingress_tcp: None, + egress_udp: None, + ingress_udp: None, scope_ipc: true, }, ) @@ -285,6 +288,8 @@ fn any_landlock_empty() -> Landlock { write_exec: Vec::new(), egress_tcp: None, ingress_tcp: None, + egress_udp: None, + ingress_udp: None, scope_ipc: true, } } diff --git a/tests/protocol_wall.rs b/tests/protocol_wall.rs new file mode 100644 index 0000000..ef1b741 --- /dev/null +++ b/tests/protocol_wall.rs @@ -0,0 +1,425 @@ +// SPDX-License-Identifier: BSD-3-Clause +//! Ring 2 closes the protocols ring 1 cannot mediate (§3, §5). +//! +//! # Specification +//! +//! **Establishes:** under a sealed child, `socket(2)` in `AF_INET`/`AF_INET6` +//! succeeds for the protocols ring 1 mediates (default, TCP, UDP) and fails for +//! ones it does not (MPTCP, SCTP, ICMP), while `AF_UNIX` is untouched. +//! +//! **Does NOT establish:** that egress is closed. Ring 1 still decides TCP and +//! UDP by port, and this file asserts nothing about that. +//! +//! # Why this file exists rather than a matrix row +//! +//! A measured bypass: `--profile strict` documents "deny-all TCP egress", and +//! `IPPROTO_TCP` duly got `EACCES` — while `socket(AF_INET, SOCK_STREAM, +//! IPPROTO_MPTCP)` connected to a plain-TCP listener and delivered payload. +//! Landlock was behaving as specified (`sk_is_tcp()` is false for MPTCP, and +//! erratum 1 narrowed the TCP rights to real TCP on purpose); the gap was that +//! oring's `socket()` rule keyed on **family** alone. +//! +//! **The coverage lesson is the reason this is a separate file.** oring's +//! fixtures parameterise on the axis the *policy* names — ports — while the +//! kernel dispatches on a different axis: family, type, protocol. The MPTCP +//! case was not a missing row in `containment_matrix`; it was **inexpressible** +//! there, because the `net-connect` probe takes a port and nothing else. A row +//! list cannot cover an axis the probe signature cannot reach, so this file +//! carries its own payload and parameterises on the triple. +//! +//! **Failure direction:** closed. Every cell asserts a specific outcome for a +//! specific triple; a payload that fails to run at all fails the test. +#![cfg(target_os = "linux")] +// The package-wide `unsafe_code = deny` is re-allowed here for the same reason +// `tests/common/mod.rs` does it: the payload must name a (family, type, +// protocol) triple, and std's socket types cannot express `protocol` at all — +// which is precisely the axis the bypass lived on. One `libc::socket` call. +#![allow(unsafe_code)] +mod common; +use common::*; + +use std::process::Command; + +/// Triple for the payload, passed through `--env-keep` (oring's default env +/// allowlist drops unknown vars). +const TRIPLE: &str = "ORING_PW_TRIPLE"; + +/// Runs INSIDE the cage. `#[ignore]` so it never executes as an ordinary test. +/// Exits 0 if `socket(2)` succeeded, else the raw errno. +#[test] +#[ignore = "payload: re-executed as the sealed child, not a test"] +fn payload() { + let spec = std::env::var(TRIPLE).expect("payload spawned without a triple"); + let n: Vec = spec + .split(',') + .map(|p| p.parse().expect("triple")) + .collect(); + // Optional 4th field selects the syscall: 0 = socket (default), 1 = + // socketpair. socketpair needs its own cell because `__sys_socketpair` + // calls `sock_create()` — including module autoload — BEFORE testing + // whether the family implements ->socketpair, so it reaches the protocol + // dispatch for a call that can never return a usable inet fd. + let socketpair = n.get(3).copied().unwrap_or(0) == 1; + // SAFETY: plain scalar syscalls; fds are intentionally leaked, the process + // exits immediately after. + let fd = if socketpair { + let mut sv = [0i32; 2]; + unsafe { libc::socketpair(n[0], n[1], n[2], sv.as_mut_ptr()) } + } else { + unsafe { libc::socket(n[0], n[1], n[2]) } + }; + if fd < 0 { + std::process::exit( + std::io::Error::last_os_error() + .raw_os_error() + .unwrap_or(255), + ); + } + std::process::exit(0); +} + +/// Seal a child under `profile` and return the payload's exit code, for a raw +/// spec string (`family,type,proto[,op]`). +fn run_payload_sealed(profile: &str, spec: &str) -> i32 { + let wd = tmp_workdir("protowall"); + let exe = std::env::current_exe().expect("current_exe"); + let policy = policy_json_sec(&[wd.to_str().unwrap()], Some(&[]), profile); + let out = Command::new(oring_bin()) + .env("ORING_POLICY", &policy) + .args(["--env-keep", TRIPLE]) + .arg("--") + .arg(&exe) + .args(["--exact", "payload", "--ignored", "--nocapture"]) + .env(TRIPLE, spec) + .output() + .expect("spawn oring"); + out.status.code().unwrap_or_else(|| { + panic!( + "payload did not exit normally:\n{}", + String::from_utf8_lossy(&out.stderr) + ) + }) +} + +/// Bare half of the control, for a raw spec string. +fn run_payload_bare(spec: &str) -> i32 { + let exe = std::env::current_exe().expect("current_exe"); + let out = Command::new(&exe) + .args(["--exact", "payload", "--ignored", "--nocapture"]) + .env(TRIPLE, spec) + .output() + .expect("spawn payload"); + out.status.code().unwrap_or(-1) +} + +/// Seal a child under `profile` and return the payload's exit code. +fn socket_under_seal(profile: &str, family: i32, ty: i32, proto: i32) -> i32 { + let wd = tmp_workdir("protowall"); + let exe = std::env::current_exe().expect("current_exe"); + let policy = policy_json_sec(&[wd.to_str().unwrap()], Some(&[]), profile); + let out = Command::new(oring_bin()) + .env("ORING_POLICY", &policy) + .args(["--env-keep", TRIPLE]) + .arg("--") + .arg(&exe) + .args(["--exact", "payload", "--ignored", "--nocapture"]) + .env(TRIPLE, format!("{family},{ty},{proto}")) + .output() + .expect("spawn oring"); + out.status.code().unwrap_or_else(|| { + panic!( + "payload did not exit normally:\n{}", + String::from_utf8_lossy(&out.stderr) + ) + }) +} + +/// `socket(2)` run WITHOUT a seal, same payload. This is the bare half of a +/// bare-vs-confined control (G19): if the kernel refuses a triple anyway — no +/// module, no support — then a sealed refusal proves nothing about oring. +fn socket_bare(family: i32, ty: i32, proto: i32) -> i32 { + let exe = std::env::current_exe().expect("current_exe"); + let out = Command::new(&exe) + .args(["--exact", "payload", "--ignored", "--nocapture"]) + .env(TRIPLE, format!("{family},{ty},{proto}")) + .output() + .expect("spawn payload"); + out.status.code().unwrap_or(-1) +} + +/// oring's refusal errno for this rule (the `eafnosupport` group). A kernel that +/// simply lacks the protocol returns `EPROTONOSUPPORT` instead, which is how the +/// two causes are told apart. +const EAFNOSUPPORT: i32 = 97; + +/// Assert that ORING refused the triple — not the kernel. Skips loudly, rather +/// than passing, when the bare control shows the triple is unavailable here. +/// +/// This is the shape the containment matrix still lacks: without it, the SCTP +/// cell below PASSED on a `localmodconfig` kernel with no `sctp.ko`, satisfied +/// entirely by the kernel's own `EPROTONOSUPPORT`. Verified by mutation — with +/// oring's rule removed, that cell stayed green while the MPTCP and ICMP cells +/// went red. +fn assert_oring_refused(label: &str, family: i32, ty: i32, proto: i32) { + let bare = socket_bare(family, ty, proto); + if bare != 0 { + eprintln!( + "SKIP: {label}: unavailable on this kernel (bare socket() → errno {bare}), \ + so a sealed refusal would be unattributable" + ); + return; + } + let sealed = socket_under_seal("strict", family, ty, proto); + assert_eq!( + sealed, EAFNOSUPPORT, + "{label}: bare socket() succeeded but sealed returned {sealed}, not \ + EAFNOSUPPORT ({EAFNOSUPPORT}) — oring's protocol wall is not what \ + refused it" + ); +} + +/// Attribution for a triple the KERNEL also refuses — but with a DIFFERENT +/// errno. [`assert_oring_refused`] skips those, which is right when the two +/// errnos would be indistinguishable, and wrong here: the whole type-axis class +/// (`SOCK_SEQPACKET` on inet with no SCTP module, `SOCK_RAW` without +/// `CAP_NET_RAW`) fails bare with a *distinguishable* errno, so the change from +/// that errno to `EAFNOSUPPORT` under seal is exactly the attribution evidence +/// the file's own header calls for. +/// +/// Fails loudly if the bare errno is already `EAFNOSUPPORT` — then the two +/// causes ARE indistinguishable and the cell would prove nothing. +fn assert_oring_changed_the_errno(label: &str, spec: &str) { + let n: Vec = spec.split(',').map(|p| p.parse().expect("spec")).collect(); + let sp = n.get(3).copied().unwrap_or(0) == 1; + let bare = run_payload_bare(spec); + assert_ne!( + bare, EAFNOSUPPORT, + "{label}: bare already returns EAFNOSUPPORT, so a sealed EAFNOSUPPORT is \ + unattributable — this cell cannot establish anything" + ); + let sealed = run_payload_sealed("strict", spec); + assert_eq!( + sealed, + EAFNOSUPPORT, + "{label}: bare errno {bare} (op={}), sealed {sealed} — expected oring's \ + EAFNOSUPPORT ({EAFNOSUPPORT}); the type-axis rule did not fire", + if sp { "socketpair" } else { "socket" } + ); +} + +/// Assert a triple still WORKS under seal. The non-regression half: AF_UNIX +/// SOCK_SEQPACKET is used by systemd, D-Bus and logging, and a family-scoped +/// rule that leaked into AF_UNIX would break far more than it protects. +fn assert_still_allowed(label: &str, spec: &str) { + let bare = run_payload_bare(spec); + if bare != 0 { + eprintln!("SKIP: {label}: unavailable bare (errno {bare}) — nothing to preserve"); + return; + } + let sealed = run_payload_sealed("strict", spec); + assert_eq!( + sealed, 0, + "{label}: worked bare but sealed returned errno {sealed}" + ); +} + +const AF_INET: i32 = 2; +const AF_INET6: i32 = 10; +const AF_UNIX: i32 = 1; +const SOCK_STREAM: i32 = 1; +const SOCK_DGRAM: i32 = 2; +const SOCK_RAW: i32 = 3; +const SOCK_SEQPACKET: i32 = 5; +const SOCK_CLOEXEC: i32 = 0o2000000; +const IPPROTO_DEFAULT: i32 = 0; +const IPPROTO_TCP: i32 = 6; +const IPPROTO_UDP: i32 = 17; +const IPPROTO_MPTCP: i32 = 262; +const IPPROTO_SCTP: i32 = 132; +const IPPROTO_ICMP: i32 = 1; + +/// The reported bypass: a stream protocol Landlock does not treat as TCP. +#[test] +fn mptcp_socket_is_refused_under_strict() { + if !require_or_skip("mptcp_socket_is_refused_under_strict") { + return; + } + assert_oring_refused("MPTCP", AF_INET, SOCK_STREAM, IPPROTO_MPTCP); +} + +/// Same class, different number — the reason the rule is an allowlist over the +/// protocols ring 1 mediates rather than a denylist of the ones it does not. +#[test] +fn sctp_socket_is_refused_under_strict() { + if !require_or_skip("sctp_socket_is_refused_under_strict") { + return; + } + assert_oring_refused("SCTP", AF_INET, SOCK_STREAM, IPPROTO_SCTP); +} + +/// ICMP falls to the same rule. Recorded as a cell because it is a **behaviour +/// change**, not a side effect to discover later: in-cage `ping` stops working, +/// and §2's ICMP residual closes for `AF_INET`. +#[test] +fn icmp_socket_is_refused_under_strict() { + if !require_or_skip("icmp_socket_is_refused_under_strict") { + return; + } + assert_oring_refused("ICMP", AF_INET, SOCK_DGRAM, IPPROTO_ICMP); +} + +/// Controls — without these the three above are satisfied by a rule that +/// simply breaks `socket(2)`. +#[test] +fn mediated_protocols_and_af_unix_still_work() { + if !require_or_skip("mediated_protocols_and_af_unix_still_work") { + return; + } + for (label, fam, ty, proto) in [ + ("AF_INET default", AF_INET, SOCK_STREAM, IPPROTO_DEFAULT), + ("AF_INET TCP", AF_INET, SOCK_STREAM, IPPROTO_TCP), + ("AF_INET UDP", AF_INET, SOCK_DGRAM, IPPROTO_UDP), + ("AF_UNIX", AF_UNIX, SOCK_STREAM, IPPROTO_DEFAULT), + ] { + let rc = socket_under_seal("strict", fam, ty, proto); + assert_eq!( + rc, 0, + "{label}: socket() was refused, but ring 1 mediates this protocol \ + (or it is AF_UNIX, which toolchains need). The protocol wall is \ + over-blocking." + ); + } +} + +// --- the TYPE axis ------------------------------------------------------- +// +// Everything above parameterises on `protocol` with `type` pinned to STREAM or +// DGRAM — which is how the type-axis hole survived a file explicitly written +// about the (family, type, protocol) triple. `type` selects the transport +// independently of `protocol`: `inet_create` dispatches on `inetsw[sock->type]`, +// and with `protocol == 0` the first registrant for that type wins. + +/// `socket(AF_INET, SOCK_SEQPACKET, 0)` reached SCTP's slot with the protocol +/// allowlist none the wiser, because arg 1 was never loaded into the filter. +#[test] +fn inet_seqpacket_is_refused_under_strict() { + if !require_or_skip("inet_seqpacket_is_refused_under_strict") { + return; + } + for (label, spec) in [ + ( + "AF_INET SEQPACKET", + format!("{AF_INET},{SOCK_SEQPACKET},{IPPROTO_DEFAULT}"), + ), + ( + "AF_INET6 SEQPACKET", + format!("{AF_INET6},{SOCK_SEQPACKET},{IPPROTO_DEFAULT}"), + ), + ( + "AF_INET SEQPACKET|CLOEXEC", + format!( + "{AF_INET},{},{IPPROTO_DEFAULT}", + SOCK_SEQPACKET | SOCK_CLOEXEC + ), + ), + ( + "AF_INET RAW/TCP", + format!("{AF_INET},{SOCK_RAW},{IPPROTO_TCP}"), + ), + ] { + assert_oring_changed_the_errno(label, &spec); + } +} + +/// **The higher-severity half.** `socketpair` carried only the family gate, so +/// `socketpair(AF_INET, SOCK_SEQPACKET, IPPROTO_SCTP)` ran `sock_create()` — and +/// its `request_module("net-pf-2-proto-132")` — for a call that can never return +/// a usable inet fd. modprobe runs as a usermode helper OUTSIDE the sandbox, so +/// a sealed child could load `sctp.ko` on its own behalf and then open a real +/// SCTP socket that ring 1 does not mediate. +/// +/// Measured ordering, unprivileged: `socketpair(AF_INET, SOCK_STREAM, +/// IPPROTO_TCP)` returns EOPNOTSUPP, which is only reachable AFTER `sock_create` +/// succeeded — proof the create path (and the autoload with it) ran. +#[test] +fn inet_socketpair_is_refused_under_strict() { + if !require_or_skip("inet_socketpair_is_refused_under_strict") { + return; + } + for (label, spec) in [ + ( + "socketpair AF_INET SEQPACKET/SCTP (the autoload primitive)", + format!("{AF_INET},{SOCK_SEQPACKET},{IPPROTO_SCTP},1"), + ), + // protocol 0 is ALLOWED by the protocol rule, so only the TYPE rule can + // refuse this one. Without it the whole test passed on the protocol + // rule's authority while the type axis went unexercised — found by + // mutation (removing the type rule left this test green), which is the + // "check with an instrument that can see the defect" trap in miniature. + ( + "socketpair AF_INET SEQPACKET/0 (type axis only)", + format!("{AF_INET},{SOCK_SEQPACKET},{IPPROTO_DEFAULT},1"), + ), + ( + "socketpair AF_INET6 SEQPACKET/0 (type axis only)", + format!("{AF_INET6},{SOCK_SEQPACKET},{IPPROTO_DEFAULT},1"), + ), + ( + "socketpair AF_INET RAW/TCP (type axis only)", + format!("{AF_INET},{SOCK_RAW},{IPPROTO_TCP},1"), + ), + ( + "socketpair AF_INET STREAM/SCTP", + format!("{AF_INET},{SOCK_STREAM},{IPPROTO_SCTP},1"), + ), + ( + "socketpair AF_INET6 SEQPACKET/SCTP", + format!("{AF_INET6},{SOCK_SEQPACKET},{IPPROTO_SCTP},1"), + ), + ] { + assert_oring_changed_the_errno(label, &spec); + } +} + +/// Non-regression, and the reason the rule is family-scoped: AF_UNIX +/// `SOCK_SEQPACKET` is everywhere (systemd, D-Bus, logging, `sd_notify`). A +/// type rule that leaked past the family guard would break far more than it +/// protects. Both syscalls, since both now carry the rule. +#[test] +fn af_unix_seqpacket_survives_the_type_wall() { + if !require_or_skip("af_unix_seqpacket_survives_the_type_wall") { + return; + } + for (label, spec) in [ + ( + "socket AF_UNIX SEQPACKET", + format!("{AF_UNIX},{SOCK_SEQPACKET},0"), + ), + ( + "socketpair AF_UNIX SEQPACKET", + format!("{AF_UNIX},{SOCK_SEQPACKET},0,1"), + ), + ( + "socketpair AF_UNIX STREAM", + format!("{AF_UNIX},{SOCK_STREAM},0,1"), + ), + ( + "socketpair AF_UNIX DGRAM", + format!("{AF_UNIX},{SOCK_DGRAM},0,1"), + ), + ] { + assert_still_allowed(label, &spec); + } +} + +// --------------------------------------------------------------------------- +// COMPOSITION CELLS LIVE IN `tests/containment_matrix.rs`, NOT HERE. +// +// A hand-written composition test lived at the bottom of this file for one commit. It +// moved because `containment_matrix.rs` gained a `Composition` shape that can hold it +// properly — several probes under ONE policy, with two rules enforced at run time that +// a hand-written test only remembered: at least two probes, and at least one +// reachability control (a conjunction of denials is satisfied by a seal that refuses +// everything). +// +// The cell is `tcp-wall-both-rings`. One home, and the matrix prints its frame. diff --git a/tests/residual_boundary.rs b/tests/residual_boundary.rs index d3e2d04..013e7c2 100644 --- a/tests/residual_boundary.rs +++ b/tests/residual_boundary.rs @@ -40,3 +40,146 @@ fn write_exec_tier_allows_exec() { "exec from the write_exec workdir must succeed" ); } + +/// **The under-coverage disclosure is a stderr line, so the CALLER can discard it.** +/// +/// This test pins a *limitation*, which is what this file is for — it fails the day +/// someone closes the gap, and that failure is the prompt to update `CONSTRAINTS.md` +/// CON-5 and the matching `docs/DESIGN.md` §2 residual. +/// +/// `CONSTRAINTS.md` CON-5 read *"disclosed on a channel the run cannot suppress"* until +/// 2026-07-29. Measured, that was false: `tests/udp_wall.rs` pins that `ORING_QUIET` +/// cannot suppress the under-coverage report, but a caller who redirects fd 2 discards it +/// entirely — the write to `/dev/null` **succeeds**, so no error-checking inside oring can +/// detect the case. The claim has been narrowed to what is true. +/// +/// **Why there is no fix at this layer, established rather than assumed:** the audit +/// transport `src/denials.rs` reads is a *consumer* of kernel-emitted Landlock records +/// (audit types 1423/1424) via `journalctl` — oring has no producer side, and acquiring +/// one needs `CAP_AUDIT_WRITE`, which an unprivileged tool by definition lacks. `/dev/log` +/// is not available either: the disclosure is emitted at pipeline step 11, when oring is +/// **already Landlock-sealed** (step 6), so a new channel would have to be granted by the +/// very policy under audit. oring also blocks `SYS_syslog` in its own denylist. +/// +/// So the residual is structural at this layer. The open product question — recorded, not +/// decided here — is whether under-coverage should *refuse* rather than warn, which is +/// what `D-4` rejects everywhere else as the degraded-path-with-a-warning pattern. +#[test] +fn the_under_coverage_disclosure_is_discardable_by_the_caller() { + use std::process::{Command, Stdio}; + + let wd = tmp_workdir("uncov-fd2"); + // A shipped profile is enough: at V10 `balanced` leaves the UDP net rights + // unhandled, so there is genuine under-coverage to disclose. + let mut base = Command::new(oring_bin()); + base.args(["--profile", "balanced", "--workdir", wd.to_str().unwrap()]) + .args(["--", "/bin/true"]) + .env_remove("ORING_QUIET") + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + let seen = base.output().expect("run with stderr piped"); + let err = String::from_utf8_lossy(&seen.stderr).to_string(); + + if !err.contains("unhandled=") { + eprintln!( + "SKIP: the_under_coverage_disclosure_is_discardable_by_the_caller: this host's \ + profile left no rights unhandled, so there is nothing to discard" + ); + return; + } + assert!( + seen.status.success(), + "control arm: the run itself must succeed; got {:?}", + seen.status + ); + + // The same run with fd 2 pointed at /dev/null. The write SUCCEEDS, the seal + // proceeds, and an auditor reading this run's output learns nothing. + let discarded = Command::new(oring_bin()) + .args(["--profile", "balanced", "--workdir", wd.to_str().unwrap()]) + .args(["--", "/bin/true"]) + .env_remove("ORING_QUIET") + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .expect("run with stderr discarded"); + + assert!( + discarded.status.success(), + "the seal still proceeds with the disclosure discarded — that is the defect being \ + pinned, not an error" + ); + assert!( + String::from_utf8_lossy(&discarded.stdout).is_empty(), + "there is no SECOND channel: nothing about under-coverage reaches stdout. If this \ + assertion fails, a channel was added — narrow this test and widen CON-5." + ); +} + +/// **CON-3's canonical instance: `access()` says readable where `open()` is denied.** +/// +/// The confined tool's effect must equal its unconfined effect, or the difference must be +/// reported. Here it does neither, and the cause is structural rather than an oversight: +/// verified against this kernel's own `security/landlock/fs.c`, Landlock registers +/// `file_open` and the `path_*`/`sb_*` hooks and registers **neither `inode_permission` +/// nor `inode_getattr`**. So a withheld path stays VISIBLE to a pre-flight check while the +/// open that follows is denied. **No ABI bump changes this** — there is no hook to add a +/// right to. +/// +/// A tool that pre-flights with `access()` therefore takes the wrong branch and produces a +/// wrong result **with nobody attacking**, which is why this is a `DIVERGE` instance that +/// an adversary-scoped threat model structurally could not see. +/// +/// Three arms, one variable each — the middle one is the finding and the other two are the +/// controls that make it attributable: +/// +/// | arm | exit | meaning | +/// |---|---|---| +/// | unconfined, withheld path | 0 | access OK, open OK — no divergence | +/// | confined, path NOT granted | **10** | **access OK, open DENIED** | +/// | confined, path granted | 0 | the seal is not simply breaking everything | +/// +/// **What this does NOT establish, and CON-3 stays `unenforced` because of it:** pinning +/// that the divergence exists is not the same as oring *reporting* it. oring cannot know +/// which paths a tool will pre-flight, so the second limb of CON-3 is unreachable per-run +/// at this layer. What this buys is that the divergence can no longer change silently — if +/// a future kernel closes it, this test fails and the §2 residual has to be rewritten. +#[test] +fn access_says_readable_where_open_is_denied() { + if !require_or_skip("access_says_readable_where_open_is_denied") { + return; + } + let wd = tmp_workdir("divergence"); + let withheld = wd.join("withheld"); + std::fs::write(&withheld, b"secret").unwrap(); + let w = withheld.to_str().unwrap(); + + // A policy that grants /etc but NOT the workdir, so `withheld` is out of every tier. + let policy = policy_json(&[], None); + + let (bare, _) = run_probe_unsealed("fs-access-vs-open", w); + assert_eq!( + bare, + Some(0), + "unconfined, both calls must succeed — otherwise the fixture is measuring DAC, \ + not Landlock" + ); + + let (sealed, _) = run_probe(&policy, "fs-access-vs-open", w); + assert_eq!( + sealed, + Some(10), + "THE DIVERGENCE: confined, `access(R_OK)` must report the withheld path readable \ + while `open()` is denied. 30 would mean both were denied — i.e. the divergence \ + has CLOSED, which would be good news and a documentation change: rewrite the \ + `access()`/`open()` residual in docs/DESIGN.md §2 and re-check CON-3." + ); + + let (granted, _) = run_probe(&policy, "fs-access-vs-open", "/etc/hostname"); + assert_eq!( + granted, + Some(0), + "control: a GRANTED path must diverge in neither direction, or this test is \ + measuring a broken seal rather than a divergence" + ); +} diff --git a/tests/seccomp_attribution.rs b/tests/seccomp_attribution.rs new file mode 100644 index 0000000..4e03302 --- /dev/null +++ b/tests/seccomp_attribution.rs @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: BSD-3-Clause +//! Ring-2 attribution: the kernel names the x32 numbering it killed (§5/§11c). +//! +//! **Establishes:** on this kernel, with this build, an x32-numbered `getpid` +//! from a sealed child makes the kernel emit an `AUDIT_SECCOMP` record *for that +//! child* naming `syscall=0x40000027` and `code=0x80000000` +//! (`SECCOMP_RET_KILL_PROCESS`). +//! +//! **Does NOT establish that x32 is closed.** That is universally quantified over +//! the numbering space and belongs to the Kani harness `x32_guard_total`. This is +//! one input on one kernel, and its job is the **model–reality bridge**: evidence +//! that the proved filter is the deployed filter and that the kernel behaves as +//! the proof assumed. That gap is not hypothetical — CVE-2019-7303 lived in it, a +//! filter provably correct about 64-bit `seccomp_data` and wrong about what the +//! kernel does to the argument afterwards. Never cite this file as the proof. +//! +//! **Correlation is weaker here than in ring 1.** `denial_attribution.rs` can +//! join on a kernel-generated domain id, unique per `restrict_self`. +//! `AUDIT_SECCOMP` records carry no such key — only pid, syscall, code, sig, exe, +//! comm — and pids recycle. Grepping the journal for `syscall=1073741863` would +//! pass on any record a previous run left behind: no possible falsifier, so a +//! ritual rather than a test. The window is therefore scoped by a **journal +//! cursor** taken immediately before the spawn, and a record counts only if it is +//! after that cursor AND carries this child's pid AND came from the binary under +//! test. "This run emitted no such record" is then expressible, and is what +//! failure means. +//! +//! **Fails closed.** No cursor, no journal, no matching record, or a record whose +//! fields disagree — all fail. Absence is never read as success. +#![cfg(all(target_os = "linux", target_arch = "x86_64"))] +mod common; +use common::*; + +use std::os::unix::process::ExitStatusExt as _; +use std::process::Command; + +/// `AUDIT_SECCOMP` — `include/uapi/linux/audit.h`. +const AUDIT_SECCOMP: &str = "_AUDIT_TYPE=1326"; +/// `SECCOMP_RET_KILL_PROCESS`, as the kernel formats it in the record. +const KILL_PROCESS: &str = "code=0x80000000"; +/// `__X32_SYSCALL_BIT` — the second numbering the guard exists to close. +const X32_BIT: i64 = 0x4000_0000; + +/// Journal cursor for "now". `None` if journald cannot be read at all. +fn cursor_now() -> Option { + let out = Command::new("journalctl") + .args(["-n", "0", "--show-cursor"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + String::from_utf8_lossy(&out.stdout) + .lines() + .find_map(|l| l.split_once("cursor: ").map(|(_, c)| c.trim().to_string())) +} + +/// Seccomp records strictly after `cursor`. +fn seccomp_records_after(cursor: &str) -> Vec { + Command::new("journalctl") + .args(["--after-cursor", cursor, AUDIT_SECCOMP, "-o", "cat"]) + .output() + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .lines() + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +/// Is there an audit-carrying journal? Fatal under `ORING_REQUIRE_AUDIT`. +/// +/// Duplicated from `tests/denial_attribution.rs` rather than lifted into +/// `tests/common/mod.rs`: that module is shared with every other suite, and +/// these fixtures are additive by construction — no existing file is touched. +fn audit_or_skip(test: &str) -> bool { + let ok = Command::new("journalctl") + .args(["-n", "0", "_TRANSPORT=audit"]) + .output() + .is_ok_and(|o| o.status.success()); + if ok { + return true; + } + assert!( + std::env::var_os("ORING_REQUIRE_AUDIT").is_none(), + "{test}: ORING_REQUIRE_AUDIT=1 but no audit records are readable from \ + journald — ring-2 attribution cannot witness anything here." + ); + eprintln!("SKIP: {test}: no readable audit journal (ORING_REQUIRE_AUDIT=1 to make fatal)"); + false +} + +/// Run the x32 probe sealed; return (signal, child pid). +fn run_x32() -> (Option, u32) { + let wd = tmp_workdir("x32attrib"); + let policy = policy_json(&[wd.to_str().unwrap()], None); + let out = Command::new(oring_bin()) + .env("ORING_POLICY", &policy) + .arg("--") + .arg(oring_bin()) + .args(["__probe", "x32-call"]) + .output() + .expect("spawn oring"); + + let stderr = String::from_utf8_lossy(&out.stderr); + let pid = stderr + .split_whitespace() + .find_map(|t| t.strip_prefix("pid=")) + .and_then(|p| p.trim().parse::().ok()) + .unwrap_or_else(|| panic!("no seal line in oring stderr:\n{stderr}")); + + (out.status.signal(), pid) +} + +#[test] +fn x32_kill_is_attributed_to_the_x32_numbering() { + if !require_or_skip("x32_kill_is_attributed_to_the_x32_numbering") { + return; + } + if !audit_or_skip("x32_kill_is_attributed_to_the_x32_numbering") { + return; + } + // The cursor MUST be taken before the run: it is the only thing separating + // this run's record from the ones previous runs left in the journal. + let Some(cursor) = cursor_now() else { + assert!( + std::env::var_os("ORING_REQUIRE_AUDIT").is_none(), + "ORING_REQUIRE_AUDIT=1 but journald returned no cursor — the window \ + cannot be scoped, so any assertion here would be unfalsifiable" + ); + eprintln!("SKIP: journald returned no cursor"); + return; + }; + + let (sig, pid) = run_x32(); + assert_eq!( + sig, + Some(SIGSYS), + "x32 syscall was not SIGSYS-killed — the guard was bypassed, and \ + everything below is moot" + ); + + // journald ingests asynchronously; poll rather than assume. + let want_syscall = format!("syscall={}", X32_BIT | libc::SYS_getpid); + let want_pid = format!("pid={pid} "); + let mut seen: Vec = Vec::new(); + for _ in 0..40 { + seen = seccomp_records_after(&cursor); + if seen.iter().any(|r| r.contains(&want_pid)) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + + let rec = seen + .iter() + .find(|r| r.contains(&want_pid)) + .unwrap_or_else(|| { + panic!( + "no AUDIT_SECCOMP record for pid {pid} after the cursor. The \ + child WAS killed by SIGSYS, so the kernel enforced — but this \ + run produced no record, which means ring-2 attribution is not \ + available here and no test may claim it is. Records seen in \ + window: {}\n{}", + seen.len(), + seen.join("\n") + ) + }); + + assert!( + rec.contains(&want_syscall), + "record found, but it does not name the x32 numbering (expected \ + {want_syscall} = x32 bit | SYS_getpid). Asserting SIGSYS alone cannot \ + tell an x32 kill from any other KILL_PROCESS. Record:\n{rec}" + ); + assert!( + rec.contains(KILL_PROCESS), + "record does not carry SECCOMP_RET_KILL_PROCESS — the action taken was \ + not the one the guard specifies. Record:\n{rec}" + ); + assert!( + rec.contains("comm=\"oring\""), + "record is not attributed to the binary under test:\n{rec}" + ); +} diff --git a/tests/seccomp_smt.rs b/tests/seccomp_smt.rs index 5db1602..d2977a2 100644 --- a/tests/seccomp_smt.rs +++ b/tests/seccomp_smt.rs @@ -405,3 +405,143 @@ fn positive_control_detects_a_high_bit_dependency() { eprintln!("SKIP positive_control: z3 inconclusive ({mutant_verdict}) on the mutant filter"); } } + +// --------------------------------------------------------------------------- +// DENIAL COMPLETENESS over the namespace flag masks (T14) +// --------------------------------------------------------------------------- + +/// `SECCOMP_RET_ALLOW`. The property below is stated against *not this*, rather +/// than against a specific errno, because the guarantee is "the call does not +/// pass through" — which errno it fails with is a separate, weaker claim. +const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000; + +/// The `CLONE_NEW*` bits, and which syscall each rule set is expected to carry. +/// +/// `CLONE_NEWTIME` (0x80) is on `unshare` ONLY: in legacy `clone(2)` the low +/// byte is `CSIGNAL`, so a 0x80 rule there would false-match an exit signal +/// [corrections: C-4]. Duplicated from `src/seccomp.rs` deliberately — a gate +/// that imports the table it checks proves only that the table equals itself. +const CLONE_NEW_BITS: &[(u32, &str)] = &[ + (0x0002_0000, "CLONE_NEWNS"), + (0x0200_0000, "CLONE_NEWCGROUP"), + (0x0400_0000, "CLONE_NEWUTS"), + (0x0800_0000, "CLONE_NEWIPC"), + (0x1000_0000, "CLONE_NEWUSER"), + (0x2000_0000, "CLONE_NEWPID"), + (0x4000_0000, "CLONE_NEWNET"), +]; +const CLONE_NEWTIME: u32 = 0x0000_0080; + +/// ∃ an argument-0 value with at least one `CLONE_NEW*` bit set that the filter +/// ALLOWS? `unsat` means no such value exists — denial completeness. +fn build_denial_query( + insns: &[(u16, u8, u8, u32)], + nr: i64, + bits: &[u32], +) -> Result { + let mut s = String::from("(set-logic QF_BV)\n"); + for i in 0..N_ARGS { + let _ = writeln!(s, "(declare-const a{i}_lo_A (_ BitVec 32))"); + let _ = writeln!(s, "(declare-const a{i}_hi_A (_ BitVec 32))"); + } + emit_program(&mut s, insns, "A", nr)?; + let any = bits + .iter() + .map(|b| format!("(not (= (bvand a0_lo_A (_ bv{b} 32)) (_ bv0 32)))")) + .collect::>() + .join(" "); + let _ = writeln!(s, "(assert (or {any}))"); + let _ = writeln!(s, "(assert (= ret_A (_ bv{SECCOMP_RET_ALLOW} 32)))"); + let _ = writeln!(s, "(check-sat)"); + Ok(s) +} + +/// **No `CLONE_NEW*` combination reaches the kernel through `clone` or +/// `unshare`.** +/// +/// Why this is a solver query and not a test cell: `docs/RESIDUALS.md` records +/// that only `CLONE_NEWUSER` is attributable at run time — unprivileged, the +/// other flags return `EPERM` **bare**, so a sealed `EPERM` proves nothing. One +/// runtime cell therefore covers one of eight bits, and a bit dropped from the +/// rule table, or a mask/value pair written wrong, would leave the whole suite +/// green. The argument space is 2^32 per call, so sampling cannot close it +/// either. +/// +/// **Establishes:** over ALL argument values, no value with any `CLONE_NEW*` bit +/// set is allowed by the emitted program. +/// +/// **Does NOT establish** that the denial carries the right errno, that the +/// kernel honours the filter, or anything about `clone3`/`setns`/`mount` — those +/// are whole-syscall blocks with their own cells. +#[test] +fn no_clone_new_flag_combination_is_allowed() { + if !z3_available() { + let msg = "SKIP: z3 not found — denial-completeness over CLONE_NEW* not checked"; + assert!( + std::env::var_os("ORING_REQUIRE_SMT").is_none(), + "{msg} (ORING_REQUIRE_SMT is set)" + ); + eprintln!("{msg}"); + return; + } + let groups = oring::seccomp::dump_groups(false).expect("dump groups"); + + let mut checked = 0usize; + for (nr, extra, label) in [ + (libc::SYS_clone, None, "clone"), + (libc::SYS_unshare, Some(CLONE_NEWTIME), "unshare"), + ] { + let g = groups + .iter() + .find(|g| g.nrs.contains(&nr)) + .unwrap_or_else(|| panic!("COULD NOT CHECK: no emitted group carries {label}")); + + let mut bits: Vec = CLONE_NEW_BITS.iter().map(|(b, _)| *b).collect(); + bits.extend(extra); + + let q = build_denial_query(&g.insns, nr, &bits).expect("encode"); + let verdict = run_z3(&q); + assert_eq!( + verdict, "unsat", + "{label}: the filter ALLOWS some argument with a CLONE_NEW* bit set \ + (z3 said {verdict:?}). `unsat` is the property; `sat` is a real hole — a bit \ + missing from the rule table, or a mask/value pair written wrong. Only \ + CLONE_NEWUSER has a runtime cell, so nothing else in the suite would catch it." + ); + checked += 1; + + // Per-bit attribution, so a failure names the flag rather than the call. + for (bit, name) in CLONE_NEW_BITS + .iter() + .chain(extra.map(|_| &(CLONE_NEWTIME, "CLONE_NEWTIME"))) + { + let q = build_denial_query(&g.insns, nr, &[*bit]).expect("encode"); + let v = run_z3(&q); + assert_eq!( + v, "unsat", + "{label}: {name} (0x{bit:x}) alone is ALLOWED — this is the shape a combined \ + mask produces, where only all-bits-set matches." + ); + } + } + + // A CONTROL, because "unsat" is also what an unsatisfiable encoding returns. + // CLONE_NEWTIME must be allowed on `clone`, where oring deliberately emits no + // rule for it: if this is also unsat, the query is proving nothing. + let g = groups + .iter() + .find(|g| g.nrs.contains(&libc::SYS_clone)) + .expect("clone group"); + let ctl = build_denial_query(&g.insns, libc::SYS_clone, &[CLONE_NEWTIME]).expect("encode"); + assert_eq!( + run_z3(&ctl), + "sat", + "CONTROL FAILED: `clone` with only CLONE_NEWTIME set should be ALLOWED — oring emits \ + no 0x80 rule there on purpose, because the low byte is CSIGNAL. If this is unsat the \ + encoding denies everything and the assertions above prove nothing." + ); + + println!( + " denial completeness: {checked} syscalls, all CLONE_NEW* combinations unsat; control sat" + ); +} diff --git a/tests/self_protection.rs b/tests/self_protection.rs new file mode 100644 index 0000000..6ae4d25 --- /dev/null +++ b/tests/self_protection.rs @@ -0,0 +1,436 @@ +// SPDX-License-Identifier: BSD-3-Clause +//! `Policy::check_self_protection` (§7) — the arms the existing suite does not +//! reach, pinned by example. +//! +//! A review recorded this function as having *zero* tests. That was stale — +//! `policy_props.rs` has exercised it since v0.1.0 — but the truth was narrower +//! and had never been written down: proptest covers exactly ONE of the two arms +//! and ONE of the two write tiers. +//! +//! | arm | tier | covered before this file | +//! |---|---|---| +//! | policy file | `write_exec` | yes — `policy_props.rs` | +//! | policy file | `write_noexec` | **no** | +//! | oring binary (`current_exe`) | `write_exec` | **no** | +//! | oring binary (`current_exe`) | `write_noexec` | **no** | +//! +//! The uncovered binary arm matches the CVE class §7 cites (CVE-2025-54135 / +//! CVE-2025-53773 — the agent rewrites the configuration governing its own next +//! run) at its worst reach: overwriting the wrapper takes the *operator's* uid on +//! the next invocation, where rewriting the policy only widens the next cage. +//! +//! Each refusal is asserted **by message**, not by `is_err()`. Both arms return +//! the same error type, so an `is_err()` check can pass because the *other* arm +//! fired — a claim that reads as verified and is not. +//! +//! **Establishes:** for the paths constructed below, `covering()` treats both +//! write tiers and both arms alike, and matches component-wise, not textually. +//! +//! **Does NOT establish** that `check_self_protection` is correct — it samples +//! paths rather than quantifying over them. `policy_props.rs` quantifies for the +//! policy-file arm; nothing quantifies over the binary arm, because +//! `current_exe()` is process state, not an input a property test can range over. +//! Honest status: *falsifiable and not yet falsified*. +//! +//! **Falsification record** (mutants in a scratch copy, never in the tree): +//! dropping the `write_noexec` chain from `covering()` kills two of these and +//! *none* of `policy_props.rs`; neutering the `current_exe()` arm kills two and +//! none there; swapping component-wise `Path::starts_with` for textual +//! `str::starts_with` kills one each. **Three of four ways to break §7 were +//! invisible to the suite that existed before this file.** + +use std::path::{Path, PathBuf}; + +use oring::policy::{Landlock, Policy, Seccomp, SeccompProfile}; + +// --- fixtures -------------------------------------------------------------- + +fn empty_landlock() -> Landlock { + Landlock { + read_only: Vec::new(), + read_exec: Vec::new(), + write_noexec: Vec::new(), + write_exec: Vec::new(), + egress_tcp: None, + ingress_tcp: None, + egress_udp: None, + ingress_udp: None, + scope_ipc: true, + } +} + +fn mk(landlock: Landlock) -> Policy { + Policy { + version: 1, + landlock, + seccomp: Seccomp { + profile: SeccompProfile::Balanced, + }, + user_authored: false, + require_all_grants: false, + require_full_coverage: false, + } +} + +/// A policy granting `dir` in one write tier and nothing else. +fn policy_writing(tier: Tier, dir: &Path) -> Policy { + let mut l = empty_landlock(); + // A read tier is present in every realistic policy; include one so a bug + // that scanned *all* tiers instead of the two write tiers would show up as + // a false refusal rather than passing silently. + l.read_only.push(PathBuf::from("/etc")); + match tier { + Tier::WriteExec => l.write_exec.push(dir.to_path_buf()), + Tier::WriteNoexec => l.write_noexec.push(dir.to_path_buf()), + } + mk(l) +} + +#[derive(Clone, Copy, Debug)] +enum Tier { + WriteExec, + WriteNoexec, +} + +/// The directory holding this test binary. `current_exe()` inside a test +/// resolves to the harness binary under `target/`, which is a real, existing, +/// canonicalisable path — exactly the input shape the production call sees. +fn exe_dir() -> PathBuf { + std::env::current_exe() + .expect("current_exe must resolve for these fixtures to mean anything") + .parent() + .expect("test binary always has a parent dir") + .to_path_buf() +} + +/// Scratch path under `/tmp`, pid-tagged like `tests/exec_tmp.rs`. +fn scratch(tag: &str) -> PathBuf { + std::env::temp_dir().join(format!("oring-selfprot-{}-{}", std::process::id(), tag)) +} + +const BINARY_ARM: &str = "the oring binary"; +const POLICY_ARM: &str = "the policy file"; + +fn refusal(p: &Policy, policy_file: Option<&Path>) -> String { + p.check_self_protection(policy_file) + .expect_err("must refuse") + .to_string() +} + +// --- the binary arm -------------------------------------------------------- + +/// The wrapper binary sitting inside a `write_exec` grant must refuse. This is +/// the highest-consequence arm: a child that can overwrite `oring` itself runs +/// as the operator on the next invocation, *outside* any cage. +#[test] +fn binary_inside_write_exec_is_refused() { + let p = policy_writing(Tier::WriteExec, &exe_dir()); + let msg = refusal(&p, None); + assert!( + msg.contains(BINARY_ARM), + "refused, but not by the binary arm: {msg}" + ); +} + +/// Same, one tier down. `write_noexec` does not stop an overwrite — it stops +/// *executing* what was written — so the binary is just as forgeable there and +/// the impl chains both tiers. Nothing pinned that chaining until now. +#[test] +fn binary_inside_write_noexec_is_refused() { + let p = policy_writing(Tier::WriteNoexec, &exe_dir()); + let msg = refusal(&p, None); + assert!( + msg.contains(BINARY_ARM), + "refused, but not by the binary arm: {msg}" + ); +} + +/// Negative control. Without it the two tests above prove only that the +/// function refuses *something*. +#[test] +fn binary_outside_every_write_tier_is_allowed() { + let mut l = empty_landlock(); + l.write_exec.push(scratch("elsewhere-x")); + l.write_noexec.push(scratch("elsewhere-w")); + let p = mk(l); + assert!( + p.check_self_protection(None).is_ok(), + "false refusal with no write tier anywhere near {:?}", + exe_dir() + ); +} + +/// Component-wise, on the binary arm. `policy_props.rs` pins this for the +/// policy-file arm; both arms share `covering()`, so a regression to string +/// `starts_with` would be caught here too, for the tier that matters more. +/// +/// The grant must be a **textual prefix of** the binary's path, not merely a +/// textual sibling of it: `Path::starts_with` and `str::starts_with` disagree +/// only in that direction. Truncating the last component's final character +/// (`…/deps` → `…/dep`) is the shortest input that separates them — a grant +/// spelled `…/deps-sibling` does not, and a test built that way passes under a +/// textual `starts_with` and proves nothing. +#[test] +fn write_tier_string_prefixing_the_binary_dir_does_not_cover() { + let dir = exe_dir(); + let mut trunc = dir.as_os_str().as_encoded_bytes().to_vec(); + trunc.pop().expect("exe dir is never empty"); + // SAFETY: dropping a trailing byte from a path that ends in an ASCII + // component name (`deps`, `debug`) leaves valid UTF-8; the assert pins it. + let trunc = String::from_utf8(trunc).expect("exe path is ASCII-tailed"); + assert!( + dir.to_string_lossy().starts_with(&trunc) && Path::new(&trunc) != dir, + "fixture is inert: {trunc} is not a strict textual prefix of {dir:?}" + ); + + let p = policy_writing(Tier::WriteExec, Path::new(&trunc)); + assert!( + p.check_self_protection(None).is_ok(), + "grant {trunc:?} is only a STRING prefix of the binary's dir {dir:?} — \ + it shares no path component boundary and must not cover it" + ); +} + +// --- the policy-file arm, second tier -------------------------------------- + +/// `policy_props.rs` grants `write_exec` only. A policy file inside a +/// `write_noexec` grant is equally rewritable — the tier governs execution, not +/// writes — and the refusal must fire there too. +#[test] +fn policy_file_inside_write_noexec_is_refused() { + let dir = scratch("wnoexec"); + let pf = dir.join("policy.json"); + let p = policy_writing(Tier::WriteNoexec, &dir); + let msg = refusal(&p, Some(&pf)); + assert!( + msg.contains(POLICY_ARM), + "refused, but not by the policy-file arm: {msg}" + ); +} + +// --- the inverse: the caller widening a PROFILE ---------------------------- + +/// `check_profile_grants` refuses when a **built-in profile** would grant write +/// over a tier containing `$HOME`. +/// +/// The live shape: `balanced` honours `$TMPDIR` so linkers can write scratch, +/// guarded only by "absolute" and "not `/tmp`" — so `TMPDIR=$HOME` granted +/// `write_noexec` over the whole home directory, with measured writes to +/// `~/.bashrc` and `~/.ssh/`. In oring's threat model the caller is the +/// adversary, which makes that an escalation rather than a footgun. +#[test] +fn profile_derived_tier_containing_home_is_refused() { + let home = scratch("home"); + let mut l = empty_landlock(); + l.read_only.push(PathBuf::from("/etc")); + l.write_noexec.push(home.clone()); // as $TMPDIR=$HOME would produce + let mut p = mk(l); + p.user_authored = false; // a built-in profile + let msg = p + .check_profile_grants(Some(&home)) + .expect_err("a profile granting write over $HOME must refuse") + .to_string(); + assert!( + msg.contains("profile self-protection"), + "refused, but not by the profile-grant check: {msg}" + ); +} + +/// The same tier, authored deliberately, is allowed. Without this the check +/// above is satisfied by a rule that refuses every `$HOME` grant, which would +/// break the operator who means it. +#[test] +fn operator_authored_tier_containing_home_is_allowed() { + let home = scratch("home-authored"); + let mut l = empty_landlock(); + l.write_noexec.push(home.clone()); + let mut p = mk(l); + p.user_authored = true; // came from --policy, not from a profile + assert!( + p.check_profile_grants(Some(&home)).is_ok(), + "an operator who names $HOME in a --policy file means it" + ); +} + +/// A profile whose tiers sit *under* `$HOME` — which every real profile has, +/// e.g. `~/.cargo/registry` — must not trip the check. Only a tier that +/// **contains** `$HOME` is the escalation. +#[test] +fn profile_tiers_beneath_home_are_not_refused() { + let home = scratch("home-beneath"); + let mut l = empty_landlock(); + l.write_noexec.push(home.join(".cargo").join("registry")); + l.write_exec.push(home.join("work")); + let mut p = mk(l); + p.user_authored = false; + assert!( + p.check_profile_grants(Some(&home)).is_ok(), + "tiers beneath $HOME are the normal case and must be allowed" + ); +} + +// --- symlinks: what canonicalisation buys, and what it does not ------------ + +/// A write tier named through a symlink still covers the policy file. Both +/// sides go through `canonicalize`, so naming the grant `/tmp/link` and the +/// policy `/tmp/real/policy.json` cannot slip past the check. +#[test] +fn write_tier_reached_through_a_symlink_still_covers_the_policy_file() { + let real = scratch("linktier-real"); + let link = scratch("linktier-link"); + let _ = std::fs::remove_dir_all(&real); + let _ = std::fs::remove_file(&link); + std::fs::create_dir_all(&real).expect("mkdir real"); + std::os::unix::fs::symlink(&real, &link).expect("symlink"); + let pf = real.join("policy.json"); + std::fs::write(&pf, "{}").expect("write policy"); + + let p = policy_writing(Tier::WriteExec, &link); + let msg = refusal(&p, Some(&pf)); + + let _ = std::fs::remove_dir_all(&real); + let _ = std::fs::remove_file(&link); + assert!( + msg.contains(POLICY_ARM), + "grant named through a symlink failed to cover its own target: {msg}" + ); +} + +/// **Documents a residual, does not endorse it.** `covering()` canonicalises +/// the *candidate* too, so a policy file reached through a symlink is judged by +/// its TARGET. Link inside a write tier, target outside ⇒ no refusal. +/// +/// The refusal is arguably correct as far as it goes: the bytes oring parses +/// this run live outside the cage. But the child can replace the *link* — it +/// owns the directory the link sits in — so the next `--policy /w/link.json` +/// run reads whatever the child chose. That is the same next-run escalation §7 +/// exists to stop, reached one level of indirection out. +/// +/// Pinned so the behaviour is a recorded decision rather than an accident. If +/// §7 later grows an `lstat`-based check on the policy path — the discipline +/// `--exec-tmp` already applies (DESIGN §4: refuse a symlinked scratch dir) — +/// this test is the one to invert, deliberately. +#[test] +fn policy_file_symlink_in_a_write_tier_is_judged_by_its_target() { + let writable = scratch("symtarget-writable"); + let safe = scratch("symtarget-safe"); + let _ = std::fs::remove_dir_all(&writable); + let _ = std::fs::remove_dir_all(&safe); + std::fs::create_dir_all(&writable).expect("mkdir writable"); + std::fs::create_dir_all(&safe).expect("mkdir safe"); + let target = safe.join("policy.json"); + std::fs::write(&target, "{}").expect("write policy"); + let link = writable.join("policy.json"); + std::os::unix::fs::symlink(&target, &link).expect("symlink"); + + let p = policy_writing(Tier::WriteExec, &writable); + let verdict = p.check_self_protection(Some(&link)); + + let _ = std::fs::remove_dir_all(&writable); + let _ = std::fs::remove_dir_all(&safe); + assert!( + verdict.is_ok(), + "behaviour changed: the symlinked policy path is now refused. If that \ + was intended, §7 gained a link check — update this test and DESIGN, \ + and drop the residual it records. Error was: {}", + verdict.unwrap_err() + ); +} + +// --- tier overlap ---------------------------------------------------------- +// +// Landlock has no deny rule and no unmount: rules on an ancestor and a +// descendant are OR'd and the wider grant wins. So the four-tier lattice is NOT +// closed under the union the kernel performs — `read_exec ∪ write_noexec = +// write_exec`, a tier neither declaration names. These pin +// `Policy::check_tier_overlap`, whose rule is: the effective tier must EQUAL the +// declared tier, except that `read_only` may effectively be `read_exec`. + +fn tiers(ro: &[&str], rx: &[&str], wn: &[&str], wx: &[&str]) -> Policy { + let mut ll = empty_landlock(); + ll.read_only = ro.iter().map(std::path::PathBuf::from).collect(); + ll.read_exec = rx.iter().map(std::path::PathBuf::from).collect(); + ll.write_noexec = wn.iter().map(std::path::PathBuf::from).collect(); + ll.write_exec = wx.iter().map(std::path::PathBuf::from).collect(); + mk(ll) +} + +/// **The shape that defeated W^X.** `balanced` puts `/tmp` in `write_noexec`; +/// `--workdir /tmp` injects the same path into `write_exec`. Measured before the +/// check existed: a script written to `/tmp` executed, `grants=` reported full +/// success, and the seal line was identical to a sound run — while +/// `--exec-tmp`'s own help text promises "`/tmp` itself stays no-exec". +#[test] +fn same_path_in_write_noexec_and_write_exec_is_refused() { + let p = tiers(&[], &[], &["/tmp"], &["/tmp"]); + let e = p.check_tier_overlap().unwrap_err().to_string(); + assert!( + e.contains("tier overlap"), + "expected a tier-overlap refusal, got: {e}" + ); + assert!( + e.contains("write_exec"), + "must name the EFFECTIVE tier: {e}" + ); +} + +/// Class A: an ancestor grants write over a path declared read-only. This is +/// Chikunov's 2024 upstream report (`LL_FS_RW=/ LL_FS_RO=/home` → the file is +/// created) in oring's own vocabulary. +#[test] +fn writable_ancestor_over_a_read_only_path_is_refused() { + let p = tiers(&["/srv/protected"], &[], &["/srv"], &[]); + let e = p.check_tier_overlap().unwrap_err().to_string(); + assert!(e.contains("tier overlap"), "got: {e}"); +} + +/// **The trap this check must not fall into.** `balanced` ships `~/go` +/// read_only with `~/go/bin` read_exec and `~/go/pkg` write_noexec — the §7 +/// split. Those are *outer-narrower*: the union with a narrower ancestor widens +/// nothing. A naive "any cross-tier containment" rule refuses them and bricks +/// the shipped profile. +#[test] +fn outer_narrower_nesting_is_clean() { + let p = tiers( + &["/home/u/go"], + &["/home/u/go/bin"], + &["/home/u/go/pkg"], + &[], + ); + assert!( + p.check_tier_overlap().is_ok(), + "the shipped ~/go split must not be refused — this rule would brick balanced" + ); +} + +/// Class C: `read_only` under `read_exec` gains exec but nothing writable does, +/// so the grant is inert. Warn, do not refuse — this is the shape +/// `tests/common/mod.rs` builds for most of the suite (`read_exec /usr` with +/// `read_only /usr/share`), and refusing it would fail oring's own tests. +#[test] +fn read_only_under_read_exec_warns_but_does_not_refuse() { + let p = tiers(&["/usr/share"], &["/usr"], &[], &[]); + assert!( + p.check_tier_overlap().is_ok(), + "read_only under read_exec is inert (nothing writable gained exec) — must not refuse" + ); +} + +/// Sibling directories that merely share a prefix are NOT nested. A string +/// prefix compare would call `/tmpfoo` a child of `/tmp`; `Path::starts_with` +/// is component-wise and does not. +#[test] +fn sibling_paths_sharing_a_prefix_are_not_nested() { + let p = tiers(&[], &[], &["/tmp"], &["/tmpfoo"]); + assert!( + p.check_tier_overlap().is_ok(), + "/tmpfoo is not under /tmp; a prefix-string compare would false-positive here" + ); +} + +/// Disjoint tiers, the ordinary case: no overlap, no diagnostics. +#[test] +fn disjoint_tiers_are_clean() { + let p = tiers(&["/etc"], &["/usr/bin"], &["/var/tmp"], &["/work"]); + assert!(p.check_tier_overlap().is_ok()); +} diff --git a/tests/threat_model.rs b/tests/threat_model.rs new file mode 100644 index 0000000..3b2aaab --- /dev/null +++ b/tests/threat_model.rs @@ -0,0 +1,378 @@ +// SPDX-License-Identifier: BSD-3-Clause +//! `THREAT_MODEL.md` cites a vocabulary it does not own, and owns no evidence. +//! +//! It is a decision procedure: it tells a reporter how an unseen behaviour is +//! classified, so it names ids — losses, hazards, feedback defects, `CON-n` rows +//! — that are **defined elsewhere**, and must name nothing else. Both halves are +//! gated here, and both have a measured failure behind them: +//! +//! * **A cited id that no longer resolves.** A decision log and +//! `CONSTRAINTS.md` once spoke two vocabularies for one concept — six mnemonics +//! appearing **zero** times in the register that gated against them, three +//! hours after a rename. A gate reading one side could not see the fork; this +//! one reads both. +//! * **A second copy of the evidence.** `SECURITY.md` grew a threat-model +//! enumeration and a 34-line mitigation subsection already present in §2. Two +//! copies, one gated and one not — and the ungated one rotted, taking two +//! corrections in as many days. A third home is what `THREAT_MODEL.md` is most +//! likely to become, so the shapes that carry evidence elsewhere are refused +//! here. +//! +//! **Does NOT establish** whether the classification procedure is *correct*, +//! whether a rule routes a report to the right place, or whether the prose still +//! matches the tool. It checks that every id resolves, that the file carries no +//! evidence of its own, and that it is reachable from the two files a reporter +//! actually opens. Correctness stays with adversarial review. +//! +//! The derivation parser is a **third** independent implementation of the one in +//! `constraint_register.rs` and `design_residuals.rs`, deliberately: if they ever +//! disagree about what `CONSTRAINTS.md` declares, believe none until you know why. +//! +//! **Failure paths**, each with a red test in `mod red` over mutated `String`s +//! rather than edited files: an unknown mnemonic, an unknown `CON-n`, a +//! `[loss: …]` tag, a register-row shape, an unlinked file from either side, an +//! emptied derivation, an emptied row set. The last two are **could-not-check** +//! rather than pass, because a vacuous derivation makes every cited id resolve. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +const LEVELS: [&str; 3] = ["Losses", "Hazards", "Feedback defects"]; + +/// Shapes that carry EVIDENCE in the two files that own it. None may appear in +/// `THREAT_MODEL.md`: each is the visible signature of a second copy. +const EVIDENCE_SHAPES: [(&str, &str); 4] = [ + ("[loss:", "a docs/DESIGN.md §2 residual tag"), + ("### Residual — ", "a docs/DESIGN.md §2 residual subsection"), + ("- **status:**", "a CONSTRAINTS.md row status field"), + ( + "- **enforced-by:**", + "a CONSTRAINTS.md row enforcement field", + ), +]; + +fn root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn read(rel: &str) -> String { + let p = root().join(rel); + std::fs::read_to_string(&p).unwrap_or_else(|e| { + panic!( + "COULD NOT CHECK: {} is unreadable ({e}). A missing input must not read as a \ + clean file.", + p.display() + ) + }) +} + +/// Derivation ids declared by `CONSTRAINTS.md`, mapped to the level that declares them. +fn declared_ids(register: &str) -> BTreeMap { + let lines: Vec<&str> = register.lines().collect(); + let mut out = BTreeMap::new(); + for level in LEVELS { + let head = format!("### {level}"); + let Some(start) = lines.iter().position(|l| l.starts_with(&head)) else { + continue; + }; + for line in &lines[start + 1..] { + if line.starts_with("### ") || line.starts_with("---") { + break; + } + if let Some(rest) = line.strip_prefix("- `") { + if let Some((id, _)) = rest.split_once('`') { + out.insert(id.to_string(), level.to_string()); + } + } + } + } + out +} + +/// `CON-n` row headings that exist in `CONSTRAINTS.md`. +fn declared_rows(register: &str) -> BTreeSet { + register + .lines() + .filter_map(|l| l.strip_prefix("## ")) + .filter(|l| l.starts_with("CON-")) + .map(|l| { + l.split_whitespace() + .next() + .unwrap_or_default() + .trim_end_matches(|c: char| !c.is_ascii_alphanumeric()) + .to_string() + }) + .collect() +} + +/// Backticked tokens in `text`. The citation form is the only one recognised: a +/// mnemonic written in prose without backticks is invisible here **on purpose**, because +/// a gate that matched bare uppercase words would fire on every emphatic sentence. +fn backticked(text: &str) -> Vec { + let mut out = Vec::new(); + let mut rest = text; + while let Some(open) = rest.find('`') { + let after = &rest[open + 1..]; + let Some(close) = after.find('`') else { break }; + out.push(after[..close].to_string()); + rest = &after[close + 1..]; + } + out +} + +/// A derivation mnemonic: uppercase and hyphens only, three characters or more. Digits +/// and underscores are excluded so `CON-1` and `NO_NEW_PRIVS` are not mistaken for one. +fn is_mnemonic(t: &str) -> bool { + t.len() >= 3 + && t.starts_with(|c: char| c.is_ascii_uppercase()) + && t.chars().all(|c| c.is_ascii_uppercase() || c == '-') +} + +fn is_row_id(t: &str) -> bool { + t.strip_prefix("CON-") + .is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())) +} + +struct Audit { + violations: Vec, + mnemonics: BTreeSet, + rows: BTreeSet, + declared_ids: usize, + declared_rows: usize, +} + +/// Text in, findings out — so the red tests below run in-process against mutated +/// `String`s instead of editing tracked files and re-running the suite from elsewhere. +/// The gate and its mutation tests are the same file, so an anchor that stops matching +/// fails here rather than passing quietly. +fn audit(threat: &str, register: &str, security: &str, readme: &str) -> Audit { + let ids = declared_ids(register); + let rows = declared_rows(register); + assert!( + !ids.is_empty(), + "COULD NOT CHECK: CONSTRAINTS.md yielded no derivation entries. Without them \ + every cited mnemonic would resolve VACUOUSLY and this gate would pass by \ + knowing nothing." + ); + assert!( + !rows.is_empty(), + "COULD NOT CHECK: CONSTRAINTS.md yielded no `## CON-n` rows. Without them every \ + cited row would resolve VACUOUSLY." + ); + + let mut violations = Vec::new(); + let mut cited_mnemonics = BTreeSet::new(); + let mut cited_rows = BTreeSet::new(); + + for tok in backticked(threat) { + if is_mnemonic(&tok) { + cited_mnemonics.insert(tok.clone()); + if !ids.contains_key(&tok) { + violations.push(format!( + "THREAT_MODEL.md cites `{tok}`, which CONSTRAINTS.md's derivation \ + does not declare — the two files have forked a vocabulary" + )); + } + } else if is_row_id(&tok) { + cited_rows.insert(tok.clone()); + if !rows.contains(&tok) { + violations.push(format!( + "THREAT_MODEL.md cites `{tok}`, which is not a row in CONSTRAINTS.md \ + — a retired row leaves a dangling rule here" + )); + } + } + } + + assert!( + !cited_mnemonics.is_empty(), + "COULD NOT CHECK: THREAT_MODEL.md cites no derivation mnemonic in backticks. \ + Either the file stopped naming the vocabulary or the citation form changed; \ + both make this gate vacuous." + ); + + for (shape, what) in EVIDENCE_SHAPES { + if threat.contains(shape) { + violations.push(format!( + "THREAT_MODEL.md contains {shape:?} — {what}. Evidence has one home: \ + residuals in docs/DESIGN.md §2, enforcement in CONSTRAINTS.md. A second \ + copy is what rotted in SECURITY.md." + )); + } + } + + // Reachability. A classification procedure nobody is routed to is a fourth home for + // the same material, not a fix — and SECURITY.md is the file a reporter opens first. + for (name, text) in [("SECURITY.md", security), ("README.md", readme)] { + if !text.contains("THREAT_MODEL.md") { + violations.push(format!( + "{name} does not link THREAT_MODEL.md, so a reporter is never sent to it" + )); + } + } + + Audit { + violations, + mnemonics: cited_mnemonics, + rows: cited_rows, + declared_ids: ids.len(), + declared_rows: rows.len(), + } +} + +fn audit_tree() -> Audit { + audit( + &read("THREAT_MODEL.md"), + &read("CONSTRAINTS.md"), + &read("SECURITY.md"), + &read("README.md"), + ) +} + +/// The could-not-check half, as its own test so the FAILING TEST NAME says which kind of +/// failure it was. +#[test] +fn the_threat_model_and_the_register_are_both_readable() { + let a = audit_tree(); + println!(" derivation entries declared: {}", a.declared_ids); + println!(" CON-n rows declared: {}", a.declared_rows); +} + +#[test] +fn every_id_threat_model_cites_resolves_in_the_register() { + let a = audit_tree(); + + // Print the FRAME, not just the total: a count of violations is checkable only + // against the span that produced it, and the cited sets are that span. + println!(" mnemonics cited: {} {:?}", a.mnemonics.len(), a.mnemonics); + println!(" CON-n rows cited: {} {:?}", a.rows.len(), a.rows); + println!(" violations: {}", a.violations.len()); + for v in &a.violations { + println!(" · {v}"); + } + assert!( + a.violations.is_empty(), + "{} violation(s) — see the lines above", + a.violations.len() + ); +} + +mod red { + use super::{audit, read, Audit}; + + fn tree_with(threat: &str) -> Audit { + audit( + threat, + &read("CONSTRAINTS.md"), + &read("SECURITY.md"), + &read("README.md"), + ) + } + + fn expect(a: &Audit, needle: &str) { + assert!( + a.violations.iter().any(|v| v.contains(needle)), + "expected a violation containing {needle:?}, got {:?}", + a.violations + ); + } + + fn mutate(text: &str, from: &str, to: &str) -> String { + assert!( + text.contains(from), + "STALE ANCHOR: {from:?} is not in the document — this case tested nothing" + ); + text.replacen(from, to, 1) + } + + #[test] + fn a_mnemonic_the_register_does_not_declare_is_caught() { + let m = mutate(&read("THREAT_MODEL.md"), "`BREACH`", "`ESCAPE`"); + expect(&tree_with(&m), "forked a vocabulary"); + } + + #[test] + fn a_row_the_register_does_not_carry_is_caught() { + let m = mutate(&read("THREAT_MODEL.md"), "`CON-1`", "`CON-4`"); + expect(&tree_with(&m), "not a row in CONSTRAINTS.md"); + } + + #[test] + fn a_residual_tag_copied_into_the_threat_model_is_caught() { + let m = format!( + "{}\n- something **[loss: BREACH · disclosure: silent]**\n", + read("THREAT_MODEL.md") + ); + expect(&tree_with(&m), "residual tag"); + } + + #[test] + fn a_register_row_shape_copied_into_the_threat_model_is_caught() { + let m = format!( + "{}\n## CON-99 — invented\n- **status:** `enforced`\n", + read("THREAT_MODEL.md") + ); + expect(&tree_with(&m), "row status field"); + } + + #[test] + fn an_unlinked_threat_model_is_caught_from_either_side() { + let threat = read("THREAT_MODEL.md"); + let security = read("SECURITY.md").replace("THREAT_MODEL.md", "SOMEWHERE_ELSE.md"); + let readme = read("README.md").replace("THREAT_MODEL.md", "SOMEWHERE_ELSE.md"); + let a = audit( + &threat, + &read("CONSTRAINTS.md"), + &security, + &read("README.md"), + ); + expect(&a, "SECURITY.md does not link"); + let b = audit( + &threat, + &read("CONSTRAINTS.md"), + &read("SECURITY.md"), + &readme, + ); + expect(&b, "README.md does not link"); + } + + /// A vacuous derivation would make every cited mnemonic resolve, so it must be a + /// could-not-check rather than a pass. + #[test] + #[should_panic(expected = "COULD NOT CHECK")] + fn an_emptied_derivation_is_a_could_not_check() { + let register = read("CONSTRAINTS.md"); + let m: String = register + .lines() + .filter(|l| !l.starts_with("- `")) + .collect::>() + .join("\n"); + assert_ne!(m, register, "STALE ANCHOR: no derivation entries to remove"); + let _ = audit( + &read("THREAT_MODEL.md"), + &m, + &read("SECURITY.md"), + &read("README.md"), + ); + } + + #[test] + #[should_panic(expected = "COULD NOT CHECK")] + fn an_emptied_row_set_is_a_could_not_check() { + let register = read("CONSTRAINTS.md").replace("\n## CON-", "\n## Ret-CON-"); + let _ = audit( + &read("THREAT_MODEL.md"), + ®ister, + &read("SECURITY.md"), + &read("README.md"), + ); + } + + /// The gate must not pass by finding nothing to check. + #[test] + #[should_panic(expected = "COULD NOT CHECK")] + fn a_threat_model_citing_no_mnemonic_is_a_could_not_check() { + let m = read("THREAT_MODEL.md").replace('`', ""); + let _ = tree_with(&m); + } +} diff --git a/tests/tmpdir_not_from_env.rs b/tests/tmpdir_not_from_env.rs new file mode 100644 index 0000000..c63b081 --- /dev/null +++ b/tests/tmpdir_not_from_env.rs @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: BSD-3-Clause +//! `$TMPDIR` must not widen a profile's tiers (§7). +//! +//! # Specification +//! +//! **Establishes:** a caller-supplied `TMPDIR` in the environment changes +//! neither the granted tiers nor the child's effective scratch; the scratch path +//! comes from `--tmpdir`/`--exec-tmp` only; and oring SETS the child's `TMPDIR` +//! to whatever it actually granted, or unsets it when it granted none. +//! +//! **Does NOT establish:** anything about `--policy` files, which name their own +//! tiers and never consulted `$TMPDIR` in the first place. +//! +//! # The defect these pin +//! +//! `profile.rs` used to push `$TMPDIR` into `write_noexec`, guarded only by +//! "absolute" and "not /tmp". In oring's threat model the CALLER is the +//! adversary, so that let a caller-supplied variable widen a built-in profile. +//! `check_profile_grants` was added to catch it and refuses a tier CONTAINING +//! `$HOME` — but it tests `home.starts_with(tier)`, so narrowing by one +//! component walked past it. **Measured:** `TMPDIR=$HOME/.ssh --profile +//! balanced` sealed and the child got `O_WRONLY` on `~/.ssh/authorized_keys`, +//! which is precisely the escalation that refusal message claims to prevent. +//! `TMPDIR=$HOME/.cargo` additionally broke all three of `balanced`'s own +//! documented invariants — the tree's `profile::` unit tests fail under it while +//! oring sealed happily. +//! +//! No predicate on the path fixes it: `~/.ssh` is an existing, caller-owned, +//! mode-0700 directory, which is exactly the shape `create_scratch_dir` accepts +//! as a GOOD scratch dir. The distinguishing information is intent, and intent +//! is not in the environment. +//! +//! # Why a subprocess and not a unit test +//! +//! `std::env::set_var` is process-global while `cargo test` runs tests on +//! threads, so a unit test that set `TMPDIR` would race every sibling. Spawning +//! the real binary makes the environment per-child and the assertion exact. +#![cfg(target_os = "linux")] +mod common; +use common::*; + +use std::process::Command; + +/// A path that is absolute, not `/tmp`, and unmistakable in output. +const SENTINEL: &str = "/var/tmp/oring-tmpdir-sentinel-must-not-appear"; + +fn explain_with_env(tmpdir: Option<&str>, extra: &[&str]) -> String { + let mut cmd = Command::new(oring_bin()); + cmd.args(["--profile", "balanced"]); + cmd.args(extra); + cmd.arg("--explain"); + match tmpdir { + Some(v) => cmd.env("TMPDIR", v), + None => cmd.env_remove("TMPDIR"), + }; + let out = cmd.output().expect("spawn oring"); + format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) +} + +/// The core property: an inherited `TMPDIR` grants nothing. +#[test] +fn inherited_tmpdir_is_not_granted() { + if !require_or_skip("inherited_tmpdir_is_not_granted") { + return; + } + let out = explain_with_env(Some(SENTINEL), &[]); + assert!( + !out.contains(SENTINEL), + "an inherited TMPDIR must not appear in any tier; got:\n{out}" + ); +} + +/// …and the operator is told, rather than silently having it dropped. A grant +/// that quietly stops happening is the Type-II failure in the other direction. +/// +/// Sealed for real rather than `--explain`ed: the notice belongs to the env +/// wiring, which only the seal path performs. +#[test] +fn inherited_tmpdir_is_reported_not_silently_dropped() { + if !require_or_skip("inherited_tmpdir_is_reported_not_silently_dropped") { + return; + } + let out = Command::new(oring_bin()) + .args(["--profile", "balanced", "--", "/bin/true"]) + .env("TMPDIR", SENTINEL) + .output() + .expect("spawn oring"); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + err.contains("ignoring inherited TMPDIR"), + "dropping an inherited TMPDIR must be announced; got:\n{err}" + ); +} + +/// oring OWNS the child's TMPDIR: it is set to what was actually granted, never +/// inherited. Dropping the grant while still PASSING the caller's value would be +/// a Type-II failure — the child gets a scratch it cannot write, and links fail +/// with a mystery EACCES that trains the operator to loosen policy until it +/// works. +#[test] +fn child_tmpdir_is_the_granted_scratch() { + if !require_or_skip("child_tmpdir_is_the_granted_scratch") { + return; + } + let out = Command::new(oring_bin()) + .args(["--profile", "balanced", "--", "/usr/bin/printenv", "TMPDIR"]) + .env("TMPDIR", SENTINEL) + .output() + .expect("spawn oring"); + let seen = String::from_utf8_lossy(&out.stdout).trim().to_owned(); + assert_eq!( + seen, "/tmp", + "the child must get the GRANTED scratch (/tmp under balanced), not the \ + caller's {SENTINEL}" + ); +} + +/// The replacement path: named on the command line, it IS granted. +#[test] +fn tmpdir_flag_is_granted() { + if !require_or_skip("tmpdir_flag_is_granted") { + return; + } + let dir = tmp_workdir("tmpdirflag"); + let d = dir.to_string_lossy().into_owned(); + let out = explain_with_env(None, &["--tmpdir", &d]); + assert!( + out.contains(&d), + "--tmpdir must appear in the plan; got:\n{out}" + ); +} + +/// `--tmpdir` inherits `--exec-tmp`'s validation, and the case that matters most +/// is a FILE: `TMPDIR=~/.bashrc` used to grant `WriteFile`+`Truncate` on the +/// operator's shell rc — a deferred-execution primitive that was invisible to +/// both the `$HOME` guard and the tier-overlap check, because a single file +/// creates no containment relation with anything. +#[test] +fn tmpdir_flag_refuses_a_non_directory() { + if !require_or_skip("tmpdir_flag_refuses_a_non_directory") { + return; + } + let dir = tmp_workdir("tmpdirfile"); + let f = dir.join("a-file"); + std::fs::write(&f, b"x").expect("write fixture"); + let out = explain_with_env(None, &["--tmpdir", &f.to_string_lossy()]); + assert!( + out.contains("not a directory"), + "--tmpdir must refuse a plain file; got:\n{out}" + ); +} + +/// A symlink would grant its TARGET, and `--explain` would show only the link — +/// so the operator reviewing the cage they asked for could not see what was +/// actually granted. +#[test] +fn tmpdir_flag_refuses_a_symlink() { + if !require_or_skip("tmpdir_flag_refuses_a_symlink") { + return; + } + let dir = tmp_workdir("tmpdirlink"); + let link = dir.join("link"); + let _ = std::fs::remove_file(&link); + std::os::unix::fs::symlink("/usr/local/bin", &link).expect("symlink"); + let out = explain_with_env(None, &["--tmpdir", &link.to_string_lossy()]); + assert!( + out.contains("symlink"), + "--tmpdir must refuse a symlink; got:\n{out}" + ); +} diff --git a/tests/udp_wall.rs b/tests/udp_wall.rs new file mode 100644 index 0000000..6107578 --- /dev/null +++ b/tests/udp_wall.rs @@ -0,0 +1,450 @@ +// SPDX-License-Identifier: BSD-3-Clause +//! Ring 1 mediates UDP egress by port at Landlock **V10** (§2 residual, closed). +//! +//! # Specification +//! +//! **Establishes:** under a sealed child, `egress_udp` behaves like its TCP +//! twin — `None` leaves UDP unrestricted (the §2 residual), `Some([53])` permits +//! `sendto` to :53 and denies :9 with `EACCES`, and `Some([])` denies both. +//! Also pins the **autobind trap**: handling `BindUdp` without granting a port +//! denies ordinary egress that `ConnectSendUdp` alone permits. +//! +//! **Does NOT establish:** anything about a sub-V10 kernel. The refusal path in +//! `landlock::apply` for `egress_udp` on a V9 kernel is NOT exercised here — +//! this box cannot produce a V9 kernel without a reboot, and reading a refusal +//! from a code path is not measuring it. Explicitly untested, not assumed-good. +//! +//! # Why this file exists rather than a matrix row +//! +//! Same reason as `protocol_wall.rs`, one axis over: `containment_matrix` is +//! keyed on `(profile, port)` and every row runs on every host at/above the V9 +//! floor. A UDP row would **refuse to seal** on V9 — by design, since silently +//! dropping a requested UDP handle is an open egress path, not a missing grant +//! — so putting these cells in the matrix would turn it red on the very kernel +//! this box returns to on its next boot. They live here, behind a V10 gate. +//! +//! **The gate is fail-loud, not fail-quiet.** `ORING_REQUIRE_UDP=1` turns the +//! skip into a panic, because a skipped test and a passing test are +//! indistinguishable in cargo's summary (`ok. 0 passed` reads green and SKIP +//! goes to stderr, which cargo discards). Do not read this file as green +//! without that variable set. +//! +//! **Failure direction:** closed. Every cell asserts a specific exit code; a +//! payload that fails to run at all fails the test. +#![cfg(target_os = "linux")] +mod common; +use common::*; + +/// Landlock ABI that first names `LANDLOCK_ACCESS_NET_{BIND,CONNECT_SEND}_UDP`. +/// Read from the binary under test, NOT hardcoded — a literal here could let +/// these cells skip on a box whose oring would have enforced, which is the +/// silent-green failure the whole file is written against (same reasoning as +/// `common::crate_floor`). +const UDP_ABI: i32 = oring::landlock::UDP_ABI; + +/// `sendto` denied by Landlock. +const EACCES_RC: i32 = 13; + +/// True if this kernel can run the UDP cells. Mirrors `require_or_skip`, but at +/// V10 rather than the V9 floor, with its own escape hatch: a box that meets the +/// floor still cannot run these. Keyed on the ABI probe alone — `landlock_abi()` +/// returns 0 when the syscall is absent, so "present" is not a separate axis. +/// **Unreachable-by-design since the floor rose to V10 on 2026-07-29, and kept anyway.** +/// +/// `UDP_ABI == FLOOR_ABI` now, and `src/landlock.rs` pins that with +/// `const _: () = assert!(UDP_ABI <= FLOOR_ABI)`. So on any kernel oring will seal on, this +/// returns `true` immediately — the skip and the `ORING_REQUIRE_UDP` panic below are both +/// dead paths, because a sub-floor kernel is refused before a test ever runs. +/// +/// Kept rather than deleted for one reason: it is the tripwire if the floor ever drops. If +/// someone lowers `FLOOR_ABI`, the compile-time assert fires first — and if that assert were +/// also removed, this helper starts skipping again and says why in its message, instead of +/// the UDP cells quietly counting as passed. Deleting it would remove the second layer. +/// +/// It also resolves half of `BUILD-QUEUE.md` T7 by product decision rather than by +/// argument: `ORING_REQUIRE_UDP` was the variable that could not be set in `make check` +/// because it would fail the gate on 7.1. There is no supported 7.1 any more. +fn require_v10_or_skip(test: &str) -> bool { + let abi = landlock_abi(); + if abi >= UDP_ABI { + return true; + } + if std::env::var_os("ORING_REQUIRE_UDP").is_some() { + panic!("{test}: needs Landlock V{UDP_ABI} (abi=V{abi})"); + } + eprintln!("SKIP: {test}: needs Landlock V{UDP_ABI} (abi=V{abi})"); + false +} + +/// A policy with the UDP fields set. Carried here rather than threaded through +/// `common::policy_json`, whose signature every other test file shares — the +/// same containment reasoning `protocol_wall.rs` uses for its payload. +fn udp_policy(egress_udp: Option<&[u16]>, ingress_udp: Option<&[u16]>) -> String { + fn field(ports: Option<&[u16]>) -> String { + match ports { + Some(p) => format!( + "[{}]", + p.iter().map(u16::to_string).collect::>().join(",") + ), + None => "null".into(), + } + } + let re = format!(r#"[{:?},"/usr","/lib","/lib64","/bin","/opt"]"#, bindir()); + format!( + r#"{{"version":1,"landlock":{{ + "read_exec":{re}, + "read_only":["/etc","/usr/share","/proc"], + "write_noexec":["/tmp","/dev/null","/dev/zero","/dev/urandom"], + "write_exec":[], + "egress_tcp":[443], + "ingress_tcp":[], + "egress_udp":{}, + "ingress_udp":{}, + "scope_ipc":true + }},"seccomp":{{"profile":"balanced"}}}}"#, + field(egress_udp), + field(ingress_udp) + ) +} + +fn udp_rc(policy: &str, target: &str) -> i32 { + let (code, sig) = run_probe(policy, "udp-send", target); + assert!( + sig.is_none(), + "udp-send to {target} died on signal {sig:?}, not an exit" + ); + code.unwrap_or_else(|| panic!("udp-send to {target} produced no exit code")) +} + +/// Seal a no-op child and return oring's stderr, for the disclosure cells. +fn seal_stderr(policy: &str) -> String { + let out = std::process::Command::new(oring_bin()) + .env("ORING_POLICY", policy) + .env_remove("ORING_QUIET") + .arg("--") + .arg("/bin/true") + .output() + .expect("spawn oring"); + String::from_utf8_lossy(&out.stderr).into_owned() +} + +/// **The disclosure regression this whole file exists downstream of.** +/// +/// A policy that restricts TCP and says nothing about UDP must SAY SO — both in +/// the machine-readable `unhandled=` field and as a warning. The bug being +/// pinned: while the disclosure was keyed on `abi > CRATE_MAX_ABI`, raising the +/// crate cap to V10 made this exact policy seal SILENTLY with UDP wide open. The +/// assertion is on the derived gap, so it stays true when the cap next moves. +#[test] +fn partial_net_restriction_is_disclosed_by_name() { + if !require_v10_or_skip("partial_net_restriction_is_disclosed_by_name") { + return; + } + let err = seal_stderr(&udp_policy(None, None)); + assert!( + err.contains("unhandled=net.BindUdp,net.ConnectSendUdp"), + "seal line must NAME the open UDP rights; got:\n{err}" + ); + assert!( + err.contains("WARN net is PARTIALLY restricted"), + "a partially-restricted net class must WARN; got:\n{err}" + ); +} + +/// The converse, and the reason the warning is survivable: a policy that handles +/// every net right the kernel exposes reports `unhandled=-` and warns about +/// NOTHING. A disclosure that fires even when the gap is closed would train +/// operators to ignore it, which is the failure mode it exists to prevent. +/// +/// **`ingress_udp: [0]`, not `[]` — this is the canonical correct policy.** Port +/// 0 is how the kernel represents an autobind, so granting it permits the +/// ephemeral source port that every outbound datagram needs. The earlier version +/// of this test used `Some(&[])`, which also reports `unhandled=-` but silently +/// denies the very port `egress_udp` grants (see the autobind cell below). A +/// worked example in a test suite gets copied; this one has to be the right one. +#[test] +fn full_net_coverage_is_silent() { + if !require_v10_or_skip("full_net_coverage_is_silent") { + return; + } + let err = seal_stderr(&udp_policy(Some(&[53]), Some(&[0]))); + assert!( + err.contains("unhandled=-"), + "full coverage must report an empty gap; got:\n{err}" + ); + assert!( + !err.contains("PARTIALLY restricted"), + "full coverage must not warn; got:\n{err}" + ); +} + +/// With no `egress_udp`, UDP egress is unrestricted — the §2 residual, live for a +/// HAND-AUTHORED policy that restricts TCP and omits UDP. Scope matters since the +/// profiles closed: this is a statement about the field being `None`, not about +/// anything oring ships, and `unhandled=` is what discloses it. +/// +/// This is also the CONTROL: if it ever starts denying, the cells below prove +/// nothing about `egress_udp`, because something else on the host is denying. +#[test] +fn without_egress_udp_the_residual_holds() { + if !require_v10_or_skip("without_egress_udp_the_residual_holds") { + return; + } + let p = udp_policy(None, None); + assert_eq!( + udp_rc(&p, "127.0.0.1:53"), + 0, + "UDP :53 must be unrestricted with egress_udp unset" + ); + assert_eq!( + udp_rc(&p, "127.0.0.1:9"), + 0, + "UDP :9 must be unrestricted with egress_udp unset" + ); +} + +/// `egress_udp: [53]` permits :53 and denies :9 — per-port, through oring's own +/// policy plumbing rather than a standalone `landlock_create_ruleset` probe. +#[test] +fn egress_udp_enforces_per_port() { + if !require_v10_or_skip("egress_udp_enforces_per_port") { + return; + } + let p = udp_policy(Some(&[53]), None); + assert_eq!(udp_rc(&p, "127.0.0.1:53"), 0, "granted UDP port must send"); + assert_eq!( + udp_rc(&p, "127.0.0.1:9"), + EACCES_RC, + "ungranted UDP port must EACCES" + ); +} + +/// `egress_udp: []` is an explicit deny-all, matching the `Some([])` convention +/// the TCP fields already use. A deny-all that silently became allow-all is the +/// failure this cell exists to catch. +#[test] +fn empty_egress_udp_denies_all() { + if !require_v10_or_skip("empty_egress_udp_denies_all") { + return; + } + let p = udp_policy(Some(&[]), None); + assert_eq!( + udp_rc(&p, "127.0.0.1:53"), + EACCES_RC, + "deny-all must deny :53" + ); + assert_eq!( + udp_rc(&p, "127.0.0.1:9"), + EACCES_RC, + "deny-all must deny :9" + ); +} + +/// **The autobind trap.** A policy reading "allow DNS out, allow no inbound UDP" +/// denies DNS: `sendto` on an unbound socket acquires a source port via +/// autobind, so handling `BindUdp` with no granted port denies the bind, and the +/// send with it. The grant the operator wrote is not the grant they got. +/// +/// Pinned as a TEST rather than a doc note because it is invisible in the seal +/// line — `net=tcp+udp-ports` renders identically with and without the trap. +#[test] +fn ingress_udp_denyall_also_kills_egress_via_autobind() { + if !require_v10_or_skip("ingress_udp_denyall_also_kills_egress_via_autobind") { + return; + } + let granted = udp_policy(Some(&[53]), None); + let with_bind_wall = udp_policy(Some(&[53]), Some(&[])); + assert_eq!( + udp_rc(&granted, "127.0.0.1:53"), + 0, + "control: :53 sends without a bind wall" + ); + assert_eq!( + udp_rc(&with_bind_wall, "127.0.0.1:53"), + EACCES_RC, + "adding ingress_udp:[] must deny the SAME send — autobind needs BindUdp" + ); +} + +/// The ESCAPE from the trap above, and the reason it is a warning rather than a +/// refusal: granting **port 0** — the kernel's representation of an autobind — +/// restores egress while keeping every real inbound port walled. This is the +/// policy operators should copy, so it is asserted rather than described. +#[test] +fn ingress_udp_port_zero_permits_autobind() { + if !require_v10_or_skip("ingress_udp_port_zero_permits_autobind") { + return; + } + let p = udp_policy(Some(&[53]), Some(&[0])); + assert_eq!( + udp_rc(&p, "127.0.0.1:53"), + 0, + "port 0 must permit the autobind, so :53 sends" + ); + assert_eq!( + udp_rc(&p, "127.0.0.1:9"), + EACCES_RC, + "granting port 0 must NOT widen egress — :9 stays denied" + ); + let err = seal_stderr(&p); + assert!( + err.contains("unhandled=-"), + "full coverage expected; got:\n{err}" + ); + assert!( + !err.contains("autobind"), + "the correct idiom must not trip the autobind warning; got:\n{err}" + ); +} + +/// The self-defeating combination must WARN, because `unhandled=` cannot show +/// it: both rights ARE handled, so the gap is empty while the egress grant is +/// inert. +#[test] +fn inert_egress_grant_warns_about_autobind() { + if !require_v10_or_skip("inert_egress_grant_warns_about_autobind") { + return; + } + let err = seal_stderr(&udp_policy(Some(&[53]), Some(&[]))); + assert!( + err.contains("autobind"), + "a non-empty egress_udp beside an ingress_udp without port 0 must warn; got:\n{err}" + ); +} + +/// **`ORING_QUIET=1` must not be able to suppress under-coverage.** `quiet` is +/// read from the ENVIRONMENT, so the party oring treats as the adversary can set +/// it; combined with the deliberate silence on a fully-unhandled class (`net=off` +/// on the seal line was supposed to carry that information), it produced a run +/// with four net rights open and literally empty stderr. The status report is +/// suppressible; the disclosure is not. +#[test] +fn quiet_cannot_suppress_the_unhandled_disclosure() { + if !require_v10_or_skip("quiet_cannot_suppress_the_unhandled_disclosure") { + return; + } + // A policy naming NO net fields — the case that produced empty stderr. + let policy = udp_policy(None, None).replace(r#""egress_tcp":[443]"#, r#""egress_tcp":null"#); + let out = std::process::Command::new(oring_bin()) + .env("ORING_POLICY", &policy) + .env("ORING_QUIET", "1") + .arg("--") + .arg("/bin/true") + .output() + .expect("spawn oring"); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + !err.contains("SEALED"), + "ORING_QUIET must still suppress the seal line; got:\n{err}" + ); + assert!( + err.contains("UNRESTRICTED") && err.contains("net.ConnectTcp"), + "under quiet, unrestricted rights must STILL be named; got:\n{err}" + ); +} + +// --------------------------------------------------------------------------- +// `--require-full-coverage` — the "refused before execve" limb of CON-5. +// +// WHY THE FLAG EXISTS. `unhandled=` is the only field reporting what a run left +// UNRESTRICTED, and it is a stderr line written with `let _ = writeln!`. Measured +// 2026-07-29: with fd 2 piped the line is present; with `2>/dev/null` ZERO +// disclosure lines appear and the seal proceeds. The write to `/dev/null` +// SUCCEEDS, so no error check inside oring can detect that case, and there is no +// second channel — the audit transport `denials.rs` reads is a *consumer* of +// kernel-emitted Landlock records, a producer needs `CAP_AUDIT_WRITE` an +// unprivileged tool lacks, and `/dev/log` would be grantable only by the very +// policy under audit because the disclosure is emitted after the seal. +// +// So an operator who needs coverage ESTABLISHED rather than REPORTED cannot get it +// from the disclosure at any redirection. This flag converts the gap into an EXIT +// CODE, which no redirect can suppress. `quiet_cannot_suppress_the_unhandled_disclosure` +// above pins the other suppression route. +// +// OPT-IN, and not a default — but NOT for the reason this comment used to give. It said +// the shipped `balanced` profile left `net.BindUdp,net.ConnectSendUdp` unhandled so +// defaulting the flag would refuse every default run. At FLOOR_ABI 10 the profiles handle +// both UDP rights, and measured 2026-07-29 the flag exits 0 on `balanced` AND `strict`. +// The surviving reason concerns hand-authored `--policy` files, not oring's defaults; see +// `Policy::require_full_coverage`. The cells below use hand-authored policies precisely so +// they exercise the flag rather than the shipped profiles, and are unaffected either way. + +/// A right left unhandled must REFUSE under the flag, before `execve`, naming the rights. +#[test] +fn require_full_coverage_refuses_when_a_right_is_unhandled() { + if !require_v10_or_skip("require_full_coverage_refuses_when_a_right_is_unhandled") { + return; + } + // egress_udp absent beside a present egress_tcp — the omission shape. + let policy = udp_policy(None, None); + let out = std::process::Command::new(oring_bin()) + .env("ORING_POLICY", &policy) + .args(["--require-full-coverage", "--", "/bin/true"]) + .output() + .expect("spawn oring"); + assert_eq!( + out.status.code(), + Some(120), + "under-coverage must refuse with 120 (confinement not established), not seal" + ); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + err.contains("coverage-incomplete") && err.contains("net."), + "the refusal must NAME the unhandled rights, not just fail; got:\n{err}" + ); +} + +/// The flag is not decorative: a policy that handles every right still seals. +/// +/// This is the arm that keeps the flag honest. A gate that can only ever refuse is +/// worse than no gate, and at V10 that requires naming BOTH UDP directions — +/// `ingress_udp` must include port 0 or kernel autobind of the source port is denied. +#[test] +fn require_full_coverage_seals_when_every_right_is_handled() { + if !require_v10_or_skip("require_full_coverage_seals_when_every_right_is_handled") { + return; + } + let policy = udp_policy(Some(&[53]), Some(&[0])); + let out = std::process::Command::new(oring_bin()) + .env("ORING_POLICY", &policy) + .args(["--require-full-coverage", "--", "/bin/true"]) + .output() + .expect("spawn oring"); + let err = String::from_utf8_lossy(&out.stderr); + assert_eq!( + out.status.code(), + Some(0), + "a fully-handled policy must SEAL under the flag — otherwise the flag can never \ + be satisfied and is decorative; stderr:\n{err}" + ); + assert!( + err.contains("unhandled=-"), + "and it must report full coverage on the seal line; got:\n{err}" + ); +} + +/// **The property the flag exists for: the verdict survives a discarded stderr.** +/// +/// `2>/dev/null` removes the disclosure entirely — measured. It cannot remove an exit +/// code. If this test ever fails, the refusal has been moved onto the suppressible +/// channel and CON-5's first limb is gone with it. +#[test] +fn the_coverage_refusal_survives_stderr_being_discarded() { + if !require_v10_or_skip("the_coverage_refusal_survives_stderr_being_discarded") { + return; + } + let policy = udp_policy(None, None); + let status = std::process::Command::new(oring_bin()) + .env("ORING_POLICY", &policy) + .args(["--require-full-coverage", "--", "/bin/true"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) // the exact suppression that defeats the disclosure + .status() + .expect("spawn oring"); + assert_eq!( + status.code(), + Some(120), + "the refusal must be an exit code, not a message: with stderr discarded the \ + disclosure is gone and only the code remains" + ); +} diff --git a/third_party/rust-landlock/.github/workflows/pages.yml b/third_party/rust-landlock/.github/workflows/pages.yml deleted file mode 100644 index d43cf7f..0000000 --- a/third_party/rust-landlock/.github/workflows/pages.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: GitHub Pages - -on: - push: - branches: [ main ] - - workflow_dispatch: - -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - build: - if: github.repository == 'landlock-lsm/rust-landlock' - runs-on: ubuntu-24.04 - - env: - CARGO_TERM_COLOR: always - - permissions: - contents: read - id-token: write - - steps: - - uses: actions/checkout@v3 - - - name: Install Rust stable - run: | - rm ~/.cargo/bin/{cargo-fmt,rustfmt} || : - rustup default stable - rustup update - - - name: Build documentation - run: rustup run stable cargo doc --no-deps - - - name: Add index - run: | - echo '' > target/doc/index.html - - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - path: target/doc - - deploy: - needs: build - runs-on: ubuntu-24.04 - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - - permissions: - pages: write - id-token: write - - steps: - - name: Deploy to GitHub Pages - uses: actions/deploy-pages@v4 diff --git a/third_party/rust-landlock/.github/workflows/rust.yml b/third_party/rust-landlock/.github/workflows/rust.yml deleted file mode 100644 index ba93a13..0000000 --- a/third_party/rust-landlock/.github/workflows/rust.yml +++ /dev/null @@ -1,247 +0,0 @@ -name: Rust checks - -permissions: {} - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -env: - CARGO_TERM_COLOR: always - RUSTDOCFLAGS: -D warnings - RUSTFLAGS: -D warnings - LANDLOCK_TEST_TOOLS_COMMIT: fad769c39b42183fb2a2e1263fe00dfa5b9f2bda - -# Ubuntu versions: https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources - -jobs: - commit_list: - runs-on: ubuntu-24.04 - steps: - - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Get commit list (push) - id: get_commit_list_push - if: ${{ github.event_name == 'push' }} - run: | - echo "id0=$GITHUB_SHA" > $GITHUB_OUTPUT - echo "List of tested commits:" > $GITHUB_STEP_SUMMARY - sed -n 's,^id[0-9]\+=\(.*\),- https://github.com/landlock-lsm/rust-landlock/commit/\1,p' -- $GITHUB_OUTPUT >> $GITHUB_STEP_SUMMARY - - - name: Get commit list (PR) - id: get_commit_list_pr - if: ${{ github.event_name == 'pull_request' }} - run: | - git rev-list --reverse refs/remotes/origin/${{ github.base_ref }}..${{ github.event.pull_request.head.sha }} | awk '{ print "id" NR "=" $1 }' > $GITHUB_OUTPUT - git diff --quiet ${{ github.event.pull_request.head.sha }} ${{ github.sha }} || echo "id0=$GITHUB_SHA" >> $GITHUB_OUTPUT - echo "List of tested commits:" > $GITHUB_STEP_SUMMARY - sed -n 's,^id[0-9]\+=\(.*\),- https://github.com/landlock-lsm/rust-landlock/commit/\1,p' -- $GITHUB_OUTPUT >> $GITHUB_STEP_SUMMARY - - outputs: - commits: ${{ toJSON(steps.*.outputs.*) }} - - kernel_list: - runs-on: ubuntu-24.04 - outputs: - kernels: ${{ toJSON(steps.id.outputs.*) }} - steps: - - - name: Identify latest Linux versions - id: id - run: | - echo "List of tested kernels:" > $GITHUB_STEP_SUMMARY - abi=0 - for version in 5.10 5.15 6.1 6.4 6.7 6.10 6.12 6.15 7.0; do - commit="$(git ls-remote https://github.com/landlock-lsm/linux refs/heads/linux-${version}.y | awk 'NR == 1 { print $1 }')" - if [[ -z "${commit}" ]]; then - echo "ERROR: Failed to fetch Linux ${version}" >&2 - exit 1 - fi - echo "kernel_${abi}={ \"version\":\"${version}\", \"abi\":\"${abi}\", \"commit\":\"${commit}\" }" >> $GITHUB_OUTPUT - echo "- Linux ${version}.y with Landlock ABI ${abi}: https://github.com/landlock-lsm/linux/commit/${commit}" >> $GITHUB_STEP_SUMMARY - let abi++ || : - done - - get_kernels: - runs-on: ubuntu-24.04 - needs: kernel_list - strategy: - fail-fast: false - matrix: - kernel: ${{ fromJSON(needs.kernel_list.outputs.kernels) }} - steps: - - - name: Cache Linux ${{ fromJSON(matrix.kernel).version}}.y - id: cache_linux - uses: actions/cache@v5 - with: - path: linux-${{ fromJSON(matrix.kernel).version }} - key: linux-${{ fromJSON(matrix.kernel).commit }}-${{ env.LANDLOCK_TEST_TOOLS_COMMIT }} - - - name: Clone Landlock test tools - if: steps.cache_linux.outputs.cache-hit != 'true' - uses: actions/checkout@v5 - with: - repository: landlock-lsm/landlock-test-tools - ref: ${{ env.LANDLOCK_TEST_TOOLS_COMMIT }} - path: landlock-test-tools - - - name: Clone Linux ${{ fromJSON(matrix.kernel).version}}.y - if: steps.cache_linux.outputs.cache-hit != 'true' - uses: actions/checkout@v5 - with: - repository: landlock-lsm/linux - ref: ${{ fromJSON(matrix.kernel).commit }} - path: linux - - - name: Build Linux ${{ fromJSON(matrix.kernel).version}}.y - if: steps.cache_linux.outputs.cache-hit != 'true' - working-directory: linux - run: | - O=. ../landlock-test-tools/check-linux.sh build_light - mv linux ../linux-${{ fromJSON(matrix.kernel).version}} - - ubuntu_24_rust_msrv: - runs-on: ubuntu-24.04 - needs: commit_list - strategy: - fail-fast: false - matrix: - commit: ${{ fromJSON(needs.commit_list.outputs.commits) }} - env: - LANDLOCK_CRATE_TEST_ABI: 7 - steps: - - - uses: actions/checkout@v5 - with: - ref: ${{ matrix.commit }} - - - name: Clone Landlock test tools - uses: actions/checkout@v5 - with: - repository: landlock-lsm/landlock-test-tools - ref: ${{ env.LANDLOCK_TEST_TOOLS_COMMIT }} - path: landlock-test-tools - - - name: Get MSRV - run: sed -n 's/^rust-version = "\([0-9.]\+\)"$/RUST_TOOLCHAIN=\1/p' Cargo.toml >> $GITHUB_ENV - - - name: Install 32-bit development libraries - run: | - sudo apt update - sudo apt install gcc-multilib libc6-dev-i386 - - - name: Install Rust MSRV - run: | - rm ~/.cargo/bin/{cargo-fmt,rustfmt} || : - rustup self update - rustup default ${{ env.RUST_TOOLCHAIN }} - rustup update ${{ env.RUST_TOOLCHAIN }} - rustup target add i686-unknown-linux-gnu - rustup target add x86_64-unknown-linux-gnu - - - name: Build (x86_64) - run: rustup run ${{ env.RUST_TOOLCHAIN }} cargo build --target x86_64-unknown-linux-gnu --verbose - - - name: Build (x86) - run: rustup run ${{ env.RUST_TOOLCHAIN }} cargo build --target i686-unknown-linux-gnu --verbose - - - name: Run tests against the local kernel (Landlock ABI ${{ env.LANDLOCK_CRATE_TEST_ABI }} on x86_64) - run: rustup run ${{ env.RUST_TOOLCHAIN }} cargo test --target x86_64-unknown-linux-gnu --verbose - - - name: Run tests against the local kernel (Landlock ABI ${{ env.LANDLOCK_CRATE_TEST_ABI }} on x86) - run: rustup run ${{ env.RUST_TOOLCHAIN }} cargo test --target i686-unknown-linux-gnu --verbose - - - name: Run tests against Linux 6.1 - run: CARGO="rustup run ${{ env.RUST_TOOLCHAIN }} cargo" ./landlock-test-tools/test-rust.sh linux-6.1 2 - - ubuntu_22_rust_stable: - runs-on: ubuntu-22.04 - needs: commit_list - strategy: - fail-fast: false - matrix: - commit: ${{ fromJSON(needs.commit_list.outputs.commits) }} - env: - LANDLOCK_CRATE_TEST_ABI: 4 - steps: - - - name: Install Rust stable - run: | - rm ~/.cargo/bin/{cargo-fmt,rustfmt} || : - rustup self update - rustup default stable - rustup component add rustfmt clippy - rustup update - - - uses: actions/checkout@v5 - with: - ref: ${{ matrix.commit }} - - - name: Build - run: rustup run stable cargo build --verbose - - - name: Run tests against the local kernel (Landlock ABI ${{ env.LANDLOCK_CRATE_TEST_ABI }}) - run: rustup run stable cargo test --verbose - - - name: Check format - run: rustup run stable cargo fmt --all -- --check - - - name: Check source with Clippy - run: rustup run stable cargo clippy -- --deny warnings - - - name: Check tests with Clippy - run: rustup run stable cargo clippy --tests -- --deny warnings - - - name: Check documentation - run: rustup run stable cargo doc --no-deps - - ubuntu_24_rust_stable: - runs-on: ubuntu-24.04 - needs: [commit_list, kernel_list, get_kernels] - strategy: - fail-fast: false - matrix: - commit: ${{ fromJSON(needs.commit_list.outputs.commits) }} - kernel: ${{ fromJSON(needs.kernel_list.outputs.kernels) }} - env: - LANDLOCK_CRATE_TEST_ABI: 4 - # $CARGO is used by landlock-test-tools/test-rust.sh - CARGO: rustup run stable cargo - steps: - - - name: Install Rust stable - run: | - rm ~/.cargo/bin/{cargo-fmt,rustfmt} || : - rustup self update - rustup default stable - rustup update - - - name: Clone Landlock test tools - uses: actions/checkout@v5 - with: - repository: landlock-lsm/landlock-test-tools - ref: ${{ env.LANDLOCK_TEST_TOOLS_COMMIT }} - path: landlock-test-tools - - - name: Clone rust-landlock - uses: actions/checkout@v5 - with: - ref: ${{ matrix.commit }} - path: rust-landlock - - - name: Get Linux ${{ fromJSON(matrix.kernel).version}}.y - uses: actions/cache/restore@v5 - with: - path: linux-${{ fromJSON(matrix.kernel).version }} - key: linux-${{ fromJSON(matrix.kernel).commit }}-${{ env.LANDLOCK_TEST_TOOLS_COMMIT }} - fail-on-cache-miss: true - - - name: Run tests against Linux ${{ fromJSON(matrix.kernel).version }}.y - working-directory: rust-landlock - run: ../landlock-test-tools/test-rust.sh ../linux-${{ fromJSON(matrix.kernel).version }} ${{ fromJSON(matrix.kernel).abi }} diff --git a/third_party/rust-landlock/.gitignore b/third_party/rust-landlock/.gitignore deleted file mode 100644 index 1b72444..0000000 --- a/third_party/rust-landlock/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/Cargo.lock -/target diff --git a/third_party/rust-landlock/CHANGELOG.md b/third_party/rust-landlock/CHANGELOG.md deleted file mode 100644 index bd3b3b7..0000000 --- a/third_party/rust-landlock/CHANGELOG.md +++ /dev/null @@ -1,215 +0,0 @@ -# Landlock changelog - -## [v0.4.5](https://github.com/landlock-lsm/rust-landlock/releases/tag/v0.4.5) - -### New API - -- Added support for [Landlock ABI 7](https://landlock.io/rust-landlock/landlock/enum.ABI.html#variant.V7): - control audit logging behavior with - [`log_same_exec()`](https://landlock.io/rust-landlock/landlock/trait.RulesetCreatedAttr.html#method.log_same_exec), - [`log_new_exec()`](https://landlock.io/rust-landlock/landlock/trait.RulesetCreatedAttr.html#method.log_new_exec) - (domain-specific), and - [`log_subdomains()`](https://landlock.io/rust-landlock/landlock/trait.RestrictSelfAttr.html#method.log_subdomains) - (shared between - [`RulesetCreated`](https://landlock.io/rust-landlock/landlock/struct.RulesetCreated.html) and - [`RestrictSelf`](https://landlock.io/rust-landlock/landlock/struct.RestrictSelf.html)) - ([PR #120](https://github.com/landlock-lsm/rust-landlock/pull/120)). -- Added [`RestrictSelf`](https://landlock.io/rust-landlock/landlock/struct.RestrictSelf.html) - builder for calling `landlock_restrict_self()` without creating a - Landlock domain (e.g., muting subdomain audit logs). -- Extended - [`RestrictionStatus`](https://landlock.io/rust-landlock/landlock/struct.RestrictionStatus.html) - with three new public fields (`log_same_exec`, `log_new_exec`, - `log_subdomains`) reporting the effective audit-logging flag state after - [`restrict_self()`](https://landlock.io/rust-landlock/landlock/struct.RulesetCreated.html#method.restrict_self) - and [`apply()`](https://landlock.io/rust-landlock/landlock/struct.RestrictSelf.html#method.apply). -- Added [`Erratum`](https://landlock.io/rust-landlock/landlock/enum.Erratum.html) - bitflags enum and - [`Erratum::current()`](https://landlock.io/rust-landlock/landlock/enum.Erratum.html#method.current) - for querying fixed kernel bugs before building a ruleset - ([PR #119](https://github.com/landlock-lsm/rust-landlock/pull/119)). - -### Deprecated API - -Deprecate the [`set_no_new_privs()`](https://landlock.io/rust-landlock/landlock/trait.RulesetCreatedAttr.html#method.set_no_new_privs) -method and replace it with -[`no_new_privs()`](https://landlock.io/rust-landlock/landlock/trait.RulesetCreatedAttr.html#method.no_new_privs) -([PR #122](https://github.com/landlock-lsm/rust-landlock/pull/122)). - -### Dependencies - -- Bumped MSRV to Rust 1.71. - -### Testing - -- Extended CI to test against Linux 6.15. -- Added errata tests verifying the `From` mapping matches CI kernel errata. -- Added `SoftRequirement` test coverage for `scope()` and restrict\_self flags. -- Added `try_compat_binary()` unit tests for the binary compat dispatch. - -### Example - -- Synced the sandboxer example with the kernel's `sandboxer.c`: - added `LL_FORCE_LOG` environment variable for audit logging - ([PR #120](https://github.com/landlock-lsm/rust-landlock/pull/120)). - -## [v0.4.4](https://github.com/landlock-lsm/rust-landlock/releases/tag/v0.4.4) - -### New API - -- Added support for all architectures ([PR #111](https://github.com/landlock-lsm/rust-landlock/pull/111)). -- Added `LandlockStatus` type to query the running kernel and display information about available Landlock features ([PR #103](https://github.com/landlock-lsm/rust-landlock/pull/103) and [PR #113](https://github.com/landlock-lsm/rust-landlock/pull/113)). - -### Dependencies - -- Bumped MSRV to Rust 1.68 ([PR #112](https://github.com/landlock-lsm/rust-landlock/pull/112)). - -### Testing - -- Extended CI to build and test on i686 architecture ([PR #111](https://github.com/landlock-lsm/rust-landlock/pull/111)). - -### Example - -- Enhanced sandboxer example to print helpful hints about Landlock status ([PR #103](https://github.com/landlock-lsm/rust-landlock/pull/103)). - -## [v0.4.3](https://github.com/landlock-lsm/rust-landlock/releases/tag/v0.4.3) - -### New API - -- Implemented common traits (e.g., `Debug`) for public types ([PR #108](https://github.com/landlock-lsm/rust-landlock/pull/108)). - -### Documentation - -- Extended [CONTRIBUTING.md](CONTRIBUTING.md) documentation with additional testing and development guidelines ([PR #95](https://github.com/landlock-lsm/rust-landlock/pull/95)). -- Added more background information to [`path_beneath_rules()`](https://landlock.io/rust-landlock/landlock/fn.path_beneath_rules.html) documentation ([PR #94](https://github.com/landlock-lsm/rust-landlock/pull/94)). - -### Testing - -- Added test case for `AccessFs::from_file()` method ([PR #92](https://github.com/landlock-lsm/rust-landlock/pull/92)). - -## [v0.4.2](https://github.com/landlock-lsm/rust-landlock/releases/tag/v0.4.2) - -### New API - -- Added support for Landlock ABI 6: control abstract UNIX sockets and signal scoping with the new [`Ruleset::scope()`](https://landlock.io/rust-landlock/landlock/struct.Ruleset.html#method.scope) method taking a [`Scope`](https://landlock.io/rust-landlock/landlock/enum.Scope.html) enum ([PR #96](https://github.com/landlock-lsm/rust-landlock/pull/96) and [PR #98](https://github.com/landlock-lsm/rust-landlock/pull/98)). -- Added `From` implementation for `Option` ([PR #104](https://github.com/landlock-lsm/rust-landlock/pull/104)) -- Introduced a new [`HandledAccess`](https://landlock.io/rust-landlock/landlock/trait.HandledAccess.html) trait specific to `AccessFs` and `AccessNet` (commit [554217dda0b7](https://github.com/landlock-lsm/rust-landlock/commit/554217dda0b775756e38db71f471dd414b199234)). -- Added a new [`Errno`](https://landlock.io/rust-landlock/landlock/struct.Errno.html) type to improve FFI support ([PR #86](https://github.com/landlock-lsm/rust-landlock/pull/86) and [PR #102](https://github.com/landlock-lsm/rust-landlock/pull/102)). -- Exposed `From` implementation for [`ABI`](https://landlock.io/rust-landlock/landlock/enum.ABI.html) ([PR #88](https://github.com/landlock-lsm/rust-landlock/pull/88)). - -### Documentation - -- Added clarifying notes about `AccessFs::WriteFile` behavior and `path_beneath_rules` usage ([PR #80](https://github.com/landlock-lsm/rust-landlock/pull/80)). -- Introduced [CONTRIBUTING.md](CONTRIBUTING.md) with testing workflow explanations ([PR #76](https://github.com/landlock-lsm/rust-landlock/pull/76)). - -### Testing - -- Enhanced test coverage for new API and added testing against Linux 6.12 ([PR #96](https://github.com/landlock-lsm/rust-landlock/pull/96)). -- Updated CI configuration to use the latest Ubuntu versions ([PR #87](https://github.com/landlock-lsm/rust-landlock/pull/87) and [PR #97](https://github.com/landlock-lsm/rust-landlock/pull/97)). -- Modified default `LANDLOCK_CRATE_TEST_ABI` to match the current kernel for more convenient local testing ([PR #76](https://github.com/landlock-lsm/rust-landlock/pull/76)). - -### Example - -- Synchronized the sandboxer example with the C version ([PR #101](https://github.com/landlock-lsm/rust-landlock/pull/101)): improved error handling for inaccessible file paths and enhanced help documentation. - -## [v0.4.1](https://github.com/landlock-lsm/rust-landlock/releases/tag/v0.4.1) - -### New API - -Add support for Landlock ABI 5: control IOCTL commands on character and block devices with the new [`AccessFs::IoctlDev`](https://landlock.io/rust-landlock/landlock/enum.AccessFs.html#variant.IoctlDev) right ([PR #74](https://github.com/landlock-lsm/rust-landlock/pull/74)). - -### Testing - -Improved the CI to better test against different kernel versions ([PR #72](https://github.com/landlock-lsm/rust-landlock/pull/72)). - - -## [v0.4.0](https://github.com/landlock-lsm/rust-landlock/releases/tag/v0.4.0) - -### New API - -Add support for Landlock ABI 4: control TCP binding and connection according to specified network ports. -This is now possible with the [`AccessNet`](https://landlock.io/rust-landlock/landlock/enum.AccessNet.html) rights and -the [`NetPort`](https://landlock.io/rust-landlock/landlock/struct.NetPort.html) rule -([PR #55](https://github.com/landlock-lsm/rust-landlock/pull/55)). - -### Breaking change - -The `from_read()` and `from_write()` methods moved from the `Access` trait to the `AccessFs` struct -([commit 68f066eba571](https://github.com/landlock-lsm/rust-landlock/commit/68f066eba571c1f9212f5a07016aac9ffb0d1c27)). - -### Compatibility management - -Improve compatibility consistency and prioritize runtime errors against compatibility errors -([PR #67](https://github.com/landlock-lsm/rust-landlock/pull/67)). - -Fixed a corner case where a ruleset was created on a kernel not supporting Landlock, while requesting to add a rule with an access right handled by the ruleset (`BestEffort`). -When trying to enforce this ruleset, this led to a runtime error (i.e. wrong file descriptor) instead of a compatibility error. - -To simplify compatibility management, always call `prctl(PR_SET_NO_NEW_PRIVS, 1)` by default (see `set_no_new_privs()`). -This was required to get a consistent compatibility management and it should not be an issue given that this feature is supported by all LTS kernels -([commit d99f75155bec](https://github.com/landlock-lsm/rust-landlock/commit/d99f75155bec2040cf4ce1532007cd3b8a23e2fb)). - - -## [v0.3.1](https://github.com/landlock-lsm/rust-landlock/releases/tag/v0.3.1) - -### New API - -Add [`RulesetCreated::try_clone()`](https://landlock.io/rust-landlock/landlock/struct.RulesetCreated.html#method.try_clone) ([PR #38](https://github.com/landlock-lsm/rust-landlock/pull/38)). - - -## [v0.3.0](https://github.com/landlock-lsm/rust-landlock/releases/tag/v0.3.0) - -### New API - -Add support for Landlock ABI 3: control truncate operations with the new -[`AccessFs::Truncate`](https://landlock.io/rust-landlock/landlock/enum.AccessFs.html#variant.Truncate) -right ([PR #40](https://github.com/landlock-lsm/rust-landlock/pull/40)). - -Revamp the compatibility handling and add a new -[`set_compatibility()`](https://landlock.io/rust-landlock/landlock/trait.Compatible.html#method.set_compatibility) -method for `Ruleset`, `RulesetCreated`, and `PathBeneath`. -We can now fine-tune the compatibility behavior according to the running kernel -and then the supported features thanks to three compatible levels: -best effort, soft requirement and hard requirement -([PR #12](https://github.com/landlock-lsm/rust-landlock/pull/12)). - -Add a new [`AccessFs::from_file()`](https://landlock.io/rust-landlock/landlock/enum.AccessFs.html#method.from_file) -helper ([commit 0b3238c6dd70](https://github.com/landlock-lsm/rust-landlock/commit/0b3238c6dd70)). - -### Deprecated API - -Deprecate the [`set_best_effort()`](https://landlock.io/rust-landlock/landlock/trait.Compatible.html#method.set_best_effort) -method and replace it with `set_compatibility()` -([PR #12](https://github.com/landlock-lsm/rust-landlock/pull/12)). - -Deprecate [`Ruleset::new()`](https://landlock.io/rust-landlock/landlock/struct.Ruleset.html#method.new) -and replace it with `Ruleset::default()` -([PR #44](https://github.com/landlock-lsm/rust-landlock/pull/44)). - -### Breaking changes - -We now check that a ruleset really handles at least one access right, -which can now cause `Ruleset::create()` to return an error if the ruleset compatibility level is -`HardRequirement` or `set_best_effort(false)` -([commit 95addc13b4a8](https://github.com/landlock-lsm/rust-landlock/commit/95addc13b4a8)). - -We now check that access rights passed to `add_rule()` make sense according to the file type. -To handle most use cases, -`path_beneath_rules()` now automatically check and downgrade access rights for files -(i.e. remove superfluous directory-only access rights, - [commit 8e47940b3722](https://github.com/landlock-lsm/rust-landlock/commit/8e47940b3722)). - -### Testing - -Test coverage in the CI is greatly improved by running all tests on all relevant kernel versions: -Linux 5.10, 5.15, 6.1, and 6.4 -([PR #41](https://github.com/landlock-lsm/rust-landlock/pull/41)). - -Run each test in a dedicated thread to avoid inconsistent behavior -([PR #46](https://github.com/landlock-lsm/rust-landlock/pull/46)). - - -## [v0.2.0](https://github.com/landlock-lsm/rust-landlock/releases/tag/v0.2.0) - -This is the first major release of this crate. -It brings a high-level interface to the Landlock kernel interface. diff --git a/third_party/rust-landlock/CONTRIBUTING.md b/third_party/rust-landlock/CONTRIBUTING.md deleted file mode 100644 index 928f1d9..0000000 --- a/third_party/rust-landlock/CONTRIBUTING.md +++ /dev/null @@ -1,34 +0,0 @@ -# Contributing - -Thanks for your interest in contributing to rust-landlock! - -## Testing vs kernel ABI - -The Landlock functionality exposed differs between kernel versions. Based on -the Landlock ABI version of the running system, rust-landlock runs different -subsets of tests. For local development, running `cargo test` will test against -your currently running kernel version (and the Landlock ABI that it ships). -However, this may result in some tests being skipped. - -To fully test a change, it should be verified against a range of ABI versions. -This is done by the Github Actions CI, but doing so locally is challenging. - -Using the `LANDLOCK_CRATE_TEST_ABI` variable, the tested ABI version can be -overridden. For more details, take a look at the comment in -[`compat.rs:current_kernel_abi()`][current-kernel-abi]. - -For more information about Landlock ABIs, see: [enum ABI][enum-abi] - -[current-kernel-abi]: https://github.com/landlock-lsm/rust-landlock/blob/main/src/compat.rs -[enum-abi]: https://landlock.io/rust-landlock/landlock/enum.ABI.html - -## Licensing & DCO - -rust-landlock is double-licensed under the terms of [Apache 2.0][apache-2.0] -and [MIT][mit]. - -All changes submitted to rust-landlock must be [signed off][dco]. - -[apache-2.0]: https://spdx.org/licenses/Apache-2.0.html -[mit]: https://spdx.org/licenses/MIT.html -[dco]: https://github.com/apps/dco diff --git a/third_party/rust-landlock/COPYRIGHT b/third_party/rust-landlock/COPYRIGHT deleted file mode 100644 index c11cc5f..0000000 --- a/third_party/rust-landlock/COPYRIGHT +++ /dev/null @@ -1,7 +0,0 @@ -Copyright 2020 Mickaël Salaün - -Licensed under the Apache License, Version 2.0 or the MIT license -, at your -option. All files in the project carrying such notice may not be -copied, modified, or distributed except according to those terms. diff --git a/third_party/rust-landlock/Cargo.toml b/third_party/rust-landlock/Cargo.toml deleted file mode 100644 index f1290ae..0000000 --- a/third_party/rust-landlock/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "landlock" -version = "0.4.5" -edition = "2021" -rust-version = "1.71" -description = "Landlock LSM helpers" -homepage = "https://landlock.io" -repository = "https://github.com/landlock-lsm/rust-landlock" -license = "MIT OR Apache-2.0" -keywords = ["access-control", "linux", "sandbox", "security"] -categories = ["api-bindings", "os::linux-apis", "virtualization", "filesystem"] -exclude = [".gitignore"] -readme = "README.md" - -[dependencies] -enumflags2 = "0.7" -libc = "0.2.186" -thiserror = "2.0" - -[dev-dependencies] -anyhow = "1.0" -lazy_static = "1" -strum = "0.28" -strum_macros = "0.28" diff --git a/third_party/rust-landlock/FORK.md b/third_party/rust-landlock/FORK.md deleted file mode 100644 index f149e89..0000000 --- a/third_party/rust-landlock/FORK.md +++ /dev/null @@ -1,78 +0,0 @@ -# oring fork of rust-landlock - -Local, unpublished fork consumed by `../oring` via a `path` dependency. - -## Base — upstream PR #130 (the maintainer's own, OPEN) - -`482c1077` "sandboxer: Sync with kernel's sandboxer.c", on top of -`e64b5c53` "ci,src: Handle Landlock ABI v8" — i.e. -[landlock-lsm/rust-landlock#130] "Add ABI v8: TSYNC" by **l0kod** (maintainer), -whose dependency #129 ("uapi: Update to Linux 7.0") is already **merged**. - -### What we actually take from #130 — and it is NOT TSYNC - -oring never calls `all_threads()`. `grep -rn 'all_threads\|TSYNC' ../oring/src` -returns only comments explaining why oring **refuses** to seal a multithreaded -process (`pipeline.rs`): `restrict_self`/`seccomp` are per-thread, so a sibling -would keep running unconfined, and refusing is the fail-closed answer. TSYNC is -carried unused. - -What #130 buys us is **V8 as an ABI enum value**. V8 adds no access rights — -TSYNC is a `restrict_self` flag — but without V8 in the enum, oring's -`CRATE_MAX_ABI` would be 7 and its abi-ahead WARN would fire on EVERY current -a V8 box and a V9 box while being misleading, since V8 added no rights to miss. - -The alternative — basing on merged main and hand-rolling V8 ourselves — would -make the divergence BIGGER, not smaller, and would conflict when #130 lands. -#130 is the maintainer's own PR, so it is the likeliest thing to merge roughly -as-is and keeps the eventual rebase cheap. - -### Why this base, and not #131 - -We were previously based on PR #131 (a third-party draft adding V8+V9 at once). -l0kod **closed #131**: TSYNC lands via his own #130, and *"I'll sent a dedicated -one for the UNIX pathname control soon."* Basing on #130 puts us on the code that -will actually merge and shrinks our delta to the single thing upstream has not -shipped yet. - -## Our delta — ABI V9 / `RESOLVE_UNIX` only - -#130 stops at V8 and has no `ResolveUnix` (its uapi bindings are Linux 7.0; the -right arrived in 7.1). oring needs it to wall pathname-AF_UNIX `connect()`, so we -add exactly that, and nothing else: - -- `uapi/`: `LANDLOCK_ACCESS_FS_RESOLVE_UNIX = 65536` (`1<<16`) in all three arch - bindings + `mod.rs` re-export. -- `compat.rs`: `ABI::V9`; `From` gains `8 => V8` and clamps `_ => V9`. -- `fs.rs`: `AccessFs::ResolveUnix`; `from_write(V9)` carries it, `from_read(V9)` - does not — read/write stay disjoint, so `consistent_access_fs_rw` passes. - (This was the bug we reported on #131.) Plus a `resolve_unix_access` self-test. -- `errata.rs` / `net.rs` / `scope.rs`: V9 folded into the existing top arms — - V9 adds no new net/scope/errata behavior. - -## Verified on bare metal, kernel 7.1.3 = ABI V9 - -- Suite **80 unit + 13 doctests green** (incl. `consistent_access_fs_rw`). -- **TSYNC**: sibling thread created before `restrict_self` is denied under - `all_threads(true)` and still allowed under `all_threads(false)`; reported - status matched observed behavior both times. -- **RESOLVE_UNIX**: `from_all(V9)` carries it; out-of-domain pathname `connect()` - → `EACCES`; connect under a `ResolveUnix`-granted `PathBeneath` → OK. - -## Watch items - -1. **#130 is unmerged and moving.** Our base is its head `482c1077`; confirm it - still is before assuming a clean rebase. A force-push would strand the delta. -2. **When l0kod's RESOLVE_UNIX PR appears, REPLACE our V9 delta with it — do not - keep both.** If his version follows #131's shape it will put `ResolveUnix` in - both `from_read` and `from_write`, our `consistent_access_fs_rw` self-test - will fail on rebase, and that failure is the SIGNAL to re-raise the - disjointness point upstream rather than paper over it. - -## Exit path - -Temporary. When #130 merges and l0kod's RESOLVE_UNIX PR lands, drop this fork for -the crates.io release. oring's only touch-points are `CRATE_MAX_ABI` and -`abi_from_version` in `src/landlock.rs`. - -[landlock-lsm/rust-landlock#130]: https://github.com/landlock-lsm/rust-landlock/pull/130 diff --git a/third_party/rust-landlock/LICENSE-APACHE b/third_party/rust-landlock/LICENSE-APACHE deleted file mode 100644 index c3e1a13..0000000 --- a/third_party/rust-landlock/LICENSE-APACHE +++ /dev/null @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2020 Mickaël Salaün - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/third_party/rust-landlock/LICENSE-MIT b/third_party/rust-landlock/LICENSE-MIT deleted file mode 100644 index 2553058..0000000 --- a/third_party/rust-landlock/LICENSE-MIT +++ /dev/null @@ -1,27 +0,0 @@ -MIT License - -Copyright (c) 2020 Mickaël Salaün - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. diff --git a/third_party/rust-landlock/README.md b/third_party/rust-landlock/README.md deleted file mode 100644 index 53c28d1..0000000 --- a/third_party/rust-landlock/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# Rust Landlock library - -Landlock is a security feature available since Linux 5.13. -The goal is to enable to restrict ambient rights (e.g., global filesystem access) for a set of processes by creating safe security sandboxes as new security layers in addition to the existing system-wide access-controls. -This kind of sandbox is expected to help mitigate the security impact of bugs, unexpected or malicious behaviors in applications. -Landlock empowers any process, including unprivileged ones, to securely restrict themselves. -More information about Landlock can be found in the [official website](https://landlock.io). - -This Rust crate provides a safe abstraction for the Landlock system calls along with some helpers. - -## Use cases - -This crate is especially useful to protect users' data by sandboxing: -* trusted applications dealing with potentially malicious data - (e.g., complex file format, network request) that could exploit security vulnerabilities; -* sandbox managers, container runtimes or shells launching untrusted applications. - -## Examples - -A simple example can be found with the -[`path_beneath_rules()`](https://landlock.io/rust-landlock/landlock/fn.path_beneath_rules.html) helper. -More complex examples can be found with the -[`Ruleset` documentation](https://landlock.io/rust-landlock/landlock/struct.Ruleset.html) -and the [sandboxer example](examples/sandboxer.rs). - -## [Crate documentation](https://landlock.io/rust-landlock/landlock/) - -## Changelog - -* [v0.4.5](CHANGELOG.md#v045) -* [v0.4.4](CHANGELOG.md#v044) -* [v0.4.3](CHANGELOG.md#v043) -* [v0.4.2](CHANGELOG.md#v042) -* [v0.4.1](CHANGELOG.md#v041) -* [v0.4.0](CHANGELOG.md#v040) -* [v0.3.1](CHANGELOG.md#v031) -* [v0.3.0](CHANGELOG.md#v030) -* [v0.2.0](CHANGELOG.md#v020) diff --git a/third_party/rust-landlock/examples/sandboxer.rs b/third_party/rust-landlock/examples/sandboxer.rs deleted file mode 100644 index 5c3f4ca..0000000 --- a/third_party/rust-landlock/examples/sandboxer.rs +++ /dev/null @@ -1,252 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 OR MIT - -// This is an idiomatic Rust rewrite of a C example: -// https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/samples/landlock/sandboxer.c - -use anyhow::{anyhow, bail, Context}; -use landlock::{ - path_beneath_rules, Access, AccessFs, AccessNet, BitFlags, CompatLevel, Compatible, - LandlockStatus, NetPort, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr, - RulesetStatus, Scope, ABI, -}; -use std::env; -use std::ffi::OsStr; -use std::os::unix::ffi::{OsStrExt, OsStringExt}; -use std::os::unix::process::CommandExt; -use std::process::Command; - -const ENV_FS_RO_NAME: &str = "LL_FS_RO"; -const ENV_FS_RW_NAME: &str = "LL_FS_RW"; -const ENV_TCP_BIND_NAME: &str = "LL_TCP_BIND"; -const ENV_TCP_CONNECT_NAME: &str = "LL_TCP_CONNECT"; -const ENV_SCOPED_NAME: &str = "LL_SCOPED"; -const ENV_FORCE_LOG_NAME: &str = "LL_FORCE_LOG"; - -struct PathEnv { - paths: Vec, - access: BitFlags, -} - -impl PathEnv { - /// Create an object able to iterate PathBeneath rules - /// - /// # Arguments - /// - /// * `name`: String identifying an environment variable containing paths requested to be - /// allowed. Paths are separated with ":", e.g. "/bin:/lib:/usr:/proc". In case an empty - /// string is provided, NO restrictions are applied. - /// * `access`: Set of access-rights allowed for each of the parsed paths. - fn new<'a>(name: &'a str, access: BitFlags) -> anyhow::Result { - Ok(Self { - paths: env::var_os(name) - .ok_or(anyhow!("missing environment variable {name}"))? - .into_vec(), - access, - }) - } - - fn iter(&self) -> impl Iterator>> + '_ { - let is_empty = self.paths.is_empty(); - path_beneath_rules( - self.paths - .split(|b| *b == b':') - // Skips the first empty element of an empty string. - .skip_while(move |_| is_empty) - .map(OsStr::from_bytes), - self.access, - ) - .map(|r| Ok(r?)) - } -} - -struct PortEnv { - ports: Vec, - access: AccessNet, -} - -impl PortEnv { - fn new<'a>(name: &'a str, access: AccessNet) -> anyhow::Result { - Ok(Self { - ports: env::var_os(name).unwrap_or_default().into_vec(), - access, - }) - } - - fn iter(&self) -> impl Iterator> + '_ { - let is_empty = self.ports.is_empty(); - self.ports - .split(|b| *b == b':') - // Skips the first empty element of an empty string. - .skip_while(move |_| is_empty) - .map(OsStr::from_bytes) - .map(|port| { - let port = port - .to_str() - .context("failed to convert port string")? - .parse::() - .context("failed to convert port to 16-bit integer")?; - Ok(NetPort::new(port, self.access)) - }) - } -} - -fn main() -> anyhow::Result<()> { - let mut args = env::args_os(); - let program_name = args - .next() - .context("Missing the sandboxer program name (i.e. argv[0])")?; - - let cmd_name = args.next().ok_or_else(|| { - let program_name = program_name.to_string_lossy(); - eprintln!( - "usage: {ENV_FS_RO_NAME}=\"...\" {ENV_FS_RW_NAME}=\"...\" [other environment variables] {program_name} [args]...\n" - ); - eprintln!("Execute the given command in a restricted environment."); - eprintln!("Multi-valued settings (lists of ports, paths, scopes) are colon-delimited.\n"); - eprintln!("Mandatory settings:"); - eprintln!("* {ENV_FS_RO_NAME}: paths allowed to be used in a read-only way"); - eprintln!("* {ENV_FS_RW_NAME}: paths allowed to be used in a read-write way\n"); - eprintln!("Optional settings (when not set, their associated access check is always allowed, which is different from an empty string which means an empty list):"); - eprintln!("* {ENV_TCP_BIND_NAME}: ports allowed to bind (server)"); - eprintln!("* {ENV_TCP_CONNECT_NAME}: ports allowed to connect (client)"); - eprintln!("* {ENV_SCOPED_NAME}: actions denied on the outside of the Landlock domain:"); - eprintln!(" - \"a\" to restrict opening abstract unix sockets"); - eprintln!(" - \"s\" to restrict sending signals"); - eprintln!( - "\nA sandboxer should not log denied access requests to avoid spamming logs, \ - but to test audit we can set {ENV_FORCE_LOG_NAME}=1\n" - ); - eprintln!( - "Example:\n\ - {ENV_FS_RO_NAME}=\"${{PATH}}:/lib:/usr:/proc:/etc:/dev/urandom\" \ - {ENV_FS_RW_NAME}=\"/dev/null:/dev/full:/dev/zero:/dev/pts:/tmp\" \ - {ENV_TCP_BIND_NAME}=\"9418\" \ - {ENV_TCP_CONNECT_NAME}=\"80:443\" \ - {ENV_SCOPED_NAME}=\"a:s\" \ - {program_name} bash -i\n" - ); - anyhow!("Missing command") - })?; - - let abi = ABI::V8; - let mut ruleset = Ruleset::default().handle_access(AccessFs::from_all(abi))?; - let ruleset_ref = &mut ruleset; - - if env::var_os(ENV_TCP_BIND_NAME).is_some() { - ruleset_ref.handle_access(AccessNet::BindTcp)?; - } - if env::var_os(ENV_TCP_CONNECT_NAME).is_some() { - ruleset_ref.handle_access(AccessNet::ConnectTcp)?; - } - - if let Some(scoped) = env::var_os(ENV_SCOPED_NAME) { - let mut abstract_scoping = false; - let mut signal_scoping = false; - let scopes = scoped.to_string_lossy(); - let is_empty = scopes.is_empty(); - for scope in scopes.split(':').skip_while(move |_| is_empty) { - match scope { - "a" => { - if abstract_scoping { - bail!("Duplicate scope 'a'"); - } - ruleset_ref.scope(Scope::AbstractUnixSocket)?; - abstract_scoping = true; - } - "s" => { - if signal_scoping { - bail!("Duplicate scope 's'"); - } - ruleset_ref.scope(Scope::Signal)?; - signal_scoping = true; - } - _ => bail!("Unknown scope \"{scope}\""), - } - } - } - - // Parse LL_FORCE_LOG into a boolean. Matches the kernel's sandboxer: - // when set, require the feature (error out on unsupported kernels). - let force_log = match env::var_os(ENV_FORCE_LOG_NAME) { - Some(v) if v == "1" => true, - Some(_) => bail!("Unknown value for {ENV_FORCE_LOG_NAME} (only \"1\" is handled)"), - None => false, - }; - - let status = ruleset - .create()? - .add_rules(PathEnv::new(ENV_FS_RO_NAME, AccessFs::from_read(abi))?.iter())? - .add_rules(PathEnv::new(ENV_FS_RW_NAME, AccessFs::from_all(abi))?.iter())? - .add_rules(PortEnv::new(ENV_TCP_BIND_NAME, AccessNet::BindTcp)?.iter())? - .add_rules(PortEnv::new(ENV_TCP_CONNECT_NAME, AccessNet::ConnectTcp)?.iter())? - .set_compatibility(CompatLevel::HardRequirement) - .log_new_exec(force_log)? - .set_compatibility(CompatLevel::BestEffort) - .restrict_self() - .expect("Failed to enforce ruleset"); - - match status.landlock { - // This should never happen because of the previous check: - LandlockStatus::NotEnabled => { - eprintln!( - "Hint: Landlock is currently disabled. \ - It can be enabled in the kernel configuration by prepending \"landlock,\" - to the content of CONFIG_LSM, or at boot time by setting the same content to - the \"lsm\" kernel parameter." - ); - } - LandlockStatus::NotImplemented => { - eprintln!( - "Hint: Landlock is not built into the current kernel. \ - To support it, build the kernel with CONFIG_SECURITY_LANDLOCK=y and \ - prepend \"landlock,\" to the content of CONFIG_LSM." - ); - } - LandlockStatus::Available { - kernel_abi: Some(raw_abi), - .. - } => { - eprintln!( - "Hint: This sandboxer only supports Landlock ABI version up to {abi} \ - whereas the current kernel supports Landlock ABI version {raw_abi}. \ - To leverage all Landlock features, update this sandboxer." - ); - } - LandlockStatus::Available { - kernel_abi: None, - effective_abi, - } => { - if effective_abi < abi { - eprintln!( - "Hint: This sandboxer supports Landlock ABI version up to {abi} \ - but the current kernel only supports Landlock ABI version {effective_abi}. \ - To leverage all Landlock features, update the kernel." - ); - } else if effective_abi > abi { - // This should not happen because the ABI used by the sandboxer - // should be the latest supported by the Landlock crate, and - // they should be updated at the same time. - eprintln!( - "Warning: This sandboxer only supports Landlock ABI version up to {abi} \ - but the current kernel supports Landlock ABI version {effective_abi}. \ - To leverage all Landlock features, update this sandboxer." - ); - } - } - } - if status.ruleset == RulesetStatus::NotEnforced { - bail!("The ruleset cannot be enforced at all"); - } - - eprintln!("Executing the sandboxed command..."); - Err(Command::new(cmd_name) - .env_remove(ENV_FS_RO_NAME) - .env_remove(ENV_FS_RW_NAME) - .env_remove(ENV_TCP_BIND_NAME) - .env_remove(ENV_TCP_CONNECT_NAME) - .env_remove(ENV_SCOPED_NAME) - .env_remove(ENV_FORCE_LOG_NAME) - .args(args) - .exec() - .into()) -} diff --git a/third_party/rust-landlock/src/access.rs b/third_party/rust-landlock/src/access.rs deleted file mode 100644 index f2033a1..0000000 --- a/third_party/rust-landlock/src/access.rs +++ /dev/null @@ -1,183 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 OR MIT - -use crate::{ - private, AccessError, AddRuleError, AddRulesError, BitFlags, CompatError, CompatResult, - HandleAccessError, HandleAccessesError, Ruleset, TailoredCompatLevel, TryCompat, ABI, -}; -use enumflags2::BitFlag; - -#[cfg(test)] -use crate::{make_bitflags, AccessFs, CompatLevel, CompatState, Compatibility}; - -pub trait Access: BitFlag + private::Sealed { - /// Gets the access rights defined by a specific [`ABI`]. - fn from_all(abi: ABI) -> BitFlags; -} - -// This HandledAccess trait is useful to document the API. -pub trait HandledAccess: Access {} - -pub trait PrivateHandledAccess: HandledAccess { - fn ruleset_handle_access( - ruleset: &mut Ruleset, - access: BitFlags, - ) -> Result<(), HandleAccessesError> - where - Self: Access; - - fn into_add_rules_error(error: AddRuleError) -> AddRulesError - where - Self: Access; - - fn into_handle_accesses_error(error: HandleAccessError) -> HandleAccessesError - where - Self: Access; -} - -// Creates an illegal/overflowed BitFlags with all its bits toggled, including undefined ones. -fn full_negation(flags: BitFlags) -> BitFlags -where - T: Access, -{ - unsafe { BitFlags::::from_bits_unchecked(!flags.bits()) } -} - -#[test] -fn bit_flags_full_negation() { - let scoped_negation = !BitFlags::::all(); - assert_eq!(scoped_negation, BitFlags::::empty()); - // !BitFlags::::all() could be equal to full_negation(BitFlags::::all())) - // if all the 64-bits would be used, which is not currently the case. - assert_ne!(scoped_negation, full_negation(BitFlags::::all())); -} - -impl TailoredCompatLevel for BitFlags where A: Access {} - -impl TryCompat for BitFlags -where - A: Access, -{ - fn try_compat_inner(&mut self, abi: ABI) -> Result, CompatError> { - if self.is_empty() { - // Empty access-rights would result to a runtime error. - Err(AccessError::Empty.into()) - } else if !Self::all().contains(*self) { - // Unknown access-rights (at build time) would result to a runtime error. - // This can only be reached by using the unsafe BitFlags::from_bits_unchecked(). - Err(AccessError::Unknown { - access: *self, - unknown: *self & full_negation(Self::all()), - } - .into()) - } else { - let compat = *self & A::from_all(abi); - let ret = if compat.is_empty() { - Ok(CompatResult::No( - AccessError::Incompatible { access: *self }.into(), - )) - } else if compat != *self { - let error = AccessError::PartiallyCompatible { - access: *self, - incompatible: *self & full_negation(compat), - } - .into(); - Ok(CompatResult::Partial(error)) - } else { - Ok(CompatResult::Full) - }; - *self = compat; - ret - } - } -} - -#[test] -fn compat_bit_flags() { - use crate::ABI; - - let mut compat: Compatibility = ABI::V1.into(); - assert!(compat.state == CompatState::Init); - - let ro_access = make_bitflags!(AccessFs::{Execute | ReadFile | ReadDir}); - assert_eq!( - ro_access, - ro_access - .try_compat(compat.abi(), compat.level, &mut compat.state) - .unwrap() - .unwrap() - ); - assert!(compat.state == CompatState::Full); - - let empty_access = BitFlags::::empty(); - assert!(matches!( - empty_access - .try_compat(compat.abi(), compat.level, &mut compat.state) - .unwrap_err(), - CompatError::Access(AccessError::Empty) - )); - - let all_unknown_access = unsafe { BitFlags::::from_bits_unchecked(1 << 63) }; - assert!(matches!( - all_unknown_access.try_compat(compat.abi(), compat.level, &mut compat.state).unwrap_err(), - CompatError::Access(AccessError::Unknown { access, unknown }) if access == all_unknown_access && unknown == all_unknown_access - )); - // An error makes the state final. - assert!(compat.state == CompatState::Dummy); - - let some_unknown_access = unsafe { BitFlags::::from_bits_unchecked(1 << 63 | 1) }; - assert!(matches!( - some_unknown_access.try_compat(compat.abi(), compat.level, &mut compat.state).unwrap_err(), - CompatError::Access(AccessError::Unknown { access, unknown }) if access == some_unknown_access && unknown == all_unknown_access - )); - assert!(compat.state == CompatState::Dummy); - - compat = ABI::Unsupported.into(); - - // Tests that the ruleset is marked as unsupported. - assert!(compat.state == CompatState::Init); - - // Access-rights are valid (but ignored) when they are not required for the current ABI. - assert_eq!( - None, - ro_access - .try_compat(compat.abi(), compat.level, &mut compat.state) - .unwrap() - ); - - assert!(compat.state == CompatState::No); - - // Access-rights are not valid when they are required for the current ABI. - compat.level = Some(CompatLevel::HardRequirement); - assert!(matches!( - ro_access.try_compat(compat.abi(), compat.level, &mut compat.state).unwrap_err(), - CompatError::Access(AccessError::Incompatible { access }) if access == ro_access - )); - - compat = ABI::V1.into(); - - // Tests that the ruleset is marked as the unknown compatibility state. - assert!(compat.state == CompatState::Init); - - // Access-rights are valid (but ignored) when they are not required for the current ABI. - assert_eq!( - ro_access, - ro_access - .try_compat(compat.abi(), compat.level, &mut compat.state) - .unwrap() - .unwrap() - ); - - // Tests that the ruleset is in an unsupported state, which is important to be able to still - // enforce no_new_privs. - assert!(compat.state == CompatState::Full); - - let v2_access = ro_access | AccessFs::Refer; - - // Access-rights are not valid when they are required for the current ABI. - compat.level = Some(CompatLevel::HardRequirement); - assert!(matches!( - v2_access.try_compat(compat.abi(), compat.level, &mut compat.state).unwrap_err(), - CompatError::Access(AccessError::PartiallyCompatible { access, incompatible }) - if access == v2_access && incompatible == AccessFs::Refer - )); -} diff --git a/third_party/rust-landlock/src/compat.rs b/third_party/rust-landlock/src/compat.rs deleted file mode 100644 index 2475a3a..0000000 --- a/third_party/rust-landlock/src/compat.rs +++ /dev/null @@ -1,887 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 OR MIT - -use crate::{uapi, Access, CompatError}; -use std::fmt::{self, Display, Formatter}; -use std::io::Error; - -#[cfg(test)] -use std::convert::TryInto; -#[cfg(test)] -use strum::{EnumCount, IntoEnumIterator}; -#[cfg(test)] -use strum_macros::{EnumCount as EnumCountMacro, EnumIter}; - -/// Version of the Landlock [ABI](https://en.wikipedia.org/wiki/Application_binary_interface). -/// -/// `ABI` enables getting the features supported by a specific Landlock ABI -/// (without relying on the kernel version which may not be accessible or patched). -/// For example, [`AccessFs::from_all(ABI::V1)`](Access::from_all) -/// gets all the file system access rights defined by the first version. -/// -/// Without `ABI`, it would be hazardous to rely on the the full set of access flags -/// (e.g., `BitFlags::::all()` or `BitFlags::ALL`), -/// a moving target that would change the semantics of your Landlock rule -/// when migrating to a newer version of this crate. -/// Indeed, a simple `cargo update` or `cargo install` run by any developer -/// can result in a new version of this crate (fixing bugs or bringing non-breaking changes). -/// This crate cannot give any guarantee concerning the new restrictions resulting from -/// these unknown bits (i.e. access rights) that would not be controlled by your application but by -/// a future version of this crate instead. -/// Because we cannot know what the effect on your application of an unknown restriction would be -/// when handling an untested Landlock access right (i.e. denied-by-default access), -/// it could trigger bugs in your application. -/// -/// This crate provides a set of tools to sandbox as much as possible -/// while guaranteeing a consistent behavior thanks to the [`Compatible`] methods. -/// You should also test with different relevant kernel versions, -/// see [landlock-test-tools](https://github.com/landlock-lsm/landlock-test-tools) and -/// [CI integration](https://github.com/landlock-lsm/rust-landlock/pull/41). -/// -/// This way, we can have the guarantee that the use of a set of tested Landlock ABI works as -/// expected because features brought by newer Landlock ABI will never be enabled by default -/// (cf. [Linux kernel compatibility contract](https://docs.kernel.org/userspace-api/landlock.html#compatibility)). -/// -/// In a nutshell, test the access rights you request on a kernel that support them and -/// on a kernel that doesn't support them. -/// -/// Derived `Debug` formats are [not stable](https://doc.rust-lang.org/stable/std/fmt/trait.Debug.html#stability). -#[cfg_attr(test, derive(EnumIter, EnumCountMacro))] -#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] -#[non_exhaustive] -pub enum ABI { - /// Kernel not supporting Landlock, either because it is not built with Landlock - /// or Landlock is not enabled at boot. - Unsupported = 0, - /// First Landlock ABI, introduced with - /// [Linux 5.13](https://git.kernel.org/stable/c/17ae69aba89dbfa2139b7f8024b757ab3cc42f59). - V1 = 1, - /// Second Landlock ABI, introduced with - /// [Linux 5.19](https://git.kernel.org/stable/c/cb44e4f061e16be65b8a16505e121490c66d30d0). - V2 = 2, - /// Third Landlock ABI, introduced with - /// [Linux 6.2](https://git.kernel.org/stable/c/299e2b1967578b1442128ba8b3e86ed3427d3651). - V3 = 3, - /// Fourth Landlock ABI, introduced with - /// [Linux 6.7](https://git.kernel.org/stable/c/136cc1e1f5be75f57f1e0404b94ee1c8792cb07d). - V4 = 4, - /// Fifth Landlock ABI, introduced with - /// [Linux 6.10](https://git.kernel.org/stable/c/2fc0e7892c10734c1b7c613ef04836d57d4676d5). - V5 = 5, - /// Sixth Landlock ABI, introduced with - /// [Linux 6.12](https://git.kernel.org/stable/c/e1b061b444fb01c237838f0d8238653afe6a8094). - V6 = 6, - /// Seventh Landlock ABI, introduced with - /// [Linux 6.15](https://git.kernel.org/stable/c/72885116069abdd05c245707c3989fc605632970). - V7 = 7, - /// Eighth Landlock ABI, introduced with - /// [Linux 7.0](https://git.kernel.org/stable/c/c22e26bd0906e9c8325462993f01adb16b8ea2c0). - V8 = 8, - /// Ninth Landlock ABI, introduced with Linux 7.1 (adds FS_RESOLVE_UNIX). - V9 = 9, -} - -// ABI should not be dynamically created (in other crates) according to the running kernel -// to avoid inconsistent behaviors and non-determinism. Creating ABIs based on runtime detection -// can lead to unreliable sandboxing where rules might differ between executions. -impl ABI { - #[cfg(test)] - fn is_known(value: i32) -> bool { - value > 0 && value < ABI::COUNT as i32 - } -} - -/// Converting from an integer to an ABI should only be used for testing. -/// Indeed, manually setting the ABI can lead to inconsistent and unexpected behaviors. -/// Instead, just use the appropriate access rights, this library will handle the rest. -impl From for ABI { - fn from(value: i32) -> ABI { - match value { - n if n <= 0 => ABI::Unsupported, - 1 => ABI::V1, - 2 => ABI::V2, - 3 => ABI::V3, - 4 => ABI::V4, - 5 => ABI::V5, - 6 => ABI::V6, - 7 => ABI::V7, - 8 => ABI::V8, - // Returns the greatest known ABI. - _ => ABI::V9, - } - } -} - -#[test] -fn abi_from() { - // EOPNOTSUPP (-95), ENOSYS (-38) - for n in [-95, -38, -1, 0] { - assert_eq!(ABI::from(n), ABI::Unsupported); - } - - let mut last_i = 1; - let mut last_abi = ABI::Unsupported; - for (i, abi) in ABI::iter().enumerate() { - last_i = i.try_into().unwrap(); - last_abi = abi; - assert_eq!(ABI::from(last_i), last_abi); - } - - assert_eq!(ABI::from(last_i + 1), last_abi); - assert_eq!(ABI::from(999), last_abi); -} - -#[test] -fn known_abi() { - assert!(!ABI::is_known(-1)); - assert!(!ABI::is_known(0)); - assert!(!ABI::is_known(999)); - - let mut last_i = -1; - for (i, _) in ABI::iter().enumerate().skip(1) { - last_i = i as i32; - assert!(ABI::is_known(last_i)); - } - assert!(!ABI::is_known(last_i + 1)); -} - -impl Display for ABI { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - match self { - ABI::Unsupported => write!(f, "unsupported"), - v => (*v as u32).fmt(f), - } - } -} - -/// Status of Landlock support for the running system. -/// -/// This enum is used to represent the status of the Landlock support for the system where the code -/// is executed. It can indicate whether Landlock is available or not. -/// -/// # Warning -/// -/// Sandboxed programs should only use this data to log or provide information to users, -/// not to change their behavior according to this status. Indeed, the `Ruleset` and the other -/// types are designed to handle the compatibility in a simple and safe way. -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -pub enum LandlockStatus { - /// Landlock is supported but not enabled (`EOPNOTSUPP`). - NotEnabled, - /// Landlock is not implemented (i.e. not built into the running kernel: `ENOSYS`). - NotImplemented, - /// Landlock is available and working on the running system. - /// - /// This indicates that the kernel supports Landlock and it's properly enabled. - /// The crate uses the `effective_abi` for all operations, which represents - /// the highest ABI version that both the kernel and this crate understand. - Available { - /// The effective ABI version that this crate will use for Landlock operations. - /// This is the intersection of what the kernel supports and what this crate knows about. - effective_abi: ABI, - /// The actual kernel ABI version when it's newer than any ABI supported by this crate. - /// - /// If `Some(version)`, it means the running kernel supports Landlock ABI `version` - /// which is higher than the latest ABI known by this crate. - /// - /// This field is purely informational and is never used for Landlock operations. - /// The crate always and only uses `effective_abi` for all functionality. - kernel_abi: Option, - }, -} - -impl LandlockStatus { - // Must remain private to avoid inconsistent behavior using such unknown-at-build-time ABI - // e.g., AccessFs::from_all(ABI::new_current()) - // - // This should not be Default::default() because the returned value would may not be the same - // for all users. - fn current() -> Self { - // Landlock ABI version starts at 1 but errno is only set for negative values. - let v = unsafe { - uapi::landlock_create_ruleset( - std::ptr::null(), - 0, - uapi::LANDLOCK_CREATE_RULESET_VERSION, - ) - }; - if v < 0 { - // The only possible error values should be EOPNOTSUPP and ENOSYS. - match Error::last_os_error().raw_os_error() { - Some(libc::EOPNOTSUPP) => Self::NotEnabled, - _ => Self::NotImplemented, - } - } else { - let abi = ABI::from(v); - Self::Available { - effective_abi: abi, - kernel_abi: (v != abi as i32).then_some(v), - } - } - } -} - -// Test against the running kernel. -#[test] -fn test_current_landlock_status() { - let status = LandlockStatus::current(); - if *TEST_ABI == ABI::Unsupported { - assert_eq!(status, LandlockStatus::NotImplemented); - } else { - assert!( - matches!(status, LandlockStatus::Available { effective_abi, .. } if effective_abi == *TEST_ABI) - ); - if std::env::var(TEST_ABI_ENV_NAME).is_ok() { - // We cannot reliably check for unknown kernel. - assert!(matches!( - status, - LandlockStatus::Available { - kernel_abi: None, - .. - } - )); - } - } -} - -impl From for ABI { - fn from(status: LandlockStatus) -> Self { - match status { - // The only possible error values should be EOPNOTSUPP and ENOSYS, - // but let's convert all kind of errors as unsupported. - LandlockStatus::NotEnabled | LandlockStatus::NotImplemented => ABI::Unsupported, - LandlockStatus::Available { effective_abi, .. } => effective_abi, - } - } -} - -// This is only useful to tests and should not be exposed publicly because -// the mapping can only be partial. -#[cfg(test)] -impl From for LandlockStatus { - fn from(abi: ABI) -> Self { - match abi { - // Convert to ENOSYS because of check_ruleset_support() and ruleset_unsupported() tests. - ABI::Unsupported => Self::NotImplemented, - _ => Self::Available { - effective_abi: abi, - kernel_abi: None, - }, - } - } -} - -#[cfg(test)] -pub(crate) static TEST_ABI_ENV_NAME: &str = "LANDLOCK_CRATE_TEST_ABI"; - -#[cfg(test)] -lazy_static! { - pub(crate) static ref TEST_ABI: ABI = match std::env::var("LANDLOCK_CRATE_TEST_ABI") { - Ok(s) => { - let n = s.parse::().unwrap(); - if ABI::is_known(n) || n == 0 { - ABI::from(n) - } else { - panic!("Unknown ABI: {n}"); - } - } - Err(std::env::VarError::NotPresent) => LandlockStatus::current().into(), - Err(e) => panic!("Failed to read LANDLOCK_CRATE_TEST_ABI: {e}"), - }; -} - -#[cfg(test)] -pub(crate) fn can_emulate(mock: ABI, partial_support: ABI, full_support: Option) -> bool { - mock < partial_support - || mock <= *TEST_ABI - || if let Some(full) = full_support { - full <= *TEST_ABI - } else { - partial_support <= *TEST_ABI - } -} - -#[cfg(test)] -pub(crate) fn get_errno_from_landlock_status() -> Option { - match LandlockStatus::current() { - LandlockStatus::NotImplemented | LandlockStatus::NotEnabled => { - match Error::last_os_error().raw_os_error() { - // Returns ENOSYS when the kernel is not built with Landlock support, - // or EOPNOTSUPP when Landlock is supported but disabled at boot time. - ret @ Some(libc::ENOSYS | libc::EOPNOTSUPP) => ret, - // Other values can only come from bogus seccomp filters or debugging tampering. - ret => { - eprintln!("Current kernel should support this Landlock ABI according to $LANDLOCK_CRATE_TEST_ABI"); - eprintln!("Unexpected result: {ret:?}"); - unreachable!(); - } - } - } - LandlockStatus::Available { .. } => None, - } -} - -#[test] -fn current_kernel_abi() { - // Ensures that the tested Landlock ABI is the latest known version supported by the running - // kernel. If this test failed, you need set the LANDLOCK_CRATE_TEST_ABI environment variable - // to the Landlock ABI version supported by your kernel. With a missing variable, the latest - // Landlock ABI version known by this crate is automatically set. - // From Linux 5.13 to 5.18, you need to run: LANDLOCK_CRATE_TEST_ABI=1 cargo test - let test_abi = *TEST_ABI; - let current_abi = LandlockStatus::current().into(); - println!( - "Current kernel version: {}", - std::fs::read_to_string("/proc/version") - .unwrap_or_else(|_| "unknown".into()) - .trim() - ); - println!("Expected Landlock ABI {test_abi:?} whereas the current ABI is {current_abi:#?}"); - assert_eq!(test_abi, current_abi); -} - -// CompatState is not public outside this crate. -/// Returned by ruleset builder. -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -pub enum CompatState { - /// Initial undefined state. - Init, - /// All requested restrictions are enforced. - Full, - /// Some requested restrictions are enforced, following a best-effort approach. - Partial, - /// The running system doesn't support Landlock. - No, - /// Final unsupported state. - Dummy, -} - -impl CompatState { - fn update(&mut self, other: Self) { - *self = match (*self, other) { - (CompatState::Init, other) => other, - (CompatState::Dummy, _) => CompatState::Dummy, - (_, CompatState::Dummy) => CompatState::Dummy, - (CompatState::No, CompatState::No) => CompatState::No, - (CompatState::Full, CompatState::Full) => CompatState::Full, - (_, _) => CompatState::Partial, - } - } -} - -#[test] -fn compat_state_update_1() { - let mut state = CompatState::Full; - - state.update(CompatState::Full); - assert_eq!(state, CompatState::Full); - - state.update(CompatState::No); - assert_eq!(state, CompatState::Partial); - - state.update(CompatState::Full); - assert_eq!(state, CompatState::Partial); - - state.update(CompatState::Full); - assert_eq!(state, CompatState::Partial); - - state.update(CompatState::No); - assert_eq!(state, CompatState::Partial); - - state.update(CompatState::Dummy); - assert_eq!(state, CompatState::Dummy); - - state.update(CompatState::Full); - assert_eq!(state, CompatState::Dummy); -} - -#[test] -fn compat_state_update_2() { - let mut state = CompatState::Full; - - state.update(CompatState::Full); - assert_eq!(state, CompatState::Full); - - state.update(CompatState::No); - assert_eq!(state, CompatState::Partial); - - state.update(CompatState::Full); - assert_eq!(state, CompatState::Partial); -} - -#[test] -fn try_compat_binary_states() { - // Supported: state -> Full. - let mut compat: Compatibility = ABI::Unsupported.into(); - assert_eq!(compat.state, CompatState::Init); - assert_eq!(compat.try_compat_binary(true, || "err"), Ok(true)); - assert_eq!(compat.state, CompatState::Full); - - // Unsupported + BestEffort: state -> Partial (Full + No). - assert_eq!(compat.try_compat_binary(false, || "err"), Ok(false)); - assert_eq!(compat.state, CompatState::Partial); - - // Unsupported + SoftRequirement: state -> Dummy. - let mut compat: Compatibility = ABI::Unsupported.into(); - compat.level = Some(CompatLevel::SoftRequirement); - assert_eq!(compat.try_compat_binary(false, || "err"), Ok(false)); - assert_eq!(compat.state, CompatState::Dummy); - - // Unsupported + HardRequirement: returns error. - let mut compat: Compatibility = ABI::Unsupported.into(); - compat.level = Some(CompatLevel::HardRequirement); - assert_eq!(compat.try_compat_binary(false, || "err"), Err("err")); -} - -#[cfg_attr(test, derive(PartialEq))] -#[derive(Copy, Clone, Debug)] -pub(crate) struct Compatibility { - status: LandlockStatus, - pub(crate) level: Option, - pub(crate) state: CompatState, -} - -impl From for Compatibility { - fn from(status: LandlockStatus) -> Self { - Compatibility { - status, - level: Default::default(), - state: CompatState::Init, - } - } -} - -#[cfg(test)] -impl From for Compatibility { - fn from(abi: ABI) -> Self { - Self::from(LandlockStatus::from(abi)) - } -} - -impl Compatibility { - // Compatibility is a semi-opaque struct. - #[allow(clippy::new_without_default)] - pub(crate) fn new() -> Self { - LandlockStatus::current().into() - } - - pub(crate) fn update(&mut self, state: CompatState) { - self.state.update(state); - } - - pub(crate) fn abi(&self) -> ABI { - self.status.into() - } - - pub(crate) fn status(&self) -> LandlockStatus { - self.status - } - - /// Handles the compat dispatch for a binary supported/not-supported check. - /// - /// This is factored out from the No branch of - /// [`TryCompat::try_compat()`](crate::TryCompat::try_compat) for use by - /// [`SyscallFlagExt::try_compat()`](crate::flags::SyscallFlagExt::try_compat), - /// where a single flag is either fully supported or not (no Partial case). - /// - /// Returns `Ok(true)` if supported (caller should apply the flag), - /// `Ok(false)` if unsupported but acceptable - /// ([`BestEffort`](crate::CompatLevel::BestEffort) / - /// [`SoftRequirement`](crate::CompatLevel::SoftRequirement)), or `Err` if - /// unsupported with [`HardRequirement`](crate::CompatLevel::HardRequirement). - pub(crate) fn try_compat_binary( - &mut self, - supported: bool, - make_error: F, - ) -> Result - where - F: FnOnce() -> E, - { - if supported { - self.state.update(CompatState::Full); - Ok(true) - } else { - match self.level.into() { - CompatLevel::BestEffort => { - self.state.update(CompatState::No); - Ok(false) - } - CompatLevel::SoftRequirement => { - self.state.update(CompatState::Dummy); - Ok(false) - } - CompatLevel::HardRequirement => Err(make_error()), - } - } - } -} - -pub(crate) mod private { - use crate::CompatLevel; - - pub trait OptionCompatLevelMut { - fn as_option_compat_level_mut(&mut self) -> &mut Option; - } -} - -/// Properly handles runtime unsupported features. -/// -/// This guarantees consistent behaviors across crate users -/// and runtime kernels even if this crate get new features. -/// It eases backward compatibility and enables future-proofness. -/// -/// Landlock is a security feature designed to help improve security of a running system -/// thanks to application developers. -/// To protect users as much as possible, -/// compatibility with the running system should then be handled in a best-effort way, -/// contrary to common system features. -/// In some circumstances -/// (e.g. applications carefully designed to only be run with a specific set of kernel features), -/// it may be required to error out if some of these features are not available -/// and will then not be enforced. -pub trait Compatible: Sized + private::OptionCompatLevelMut { - /// To enable a best-effort security approach, - /// Landlock features that are not supported by the running system - /// are silently ignored by default, - /// which is a sane choice for most use cases. - /// However, on some rare circumstances, - /// developers may want to have some guarantees that their applications - /// will not run if a certain level of sandboxing is not possible. - /// If we really want to error out when not all our requested requirements are met, - /// then we can configure it with `set_compatibility()`. - /// - /// The `Compatible` trait is implemented for all object builders - /// (e.g. [`Ruleset`](crate::Ruleset)). - /// Such builders have a set of methods to incrementally build an object. - /// These build methods rely on kernel features that may not be available at runtime. - /// The `set_compatibility()` method enables to control the effect of - /// the following build method calls starting after the `set_compatibility()` call. - /// Such effect can be: - /// * to silently ignore unsupported features - /// and continue building ([`CompatLevel::BestEffort`]); - /// * to silently ignore unsupported features - /// and ignore the whole build ([`CompatLevel::SoftRequirement`]); - /// * to return an error for any unsupported feature ([`CompatLevel::HardRequirement`]). - /// - /// Taking [`Ruleset`](crate::Ruleset) as an example, - /// the [`handle_access()`](crate::RulesetAttr::handle_access()) build method - /// returns a [`Result`] that can be [`Err(RulesetError)`](crate::RulesetError) - /// with a nested [`CompatError`]. - /// Such error can only occur with a running Linux kernel not supporting the requested - /// Landlock accesses *and* if the current compatibility level is - /// [`CompatLevel::HardRequirement`]. - /// However, such error is not possible with [`CompatLevel::BestEffort`] - /// nor [`CompatLevel::SoftRequirement`]. - /// - /// The order of this call is important because - /// it defines the behavior of the following build method calls that return a [`Result`]. - /// If `set_compatibility(CompatLevel::HardRequirement)` is called on an object, - /// then a [`CompatError`] may be returned for the next method calls, - /// until the next call to `set_compatibility()`. - /// This enables to change the behavior of a set of build method calls, - /// for instance to be sure that the sandbox will at least restrict some access rights. - /// - /// New objects inherit the compatibility configuration of their parents, if any. - /// For instance, [`Ruleset::create()`](crate::Ruleset::create()) returns - /// a [`RulesetCreated`](crate::RulesetCreated) object that inherits the - /// `Ruleset`'s compatibility configuration. - /// - /// # Example with `SoftRequirement` - /// - /// Let's say an application legitimately needs to rename files between directories. - /// Because of [previous Landlock limitations](https://docs.kernel.org/userspace-api/landlock.html#file-renaming-and-linking-abi-2), - /// this was forbidden with the [first version of Landlock](ABI::V1), - /// but it is now handled starting with the [second version](ABI::V2). - /// For this use case, we only want the application to be sandboxed - /// if we have the guarantee that it will not break a legitimate usage (i.e. rename files). - /// We then create a ruleset which will either support file renaming - /// (thanks to [`AccessFs::Refer`](crate::AccessFs::Refer)) or silently do nothing. - /// - /// ``` - /// use landlock::*; - /// - /// fn ruleset_handling_renames() -> Result { - /// Ok(Ruleset::default() - /// // This ruleset must either handle the AccessFs::Refer right, - /// // or it must silently ignore the whole sandboxing. - /// .set_compatibility(CompatLevel::SoftRequirement) - /// .handle_access(AccessFs::Refer)? - /// // However, this ruleset may also handle other (future) access rights - /// // if they are supported by the running kernel. - /// .set_compatibility(CompatLevel::BestEffort) - /// .handle_access(AccessFs::from_all(ABI::V8))? - /// .create()?) - /// } - /// ``` - /// - /// # Example with `HardRequirement` - /// - /// Security-dedicated applications may want to ensure that - /// an untrusted software component is subject to a minimum of restrictions before launching it. - /// In this case, we want to create a ruleset which will at least support - /// all restrictions provided by the [first version of Landlock](ABI::V1), - /// and opportunistically handle restrictions supported by newer kernels. - /// - /// ``` - /// use landlock::*; - /// - /// fn ruleset_fragile() -> Result { - /// Ok(Ruleset::default() - /// // This ruleset must either handle at least all accesses defined by - /// // the first Landlock version (e.g. AccessFs::WriteFile), - /// // or the following handle_access() call must return a wrapped - /// // AccessError::Incompatible error. - /// .set_compatibility(CompatLevel::HardRequirement) - /// .handle_access(AccessFs::from_all(ABI::V1))? - /// // However, this ruleset may also handle new access rights - /// // (e.g. AccessFs::Refer defined by the second version of Landlock) - /// // if they are supported by the running kernel, - /// // but without returning any error otherwise. - /// .set_compatibility(CompatLevel::BestEffort) - /// .handle_access(AccessFs::from_all(ABI::V8))? - /// .create()?) - /// } - /// ``` - fn set_compatibility(mut self, level: CompatLevel) -> Self { - *self.as_option_compat_level_mut() = Some(level); - self - } - - /// Cf. [`set_compatibility()`](Compatible::set_compatibility()): - /// - /// - `set_best_effort(true)` translates to `set_compatibility(CompatLevel::BestEffort)`. - /// - /// - `set_best_effort(false)` translates to `set_compatibility(CompatLevel::HardRequirement)`. - #[deprecated(note = "Use set_compatibility() instead")] - fn set_best_effort(self, best_effort: bool) -> Self - where - Self: Sized, - { - self.set_compatibility(match best_effort { - true => CompatLevel::BestEffort, - false => CompatLevel::HardRequirement, - }) - } -} - -#[test] -#[allow(deprecated)] -fn deprecated_set_best_effort() { - use crate::{CompatLevel, Compatible, Ruleset}; - - assert_eq!( - Ruleset::default().set_best_effort(true).compat, - Ruleset::default() - .set_compatibility(CompatLevel::BestEffort) - .compat - ); - assert_eq!( - Ruleset::default().set_best_effort(false).compat, - Ruleset::default() - .set_compatibility(CompatLevel::HardRequirement) - .compat - ); -} - -/// See the [`Compatible`] documentation. -#[cfg_attr(test, derive(EnumIter))] -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum CompatLevel { - /// Takes into account the build requests if they are supported by the running system, - /// or silently ignores them otherwise. - /// Never returns a compatibility error. - #[default] - BestEffort, - /// Takes into account the build requests if they are supported by the running system, - /// or silently ignores the whole build object otherwise. - /// Never returns a compatibility error. - /// If not supported, - /// the call to [`RulesetCreated::restrict_self()`](crate::RulesetCreated::restrict_self()) - /// will return a - /// [`RestrictionStatus { ruleset: RulesetStatus::NotEnforced, no_new_privs: false, }`](crate::RestrictionStatus). - SoftRequirement, - /// Takes into account the build requests if they are supported by the running system, - /// or returns a compatibility error otherwise ([`CompatError`]). - HardRequirement, -} - -impl From> for CompatLevel { - fn from(opt: Option) -> Self { - match opt { - None => CompatLevel::default(), - Some(ref level) => *level, - } - } -} - -// TailoredCompatLevel could be replaced with AsMut>, but only traits defined -// in the current crate can be implemented for types defined outside of the crate. Furthermore it -// provides a default implementation which is handy for types such as BitFlags. -pub trait TailoredCompatLevel { - fn tailored_compat_level(&mut self, parent_level: L) -> CompatLevel - where - L: Into, - { - parent_level.into() - } -} - -impl TailoredCompatLevel for T -where - Self: Compatible, -{ - // Every Compatible trait implementation returns its own compatibility level, if set. - fn tailored_compat_level(&mut self, parent_level: L) -> CompatLevel - where - L: Into, - { - // Using a mutable reference is not required but it makes the code simpler (no double AsRef - // implementations for each Compatible types), and more importantly it guarantees - // consistency with Compatible::set_compatibility(). - match self.as_option_compat_level_mut() { - None => parent_level.into(), - // Returns the most constrained compatibility level. - Some(ref level) => parent_level.into().max(*level), - } - } -} - -#[test] -fn tailored_compat_level() { - use crate::{AccessFs, PathBeneath, PathFd}; - - fn new_path(level: CompatLevel) -> PathBeneath { - PathBeneath::new(PathFd::new("/").unwrap(), AccessFs::Execute).set_compatibility(level) - } - - for parent_level in CompatLevel::iter() { - assert_eq!( - new_path(CompatLevel::BestEffort).tailored_compat_level(parent_level), - parent_level - ); - assert_eq!( - new_path(CompatLevel::HardRequirement).tailored_compat_level(parent_level), - CompatLevel::HardRequirement - ); - } - - assert_eq!( - new_path(CompatLevel::SoftRequirement).tailored_compat_level(CompatLevel::SoftRequirement), - CompatLevel::SoftRequirement - ); - - for child_level in CompatLevel::iter() { - assert_eq!( - new_path(child_level).tailored_compat_level(CompatLevel::BestEffort), - child_level - ); - assert_eq!( - new_path(child_level).tailored_compat_level(CompatLevel::HardRequirement), - CompatLevel::HardRequirement - ); - } -} - -// CompatResult is not public outside this crate. -pub enum CompatResult -where - A: Access, -{ - // Fully matches the request. - Full, - // Partially matches the request. - Partial(CompatError), - // Doesn't matches the request. - No(CompatError), -} - -// TryCompat is not public outside this crate. -pub trait TryCompat -where - Self: Sized + TailoredCompatLevel, - A: Access, -{ - fn try_compat_inner(&mut self, abi: ABI) -> Result, CompatError>; - - // Default implementation for objects without children. - // - // If returning something other than Ok(Some(self)), the implementation must use its own - // compatibility level, if any, with self.tailored_compat_level(default_compat_level), and pass - // it with the abi and compat_state to each child.try_compat(). See PathBeneath implementation - // and the self.allowed_access.try_compat() call. - // - // # Warning - // - // Errors must be prioritized over incompatibility (i.e. return Err(e) over Ok(None)) for all - // children. - fn try_compat_children( - self, - _abi: ABI, - _parent_level: L, - _compat_state: &mut CompatState, - ) -> Result, CompatError> - where - L: Into, - { - Ok(Some(self)) - } - - // Update compat_state and return an error according to try_compat_*() error, or to the - // compatibility level, i.e. either route compatible object or error. - fn try_compat( - mut self, - abi: ABI, - parent_level: L, - compat_state: &mut CompatState, - ) -> Result, CompatError> - where - L: Into, - { - let compat_level = self.tailored_compat_level(parent_level); - let some_inner = match self.try_compat_inner(abi) { - Ok(CompatResult::Full) => { - compat_state.update(CompatState::Full); - true - } - Ok(CompatResult::Partial(error)) => match compat_level { - CompatLevel::BestEffort => { - compat_state.update(CompatState::Partial); - true - } - CompatLevel::SoftRequirement => { - compat_state.update(CompatState::Dummy); - false - } - CompatLevel::HardRequirement => { - compat_state.update(CompatState::Dummy); - return Err(error); - } - }, - Ok(CompatResult::No(error)) => match compat_level { - CompatLevel::BestEffort => { - compat_state.update(CompatState::No); - false - } - CompatLevel::SoftRequirement => { - compat_state.update(CompatState::Dummy); - false - } - CompatLevel::HardRequirement => { - compat_state.update(CompatState::Dummy); - return Err(error); - } - }, - Err(error) => { - // Safeguard to help for test consistency. - compat_state.update(CompatState::Dummy); - return Err(error); - } - }; - - // At this point, any inner error have been returned, so we can proceed with - // try_compat_children()?. - match self.try_compat_children(abi, compat_level, compat_state)? { - Some(n) if some_inner => Ok(Some(n)), - _ => Ok(None), - } - } -} diff --git a/third_party/rust-landlock/src/errata.rs b/third_party/rust-landlock/src/errata.rs deleted file mode 100644 index a74ef66..0000000 --- a/third_party/rust-landlock/src/errata.rs +++ /dev/null @@ -1,248 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 OR MIT - -use crate::compat::ABI; -use crate::{uapi, BitFlags}; -use enumflags2::bitflags; - -/// Fixed kernel issues for the running Landlock implementation. -/// -/// Each variant represents a specific bug fix that may have been -/// backported to the running kernel. Use [`Erratum::current()`] -/// before building a [`Ruleset`](crate::Ruleset) to decide which -/// features are safe to use. -/// -/// An [`ABI`] version can be converted into the set of applicable errata -/// with `BitFlags::::from(abi)`. -/// -/// # Warning -/// -/// Most applications should **not** check errata. Disabling a sandboxing -/// feature because an erratum is not fixed could leave the system **less** -/// secure than using Landlock's best-effort protection with the buggy -/// feature enabled. Errata should only be used to **add** features -/// (e.g., enabling a restriction only when its bug is confirmed fixed), -/// never to remove them. -#[bitflags] -#[repr(u32)] -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -#[non_exhaustive] -pub enum Erratum { - /// Erratum 1 (ABI 4): non-TCP stream sockets (SMC, MPTCP, SCTP) - /// were incorrectly restricted by TCP access rights during - /// `bind(2)` and `connect(2)`. - /// - /// Affects [`crate::AccessNet::BindTcp`] and [`crate::AccessNet::ConnectTcp`]. - /// - /// See [erratum 1](https://docs.kernel.org/userspace-api/landlock.html#erratum-1-tcp-socket-identification). - TcpSocketIdentification = 1 << 0, - /// Erratum 2 (ABI 6): signal scoping was overly restrictive, - /// preventing sandboxed threads from signaling other threads - /// within the same process in different domains. - /// - /// Affects [`crate::Scope::Signal`]. - /// - /// See [erratum 2](https://docs.kernel.org/userspace-api/landlock.html#erratum-2-scoped-signal-handling). - ScopedSignalHandling = 1 << 1, - /// Erratum 3 (ABI 1): access rights could be widened through - /// rename or link actions on disconnected directories under - /// bind mounts, potentially bypassing `LANDLOCK_ACCESS_FS_REFER` - /// restrictions. - /// - /// See [erratum 3](https://docs.kernel.org/userspace-api/landlock.html#erratum-3-disconnected-directory-handling). - DisconnectedDirectoryHandling = 1 << 2, -} - -impl Erratum { - /// Queries the running kernel for fixed errata. - /// - /// Returns a bitmask of errata that have been fixed in the running - /// kernel. Unknown errata bits from newer kernels are preserved. - /// Returns empty if the kernel doesn't support the errata interface. - pub fn current() -> BitFlags { - let ret = unsafe { - uapi::landlock_create_ruleset(std::ptr::null(), 0, uapi::LANDLOCK_CREATE_RULESET_ERRATA) - }; - if ret >= 0 { - // SAFETY: The kernel may return bits unknown to this crate version. - // Using from_bits_unchecked to preserve them. - unsafe { BitFlags::from_bits_unchecked(ret as u32) } - } else { - BitFlags::empty() - } - } -} - -/// Converts an [`ABI`] version into the set of errata applicable to that ABI. -/// -/// An erratum is applicable if the ABI includes the feature affected by the bug. -/// For example, [`Erratum::TcpSocketIdentification`] is only applicable to -/// [`ABI::V4`] and later, since TCP access rights were introduced in that version. -/// -/// Uses the same incremental accumulation pattern as -/// [`AccessFs::from_write()`](crate::AccessFs::from_write). -/// -/// # Stability -/// -/// The set of errata returned for a given ABI may grow in future versions -/// of this crate as new kernel bug fixes are identified and backported. -/// Do not rely on the exact set being stable across crate versions. -impl From for BitFlags { - fn from(abi: ABI) -> Self { - match abi { - ABI::Unsupported => BitFlags::empty(), - // Erratum 3: disconnected directory handling (FS, ABI 1+). - ABI::V1 | ABI::V2 | ABI::V3 => Erratum::DisconnectedDirectoryHandling.into(), - // Erratum 1: TCP socket identification (net, ABI 4+). - ABI::V4 | ABI::V5 => Self::from(ABI::V3) | Erratum::TcpSocketIdentification, - // Erratum 2: scoped signal handling (scopes, ABI 6+). - // When adding a new ABI version without new errata, append it here. - ABI::V6 | ABI::V7 | ABI::V8 | ABI::V9 => Self::from(ABI::V5) | Erratum::ScopedSignalHandling, - } - } -} - -/// Extracts the (major, minor, patch, suffix) from /proc/version's -/// "Linux version X.Y.Z-suffix..." line. -/// -/// Returns `None` if the input does not start with "Linux version " or if -/// the major/minor numbers cannot be parsed. The patch number defaults to -/// 0 when absent (e.g., for RC kernels). -#[cfg(test)] -fn parse_kernel_version(proc_version: &str) -> Option<(u32, u32, u32, &str)> { - let after_prefix = proc_version.strip_prefix("Linux version ")?; - let token = after_prefix.split_whitespace().next()?; - let first_dot = token.find('.')?; - let major: u32 = token[..first_dot].parse().ok()?; - let rest = &token[first_dot + 1..]; - let minor_end = rest - .find(|c: char| !c.is_ascii_digit()) - .unwrap_or(rest.len()); - let minor: u32 = rest[..minor_end].parse().ok()?; - let after_minor = &rest[minor_end..]; - // If a second dot follows, parse the patch number; otherwise default to 0. - let (patch, suffix) = match after_minor.strip_prefix('.') { - Some(after_dot) => { - let patch_end = after_dot - .find(|c: char| !c.is_ascii_digit()) - .unwrap_or(after_dot.len()); - let patch: u32 = after_dot[..patch_end].parse().unwrap_or(0); - (patch, &after_dot[patch_end..]) - } - None => (0, after_minor), - }; - Some((major, minor, patch, suffix)) -} - -#[test] -fn parse_kernel_version_cases() { - // Distro-suffixed stable release. - assert_eq!( - parse_kernel_version("Linux version 6.15.0-29-generic (build@host) ..."), - Some((6, 15, 0, "-29-generic")), - ); - // Distro-suffixed older stable. - assert_eq!( - parse_kernel_version("Linux version 5.10.234-1-amd64 (debian) ..."), - Some((5, 10, 234, "-1-amd64")), - ); - // Release candidate without patch number. - assert_eq!( - parse_kernel_version("Linux version 6.15-rc1 (...)"), - Some((6, 15, 0, "-rc1")), - ); - // Bare version with no suffix. - assert_eq!( - parse_kernel_version("Linux version 6.15"), - Some((6, 15, 0, "")), - ); - // Stable patch level with no distro suffix. - assert_eq!( - parse_kernel_version("Linux version 6.12.5"), - Some((6, 12, 5, "")), - ); - // Missing prefix. - assert_eq!(parse_kernel_version(""), None); - assert_eq!(parse_kernel_version("Some other text"), None); - // Unparseable version after prefix. - assert_eq!(parse_kernel_version("Linux version garbage"), None); -} - -/// Returns the set of errata that have not been backported yet for a given -/// kernel version. -/// -/// This is the single source of truth for known backport gaps. The version -/// is the (major, minor, patch, suffix) parsed from /proc/version. -/// Backports are made per kernel version (not per Landlock ABI), so this -/// lookup is keyed by kernel version. The patch number and distro suffix -/// allow narrowing to specific kernel builds when a fix arrives in a stable -/// patch level or distro-specific backport. When an erratum is backported -/// to a kernel version, remove it from the corresponding match arm. The -/// CI will catch mismatches. -#[cfg(test)] -fn not_backported_yet(version: (u32, u32, u32, &str)) -> BitFlags { - match version { - // TODO: erratum 3 (DisconnectedDirectoryHandling) should be backported. - (5, 15, _, _) | (6, 1, _, _) => Erratum::DisconnectedDirectoryHandling.into(), - - // 6.15: errata 1 and 2 backported. - // TODO: erratum 3 (DisconnectedDirectoryHandling) should be backported. - (6, 15, _, _) => Erratum::DisconnectedDirectoryHandling.into(), - - // 6.4, 6.7, 6.10: EOL, no errata interface on stable.kernel. - // 6.12: all errata backported. - // 7.0: all errata backported (erratum 3 fix landed via errata/abi-1.h). - // Future or unknown kernel: assume all errata backported. - _ => BitFlags::empty(), - } -} - -#[test] -fn errata_query() { - // Verifies the syscall wrapper works on any kernel. - let _errata = Erratum::current(); -} - -#[test] -fn errata_up_to_date() { - use crate::compat::{ABI, TEST_ABI, TEST_ABI_ENV_NAME}; - - // Print /proc/version for diagnostic info when this test runs in CI. - let proc_version = std::fs::read_to_string("/proc/version").unwrap_or_default(); - eprintln!("/proc/version: {}", proc_version.trim()); - - // This test requires LANDLOCK_CRATE_TEST_ABI to be explicitly set because - // the errata assertions are tied to specific CI kernel versions. Without - // it, TEST_ABI is auto-detected from the running kernel, but From - // maps unknown ABI versions to the highest known one, making the - // ABI-to-kernel mapping ambiguous (e.g., a 6.15 kernel maps to V6 before - // ABI::V7 exists). Since Erratum::current() queries the real kernel, the - // expected errata for the declared ABI may not match. - if std::env::var(TEST_ABI_ENV_NAME).is_err() { - eprintln!("Skipping errata_up_to_date: {} not set", TEST_ABI_ENV_NAME,); - return; - } - - let kernel_version = - parse_kernel_version(&proc_version).expect("Failed to parse /proc/version"); - eprintln!("Parsed kernel version: {:?}", kernel_version); - - let current = Erratum::current(); - let applicable: BitFlags = (*TEST_ABI).into(); - let expected = applicable & !not_backported_yet(kernel_version); - - // Kernel must never report errata for features absent from this ABI. - assert!( - current & !applicable == BitFlags::empty(), - "kernel reported errata not applicable to ABI {:?}: {:?}", - *TEST_ABI, - current & !applicable, - ); - - match *TEST_ABI { - ABI::Unsupported => assert!(current.is_empty()), - ABI::V1 | ABI::V2 => assert_eq!(current, expected), - // 6.4, 6.7, 6.10: EOL, no errata interface on stable.kernel. - ABI::V3 | ABI::V4 | ABI::V5 => {} - ABI::V6 | ABI::V7 | ABI::V8 | ABI::V9 => assert_eq!(current, expected), - } -} diff --git a/third_party/rust-landlock/src/errors.rs b/third_party/rust-landlock/src/errors.rs deleted file mode 100644 index ccd0baa..0000000 --- a/third_party/rust-landlock/src/errors.rs +++ /dev/null @@ -1,340 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 OR MIT - -use crate::{ - Access, AccessFs, AccessNet, BitFlags, HandledAccess, PrivateHandledAccess, RestrictSelfFlag, - Scope, SyscallFlag, -}; -use libc::c_int; -use std::io; -use std::path::PathBuf; -use thiserror::Error; - -/// Maps to all errors that can be returned by a ruleset action. -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum RulesetError { - #[error(transparent)] - HandleAccesses(#[from] HandleAccessesError), - #[error(transparent)] - CreateRuleset(#[from] CreateRulesetError), - #[error(transparent)] - AddRules(#[from] AddRulesError), - #[error(transparent)] - RestrictSelf(#[from] RestrictSelfError), - #[error(transparent)] - Scope(#[from] ScopeError), - /// Error configuring flags passed to `landlock_restrict_self(2)`. - /// Distinct from [`Self::RestrictSelf`], which wraps errors from - /// the syscall itself (e.g., EINVAL on an unsupported kernel). - #[error(transparent)] - RestrictSelfFlags(#[from] SyscallFlagError), -} - -#[test] -fn ruleset_error_breaking_change() { - use crate::*; - - // Generics are part of the API and modifying them can lead to a breaking change. - let _: RulesetError = RulesetError::HandleAccesses(HandleAccessesError::Fs( - HandleAccessError::Compat(CompatError::Access(AccessError::Empty)), - )); -} - -/// Identifies errors when updating the ruleset's handled access-rights. -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum HandleAccessError -where - T: HandledAccess, -{ - #[error(transparent)] - Compat(#[from] CompatError), -} - -/// Identifies errors when updating the ruleset's scopes. -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum ScopeError { - #[error(transparent)] - Compat(#[from] CompatError), -} - -/// Identifies errors when configuring a syscall flag. -/// -/// Generic over the flag type to support different syscall flag categories. -/// Each flag type implements [`SyscallFlag`]. -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum SyscallFlagError { - /// The requested flag configuration is not supported by the running kernel. - #[error("unsupported syscall flag: {flag:?} set to {set}")] - #[non_exhaustive] - NotSupported { flag: F, set: bool }, -} - -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum HandleAccessesError { - #[error(transparent)] - Fs(HandleAccessError), - #[error(transparent)] - Net(HandleAccessError), -} - -// Generically implement for all the handled access implementations rather than for the cases -// listed in HandleAccessesError (with #[from]). -impl From> for HandleAccessesError -where - A: PrivateHandledAccess, -{ - fn from(error: HandleAccessError) -> Self { - A::into_handle_accesses_error(error) - } -} - -/// Identifies errors when creating a ruleset. -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum CreateRulesetError { - /// The `landlock_create_ruleset()` system call failed. - #[error("failed to create a ruleset: {source}")] - #[non_exhaustive] - CreateRulesetCall { source: io::Error }, - /// Missing call to [`RulesetAttr::handle_access()`](crate::RulesetAttr::handle_access) - /// or [`RulesetAttr::scope()`](crate::RulesetAttr::scope). - #[error("missing access")] - MissingHandledAccess, -} - -/// Identifies errors when adding a rule to a ruleset. -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum AddRuleError -where - T: HandledAccess, -{ - /// The `landlock_add_rule()` system call failed. - #[error("failed to add a rule: {source}")] - #[non_exhaustive] - AddRuleCall { source: io::Error }, - /// The rule's access-rights are not all handled by the (requested) ruleset access-rights. - #[error("access-rights not handled by the ruleset: {incompatible:?}")] - UnhandledAccess { - access: BitFlags, - incompatible: BitFlags, - }, - #[error(transparent)] - Compat(#[from] CompatError), -} - -// Generically implement for all the handled access implementations rather than for the cases listed -// in AddRulesError (with #[from]). -impl From> for AddRulesError -where - A: PrivateHandledAccess, -{ - fn from(error: AddRuleError) -> Self { - A::into_add_rules_error(error) - } -} - -/// Identifies errors when adding rules to a ruleset thanks to an iterator returning -/// Result items. -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum AddRulesError { - #[error(transparent)] - Fs(AddRuleError), - #[error(transparent)] - Net(AddRuleError), -} - -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum CompatError -where - T: Access, -{ - #[error(transparent)] - PathBeneath(#[from] PathBeneathError), - #[error(transparent)] - Access(#[from] AccessError), -} - -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum PathBeneathError { - /// To check that access-rights are consistent with a file descriptor, a call to - /// [`RulesetCreatedAttr::add_rule()`](crate::RulesetCreatedAttr::add_rule) - /// looks at the file type with an `fstat()` system call. - #[error("failed to check file descriptor type: {source}")] - #[non_exhaustive] - StatCall { source: io::Error }, - /// This error is returned by - /// [`RulesetCreatedAttr::add_rule()`](crate::RulesetCreatedAttr::add_rule) - /// if the related PathBeneath object is not set to best-effort, - /// and if its allowed access-rights contain directory-only ones - /// whereas the file descriptor doesn't point to a directory. - #[error("incompatible directory-only access-rights: {incompatible:?}")] - DirectoryAccess { - access: BitFlags, - incompatible: BitFlags, - }, -} - -#[derive(Debug, Error)] -// Exhaustive enum -pub enum AccessError -where - T: Access, -{ - /// The access-rights set is empty, which doesn't make sense and would be rejected by the - /// kernel. - #[error("empty access-right")] - Empty, - /// The access-rights set was forged with the unsafe `BitFlags::from_bits_unchecked()` and it - /// contains unknown bits. - #[error("unknown access-rights (at build time): {unknown:?}")] - Unknown { - access: BitFlags, - unknown: BitFlags, - }, - /// The best-effort approach was (deliberately) disabled and the requested access-rights are - /// fully incompatible with the running kernel. - #[error("fully incompatible access-rights: {access:?}")] - Incompatible { access: BitFlags }, - /// The best-effort approach was (deliberately) disabled and the requested access-rights are - /// partially incompatible with the running kernel. - #[error("partially incompatible access-rights: {incompatible:?}")] - PartiallyCompatible { - access: BitFlags, - incompatible: BitFlags, - }, -} - -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum RestrictSelfError { - /// The `prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)` system call failed. - #[error("failed to set no_new_privs: {source}")] - #[non_exhaustive] - SetNoNewPrivsCall { source: io::Error }, - /// The `landlock_restrict_self() `system call failed. - #[error("failed to restrict the calling thread: {source}")] - #[non_exhaustive] - RestrictSelfCall { source: io::Error }, -} - -#[derive(Debug, Error)] -#[non_exhaustive] -pub enum PathFdError { - /// The `open()` system call failed. - #[error("failed to open \"{path}\": {source}")] - #[non_exhaustive] - OpenCall { source: io::Error, path: PathBuf }, -} - -#[cfg(test)] -#[derive(Debug, Error)] -pub(crate) enum TestRulesetError { - #[error(transparent)] - Ruleset(#[from] RulesetError), - #[error(transparent)] - PathFd(#[from] PathFdError), - #[error(transparent)] - File(#[from] std::io::Error), -} - -/// Get the underlying errno value. -/// -/// This helper is useful for FFI to easily translate a Landlock error into an -/// errno value. -#[derive(Debug, PartialEq, Eq)] -pub struct Errno(c_int); - -impl Errno { - pub fn new(value: c_int) -> Self { - Self(value) - } -} - -impl From for Errno -where - T: std::error::Error, -{ - fn from(error: T) -> Self { - let default = libc::EINVAL; - if let Some(e) = error.source() { - if let Some(e) = e.downcast_ref::() { - return Errno(e.raw_os_error().unwrap_or(default)); - } - } - Errno(default) - } -} - -impl std::ops::Deref for Errno { - type Target = c_int; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -#[cfg(test)] -fn _test_ruleset_errno(expected_errno: c_int) { - use std::io::Error; - - let handle_access_err = RulesetError::HandleAccesses(HandleAccessesError::Fs( - HandleAccessError::Compat(CompatError::Access(AccessError::Empty)), - )); - assert_eq!(*Errno::from(handle_access_err), libc::EINVAL); - - let create_ruleset_err = RulesetError::CreateRuleset(CreateRulesetError::CreateRulesetCall { - source: Error::from_raw_os_error(expected_errno), - }); - assert_eq!(*Errno::from(create_ruleset_err), expected_errno); - - let add_rules_fs_err = RulesetError::AddRules(AddRulesError::Fs(AddRuleError::AddRuleCall { - source: Error::from_raw_os_error(expected_errno), - })); - assert_eq!(*Errno::from(add_rules_fs_err), expected_errno); - - let add_rules_net_err = RulesetError::AddRules(AddRulesError::Net(AddRuleError::AddRuleCall { - source: Error::from_raw_os_error(expected_errno), - })); - assert_eq!(*Errno::from(add_rules_net_err), expected_errno); - - let add_rules_other_err = - RulesetError::AddRules(AddRulesError::Fs(AddRuleError::UnhandledAccess { - access: AccessFs::Execute.into(), - incompatible: BitFlags::::EMPTY, - })); - assert_eq!(*Errno::from(add_rules_other_err), libc::EINVAL); - - let restrict_self_err = RulesetError::RestrictSelf(RestrictSelfError::RestrictSelfCall { - source: Error::from_raw_os_error(expected_errno), - }); - assert_eq!(*Errno::from(restrict_self_err), expected_errno); - - let set_no_new_privs_err = RulesetError::RestrictSelf(RestrictSelfError::SetNoNewPrivsCall { - source: Error::from_raw_os_error(expected_errno), - }); - assert_eq!(*Errno::from(set_no_new_privs_err), expected_errno); - - let create_ruleset_missing_err = - RulesetError::CreateRuleset(CreateRulesetError::MissingHandledAccess); - assert_eq!(*Errno::from(create_ruleset_missing_err), libc::EINVAL); - - let restrict_self_flags_err = RulesetError::RestrictSelfFlags(SyscallFlagError::NotSupported { - flag: RestrictSelfFlag::LogSameExec, - set: false, - }); - assert_eq!(*Errno::from(restrict_self_flags_err), libc::EINVAL); -} - -#[test] -fn test_ruleset_errno() { - _test_ruleset_errno(libc::EACCES); - _test_ruleset_errno(libc::EIO); -} diff --git a/third_party/rust-landlock/src/flags.rs b/third_party/rust-landlock/src/flags.rs deleted file mode 100644 index 0f0b118..0000000 --- a/third_party/rust-landlock/src/flags.rs +++ /dev/null @@ -1,219 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 OR MIT - -//! Syscall flag types for `landlock_restrict_self()` and future syscall flags. -//! -//! This module provides a compatibility mechanism for individual syscall flags, -//! parallel to but simpler than the [`Access`](crate::Access) / -//! [`TryCompat`](crate::TryCompat) machinery. -//! -//! Key differences from the [`Access`](crate::Access) pattern: -//! -//! - Access types ([`AccessFs`](crate::AccessFs), [`AccessNet`](crate::AccessNet), -//! [`Scope`](crate::Scope)) operate on **sets** of rights passed as -//! [`BitFlags`](enumflags2::BitFlags), where the compat result can be Full, -//! Partial, or No depending on which bits are supported. They use the -//! [`TryCompat`](crate::TryCompat) trait and the full -//! [`AccessError`](crate::AccessError) hierarchy. -//! -//! - Syscall flags are set **individually** through boolean setter methods -//! (e.g., [`log_same_exec()`](crate::RulesetCreatedAttr::log_same_exec)), so -//! the compat result is binary: supported or not. There is no Partial case, -//! no set-level validation, and no need for an -//! [`AccessError`](crate::AccessError)-like hierarchy. The -//! [`try_compat()`](SyscallFlagExt::try_compat) default method on -//! [`SyscallFlagExt`] handles the compat dispatch directly, using -//! [`Compatibility::try_compat_binary()`](crate::compat::Compatibility::try_compat_binary) -//! factored out from the No branch of -//! [`TryCompat::try_compat()`](crate::TryCompat::try_compat). -//! -//! - Access types implement the [`Access`](crate::Access) trait (which requires -//! [`BitFlag`](enumflags2::BitFlag)) and are used with -//! [`BitFlags`](enumflags2::BitFlags) throughout the builder. Syscall -//! flags are plain enums (no `#[bitflags]`); the raw UAPI bitmask is built -//! internally by OR-ing the constants returned by -//! [`raw_bit()`](SyscallFlagExt::raw_bit) into a `u32`. - -use crate::compat::{Compatibility, ABI}; -use crate::errors::SyscallFlagError; -use crate::private; -use crate::uapi; - -/// Marker trait for syscall flag types used in compatibility checks. -/// -/// This is the syscall-flag equivalent of the [`Access`](crate::Access) trait, -/// but without the [`BitFlag`](enumflags2::BitFlag) requirement since syscall -/// flags are set individually rather than as sets. -/// -/// This trait is sealed and cannot be implemented outside of this crate. -pub trait SyscallFlag: Copy + core::fmt::Debug + private::Sealed {} - -/// Internal extension providing compatibility logic for syscall flags. -/// -/// This is the syscall-flag equivalent of [`TryCompat`](crate::TryCompat), -/// simplified for single-flag boolean setters where the compat result is -/// binary (supported or not) rather than Full/Partial/No. -/// -/// Implementors provide [`default_value()`](Self::default_value), -/// [`raw_bit()`](Self::raw_bit), and [`since()`](Self::since); the -/// [`try_compat()`](Self::try_compat) default method handles the compat -/// state update and level dispatch. -pub(crate) trait SyscallFlagExt: SyscallFlag { - /// Returns the kernel's default state for this flag. - fn default_value(self) -> bool; - - /// Returns the raw UAPI constant for this flag. - fn raw_bit(self) -> u32; - - /// Returns the minimum ABI version that supports this flag. - fn since(self) -> ABI; - - /// Checks compatibility and returns whether the non-default bit should - /// be applied. - /// - /// Returns `Ok(true)` if [`raw_bit()`](Self::raw_bit) should be set in - /// the actual flags. Returns `Ok(false)` if the flag should be cleared - /// to its default state, either because `set == default_value()` or - /// because the kernel does not support the flag and the compat level - /// permits dropping it. Returns `Err` if the flag is unsupported with - /// [`CompatLevel::HardRequirement`](crate::CompatLevel::HardRequirement). - /// - /// Setting to the default value never requires a compat check. - /// - /// This mirrors the compat dispatch in - /// [`TryCompat::try_compat()`](crate::TryCompat::try_compat) but without - /// the Partial case (a single flag is either fully supported or not). - fn try_compat( - self, - set: bool, - compat: &mut Compatibility, - ) -> Result> { - if set == self.default_value() { - // Setting to the default value is always safe; no compat check - // needed and the caller should clear the bit. - return Ok(false); - } - compat.try_compat_binary(compat.abi() >= self.since(), || { - SyscallFlagError::NotSupported { flag: self, set } - }) - } -} - -/// Identifies a configuration flag for the `landlock_restrict_self()` syscall. -/// -/// Unlike access rights ([`AccessFs`](crate::AccessFs), [`AccessNet`](crate::AccessNet)) -/// and scopes ([`Scope`](crate::Scope)), these flags are not passed to -/// `landlock_create_ruleset()` but to `landlock_restrict_self()`. They control -/// audit logging behavior and thread synchronization rather than access -/// restrictions. -/// -/// Each flag is set through a dedicated boolean method on -/// [`RulesetCreatedAttr`](crate::RulesetCreatedAttr) or -/// [`RestrictSelfAttr`](crate::RestrictSelfAttr). The polarity mapping -/// (e.g., `log_same_exec(false)` maps to `LANDLOCK_RESTRICT_SELF_LOG_SAME_EXEC_OFF`) -/// is handled internally. -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -#[non_exhaustive] -pub enum RestrictSelfFlag { - /// Same-exec logging (see [`RulesetCreatedAttr::log_same_exec()`](crate::RulesetCreatedAttr::log_same_exec)). - LogSameExec, - /// New-exec logging (see [`RulesetCreatedAttr::log_new_exec()`](crate::RulesetCreatedAttr::log_new_exec)). - LogNewExec, - /// Subdomain logging (see [`RestrictSelfAttr::log_subdomains()`](crate::RestrictSelfAttr::log_subdomains)). - LogSubdomains, - /// Apply the restriction to all threads of the process - /// (see [`RestrictSelfAttr::all_threads()`](crate::RestrictSelfAttr::all_threads)). - AllThreads, -} - -impl SyscallFlag for RestrictSelfFlag {} - -impl RestrictSelfFlag { - /// Returns the effective state of this flag given a raw bitmask of - /// applied flags. - pub(crate) fn is_set(self, raw_flags: u32) -> bool { - // Each flag's raw_bit() returns either an OFF or ON UAPI constant - // depending on the kernel's default for that flag. When the bit - // is set in raw_flags, the kernel applies the opposite of the - // default; when unset, the default applies. - if raw_flags & self.raw_bit() != 0 { - !self.default_value() - } else { - self.default_value() - } - } -} - -impl SyscallFlagExt for RestrictSelfFlag { - fn default_value(self) -> bool { - match self { - // Same-exec logging is enabled by default. - Self::LogSameExec => true, - // New-exec logging is disabled by default. - Self::LogNewExec => false, - // Subdomain logging is enabled by default. - Self::LogSubdomains => true, - // Only the calling thread is restricted by default. - Self::AllThreads => false, - } - } - - fn raw_bit(self) -> u32 { - match self { - Self::LogSameExec => uapi::LANDLOCK_RESTRICT_SELF_LOG_SAME_EXEC_OFF, - Self::LogNewExec => uapi::LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON, - Self::LogSubdomains => uapi::LANDLOCK_RESTRICT_SELF_LOG_SUBDOMAINS_OFF, - Self::AllThreads => uapi::LANDLOCK_RESTRICT_SELF_TSYNC, - } - } - - fn since(self) -> ABI { - match self { - Self::LogSameExec => ABI::V7, - Self::LogNewExec => ABI::V7, - Self::LogSubdomains => ABI::V7, - Self::AllThreads => ABI::V8, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::uapi; - - #[test] - fn restrict_self_flag_raw_bit() { - assert_eq!( - RestrictSelfFlag::LogSameExec.raw_bit(), - uapi::LANDLOCK_RESTRICT_SELF_LOG_SAME_EXEC_OFF, - ); - assert_eq!( - RestrictSelfFlag::LogNewExec.raw_bit(), - uapi::LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON, - ); - assert_eq!( - RestrictSelfFlag::LogSubdomains.raw_bit(), - uapi::LANDLOCK_RESTRICT_SELF_LOG_SUBDOMAINS_OFF, - ); - assert_eq!( - RestrictSelfFlag::AllThreads.raw_bit(), - uapi::LANDLOCK_RESTRICT_SELF_TSYNC, - ); - } - - #[test] - fn restrict_self_flag_default_value() { - assert!(RestrictSelfFlag::LogSameExec.default_value()); - assert!(!RestrictSelfFlag::LogNewExec.default_value()); - assert!(RestrictSelfFlag::LogSubdomains.default_value()); - assert!(!RestrictSelfFlag::AllThreads.default_value()); - } - - #[test] - fn restrict_self_flag_since() { - assert_eq!(RestrictSelfFlag::LogSameExec.since(), ABI::V7); - assert_eq!(RestrictSelfFlag::LogNewExec.since(), ABI::V7); - assert_eq!(RestrictSelfFlag::LogSubdomains.since(), ABI::V7); - assert_eq!(RestrictSelfFlag::AllThreads.since(), ABI::V8); - } -} diff --git a/third_party/rust-landlock/src/fs.rs b/third_party/rust-landlock/src/fs.rs deleted file mode 100644 index 8473538..0000000 --- a/third_party/rust-landlock/src/fs.rs +++ /dev/null @@ -1,647 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 OR MIT - -use crate::compat::private::OptionCompatLevelMut; -use crate::{ - uapi, Access, AddRuleError, AddRulesError, CompatError, CompatLevel, CompatResult, CompatState, - Compatible, HandleAccessError, HandleAccessesError, HandledAccess, PathBeneathError, - PathFdError, PrivateHandledAccess, PrivateRule, Rule, Ruleset, RulesetCreated, RulesetError, - TailoredCompatLevel, TryCompat, ABI, -}; -use enumflags2::{bitflags, make_bitflags, BitFlags}; -use std::fs::OpenOptions; -use std::io::Error; -use std::mem::zeroed; -use std::os::unix::fs::OpenOptionsExt; -use std::os::unix::io::{AsFd, AsRawFd, BorrowedFd, OwnedFd}; -use std::path::Path; - -#[cfg(test)] -use crate::{RulesetAttr, RulesetCreatedAttr}; -#[cfg(test)] -use strum::IntoEnumIterator; - -/// File system access right. -/// -/// Each variant of `AccessFs` is an [access right](https://www.kernel.org/doc/html/latest/userspace-api/landlock.html#access-rights) -/// for the file system. -/// A set of access rights can be created with [`BitFlags`](BitFlags). -/// -/// # Example -/// -/// ``` -/// use landlock::{ABI, Access, AccessFs, BitFlags, make_bitflags}; -/// -/// let exec = AccessFs::Execute; -/// -/// let exec_set: BitFlags = exec.into(); -/// -/// let file_content = make_bitflags!(AccessFs::{Execute | WriteFile | ReadFile}); -/// -/// let fs_v1 = AccessFs::from_all(ABI::V1); -/// -/// let without_exec = fs_v1 & !AccessFs::Execute; -/// -/// assert_eq!(fs_v1 | AccessFs::Refer, AccessFs::from_all(ABI::V2)); -/// ``` -/// -/// # Warning -/// -/// To avoid unknown restrictions **don't use `BitFlags::::all()` nor `BitFlags::ALL`**, -/// but use a version you tested and vetted instead, -/// for instance [`AccessFs::from_all(ABI::V1)`](Access::from_all). -/// Direct use of **the [`BitFlags`] API is deprecated**. -/// See [`ABI`] for the rationale and help to test it. -#[bitflags] -#[repr(u64)] -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -#[non_exhaustive] -pub enum AccessFs { - /// Execute a file. - Execute = uapi::LANDLOCK_ACCESS_FS_EXECUTE as u64, - /// Open a file with write access. - /// - /// # Note - /// - /// Certain operations (such as [`std::fs::write`]) may also require [`AccessFs::Truncate`] since [`ABI::V3`]. - WriteFile = uapi::LANDLOCK_ACCESS_FS_WRITE_FILE as u64, - /// Open a file with read access. - ReadFile = uapi::LANDLOCK_ACCESS_FS_READ_FILE as u64, - /// Open a directory or list its content. - ReadDir = uapi::LANDLOCK_ACCESS_FS_READ_DIR as u64, - /// Remove an empty directory or rename one. - RemoveDir = uapi::LANDLOCK_ACCESS_FS_REMOVE_DIR as u64, - /// Unlink (or rename) a file. - RemoveFile = uapi::LANDLOCK_ACCESS_FS_REMOVE_FILE as u64, - /// Create (or rename or link) a character device. - MakeChar = uapi::LANDLOCK_ACCESS_FS_MAKE_CHAR as u64, - /// Create (or rename) a directory. - MakeDir = uapi::LANDLOCK_ACCESS_FS_MAKE_DIR as u64, - /// Create (or rename or link) a regular file. - MakeReg = uapi::LANDLOCK_ACCESS_FS_MAKE_REG as u64, - /// Create (or rename or link) a UNIX domain socket. - MakeSock = uapi::LANDLOCK_ACCESS_FS_MAKE_SOCK as u64, - /// Create (or rename or link) a named pipe. - MakeFifo = uapi::LANDLOCK_ACCESS_FS_MAKE_FIFO as u64, - /// Create (or rename or link) a block device. - MakeBlock = uapi::LANDLOCK_ACCESS_FS_MAKE_BLOCK as u64, - /// Create (or rename or link) a symbolic link. - MakeSym = uapi::LANDLOCK_ACCESS_FS_MAKE_SYM as u64, - /// Link or rename a file from or to a different directory. - Refer = uapi::LANDLOCK_ACCESS_FS_REFER as u64, - /// Truncate a file with `truncate(2)`, `ftruncate(2)`, `creat(2)`, or `open(2)` with `O_TRUNC`. - Truncate = uapi::LANDLOCK_ACCESS_FS_TRUNCATE as u64, - /// Send IOCL commands to a device file. - IoctlDev = uapi::LANDLOCK_ACCESS_FS_IOCTL_DEV as u64, - /// Connect to a pathname AF_UNIX socket (V9+). - ResolveUnix = uapi::LANDLOCK_ACCESS_FS_RESOLVE_UNIX as u64, -} - -impl Access for AccessFs { - /// Union of [`from_read()`](AccessFs::from_read) and [`from_write()`](AccessFs::from_write). - fn from_all(abi: ABI) -> BitFlags { - // An empty access-right would be an error if passed to the kernel, but because the kernel - // doesn't support Landlock, no Landlock syscall should be called. try_compat() should - // also return RestrictionStatus::Unrestricted when called with unsupported/empty - // access-rights. - Self::from_read(abi) | Self::from_write(abi) - } -} - -impl AccessFs { - // Roughly read (i.e. not all FS actions are handled). - /// Gets the access rights identified as read-only according to a specific ABI. - /// Exclusive with [`from_write()`](AccessFs::from_write). - pub fn from_read(abi: ABI) -> BitFlags { - match abi { - ABI::Unsupported => BitFlags::EMPTY, - ABI::V9 => Self::from_read(ABI::V8), - ABI::V1 | ABI::V2 | ABI::V3 | ABI::V4 | ABI::V5 | ABI::V6 | ABI::V7 | ABI::V8 => { - make_bitflags!(AccessFs::{ - Execute - | ReadFile - | ReadDir - }) - } - } - } - - // Roughly write (i.e. not all FS actions are handled). - /// Gets the access rights identified as write-only according to a specific ABI. - /// Exclusive with [`from_read()`](AccessFs::from_read). - pub fn from_write(abi: ABI) -> BitFlags { - match abi { - ABI::Unsupported => BitFlags::EMPTY, - ABI::V1 => make_bitflags!(AccessFs::{ - WriteFile - | RemoveDir - | RemoveFile - | MakeChar - | MakeDir - | MakeReg - | MakeSock - | MakeFifo - | MakeBlock - | MakeSym - }), - ABI::V2 => Self::from_write(ABI::V1) | AccessFs::Refer, - ABI::V3 | ABI::V4 => Self::from_write(ABI::V2) | AccessFs::Truncate, - ABI::V5 | ABI::V6 | ABI::V7 | ABI::V8 => Self::from_write(ABI::V4) | AccessFs::IoctlDev, - ABI::V9 => Self::from_write(ABI::V8) | AccessFs::ResolveUnix, - } - } - - /// Gets the access rights legitimate for non-directory files. - pub fn from_file(abi: ABI) -> BitFlags { - Self::from_all(abi) & ACCESS_FILE - } -} - -#[test] -fn consistent_access_fs_rw() { - for abi in ABI::iter() { - let access_all = AccessFs::from_all(abi); - let access_read = AccessFs::from_read(abi); - let access_write = AccessFs::from_write(abi); - let access_file = AccessFs::from_file(abi); - assert_eq!(access_read, !access_write & access_all); - assert_eq!(access_read | access_write, access_all); - assert_eq!(access_file, access_all & ACCESS_FILE); - } -} - -impl HandledAccess for AccessFs {} - -impl PrivateHandledAccess for AccessFs { - fn ruleset_handle_access( - ruleset: &mut Ruleset, - access: BitFlags, - ) -> Result<(), HandleAccessesError> { - // We need to record the requested accesses for PrivateRule::check_consistency(). - ruleset.requested_handled_fs |= access; - ruleset.actual_handled_fs |= match access - .try_compat( - ruleset.compat.abi(), - ruleset.compat.level, - &mut ruleset.compat.state, - ) - .map_err(HandleAccessError::Compat)? - { - Some(a) => a, - None => return Ok(()), - }; - Ok(()) - } - - fn into_add_rules_error(error: AddRuleError) -> AddRulesError { - AddRulesError::Fs(error) - } - - fn into_handle_accesses_error(error: HandleAccessError) -> HandleAccessesError { - HandleAccessesError::Fs(error) - } -} - -// TODO: Make ACCESS_FILE a property of AccessFs. -// TODO: Add tests for ACCESS_FILE. -const ACCESS_FILE: BitFlags = make_bitflags!(AccessFs::{ - ReadFile | WriteFile | Execute | Truncate | IoctlDev -}); - -// XXX: What should we do when a stat call failed? -fn is_file(fd: F) -> Result -where - F: AsFd, -{ - unsafe { - let mut stat = zeroed(); - match libc::fstat(fd.as_fd().as_raw_fd(), &mut stat) { - 0 => Ok((stat.st_mode & libc::S_IFMT) != libc::S_IFDIR), - _ => Err(Error::last_os_error()), - } - } -} - -/// Landlock rule for a file hierarchy. -/// -/// # Example -/// -/// ``` -/// use landlock::{AccessFs, PathBeneath, PathFd, PathFdError}; -/// -/// fn home_dir() -> Result, PathFdError> { -/// Ok(PathBeneath::new(PathFd::new("/home")?, AccessFs::ReadDir)) -/// } -/// ``` -#[derive(Debug)] -pub struct PathBeneath { - attr: uapi::landlock_path_beneath_attr, - // Ties the lifetime of a file descriptor to this object. - parent_fd: F, - allowed_access: BitFlags, - compat_level: Option, -} - -impl PathBeneath -where - F: AsFd, -{ - /// Creates a new `PathBeneath` rule identifying the `parent` directory of a file hierarchy, - /// or just a file, and allows `access` on it. - /// The `parent` file descriptor will be automatically closed with the returned `PathBeneath`. - pub fn new(parent: F, access: A) -> Self - where - A: Into>, - { - PathBeneath { - // Invalid access rights until as_ptr() is called. - attr: unsafe { zeroed() }, - parent_fd: parent, - allowed_access: access.into(), - compat_level: None, - } - } -} - -impl TryCompat for PathBeneath -where - F: AsFd, -{ - fn try_compat_children( - mut self, - abi: ABI, - parent_level: L, - compat_state: &mut CompatState, - ) -> Result, CompatError> - where - L: Into, - { - // Checks with our own compatibility level, if any. - self.allowed_access = match self.allowed_access.try_compat( - abi, - self.tailored_compat_level(parent_level), - compat_state, - )? { - Some(a) => a, - None => return Ok(None), - }; - Ok(Some(self)) - } - - fn try_compat_inner( - &mut self, - _abi: ABI, - ) -> Result, CompatError> { - // Gets subset of valid accesses according the FD type. - let valid_access = - if is_file(&self.parent_fd).map_err(|e| PathBeneathError::StatCall { source: e })? { - self.allowed_access & ACCESS_FILE - } else { - self.allowed_access - }; - - if self.allowed_access != valid_access { - let error = PathBeneathError::DirectoryAccess { - access: self.allowed_access, - incompatible: self.allowed_access ^ valid_access, - } - .into(); - self.allowed_access = valid_access; - // Linux would return EINVAL. - Ok(CompatResult::Partial(error)) - } else { - Ok(CompatResult::Full) - } - } -} - -#[test] -fn path_beneath_try_compat_children() { - use crate::*; - - // AccessFs::Refer is not handled by ABI::V1 and only for directories. - let access_file = AccessFs::ReadFile | AccessFs::Refer; - - // Test error ordering with ABI::V1 - let mut ruleset = Ruleset::from(ABI::V1).handle_access(access_file).unwrap(); - // Do not actually perform any syscall. - ruleset.compat.state = CompatState::Dummy; - assert!(matches!( - RulesetCreated::new(ruleset, None) - .set_compatibility(CompatLevel::HardRequirement) - .add_rule(PathBeneath::new(PathFd::new("/dev/null").unwrap(), access_file)) - .unwrap_err(), - RulesetError::AddRules(AddRulesError::Fs(AddRuleError::Compat( - CompatError::PathBeneath(PathBeneathError::DirectoryAccess { access, incompatible }) - ))) if access == access_file && incompatible == AccessFs::Refer - )); - - // Test error ordering with ABI::V2 - let mut ruleset = Ruleset::from(ABI::V2).handle_access(access_file).unwrap(); - // Do not actually perform any syscall. - ruleset.compat.state = CompatState::Dummy; - assert!(matches!( - RulesetCreated::new(ruleset, None) - .set_compatibility(CompatLevel::HardRequirement) - .add_rule(PathBeneath::new(PathFd::new("/dev/null").unwrap(), access_file)) - .unwrap_err(), - RulesetError::AddRules(AddRulesError::Fs(AddRuleError::Compat( - CompatError::PathBeneath(PathBeneathError::DirectoryAccess { access, incompatible }) - ))) if access == access_file && incompatible == AccessFs::Refer - )); -} - -#[test] -fn path_beneath_try_compat() { - use crate::*; - - let abi = ABI::V1; - - for file in &["/etc/passwd", "/dev/null"] { - let mut compat_state = CompatState::Init; - let ro_access = AccessFs::ReadDir | AccessFs::ReadFile; - assert!(matches!( - PathBeneath::new(PathFd::new(file).unwrap(), ro_access) - .try_compat(abi, CompatLevel::HardRequirement, &mut compat_state) - .unwrap_err(), - CompatError::PathBeneath(PathBeneathError::DirectoryAccess { access, incompatible }) - if access == ro_access && incompatible == AccessFs::ReadDir - )); - - let mut compat_state = CompatState::Init; - assert!(matches!( - PathBeneath::new(PathFd::new(file).unwrap(), BitFlags::EMPTY) - .try_compat(abi, CompatLevel::BestEffort, &mut compat_state) - .unwrap_err(), - CompatError::Access(AccessError::Empty) - )); - } - - let full_access = AccessFs::from_all(ABI::V1); - for compat_level in &[ - CompatLevel::BestEffort, - CompatLevel::SoftRequirement, - CompatLevel::HardRequirement, - ] { - let mut compat_state = CompatState::Init; - let mut path_beneath = PathBeneath::new(PathFd::new("/").unwrap(), full_access) - .try_compat(abi, *compat_level, &mut compat_state) - .unwrap() - .unwrap(); - assert_eq!(compat_state, CompatState::Full); - - // Without synchronization. - let raw_access = path_beneath.attr.allowed_access; - assert_eq!(raw_access, 0); - - // Synchronize the inner attribute buffer. - let _ = path_beneath.as_ptr(); - let raw_access = path_beneath.attr.allowed_access; - assert_eq!(raw_access, full_access.bits()); - } -} - -impl OptionCompatLevelMut for PathBeneath { - fn as_option_compat_level_mut(&mut self) -> &mut Option { - &mut self.compat_level - } -} - -impl OptionCompatLevelMut for &mut PathBeneath { - fn as_option_compat_level_mut(&mut self) -> &mut Option { - &mut self.compat_level - } -} - -impl Compatible for PathBeneath {} - -impl Compatible for &mut PathBeneath {} - -#[test] -fn path_beneath_compatibility() { - let mut path = PathBeneath::new(PathFd::new("/").unwrap(), AccessFs::from_all(ABI::V1)); - let path_ref = &mut path; - - let level = path_ref.as_option_compat_level_mut(); - assert_eq!(level, &None); - assert_eq!( - as Into>::into(*level), - CompatLevel::BestEffort - ); - - path_ref.set_compatibility(CompatLevel::SoftRequirement); - assert_eq!( - path_ref.as_option_compat_level_mut(), - &Some(CompatLevel::SoftRequirement) - ); - - path.set_compatibility(CompatLevel::HardRequirement); -} - -// It is useful for documentation generation to explicitely implement Rule for every types, instead -// of doing it generically. -impl Rule for PathBeneath where F: AsFd {} - -impl PrivateRule for PathBeneath -where - F: AsFd, -{ - const TYPE_ID: uapi::landlock_rule_type = uapi::landlock_rule_type_LANDLOCK_RULE_PATH_BENEATH; - - fn as_ptr(&mut self) -> *const libc::c_void { - self.attr.parent_fd = self.parent_fd.as_fd().as_raw_fd(); - self.attr.allowed_access = self.allowed_access.bits(); - &self.attr as *const _ as _ - } - - fn check_consistency(&self, ruleset: &RulesetCreated) -> Result<(), AddRulesError> { - // Checks that this rule doesn't contain a superset of the access-rights handled by the - // ruleset. This check is about requested access-rights but not actual access-rights. - // Indeed, we want to get a deterministic behavior, i.e. not based on the running kernel - // (which is handled by Ruleset and RulesetCreated). - if ruleset.requested_handled_fs.contains(self.allowed_access) { - Ok(()) - } else { - Err(AddRuleError::UnhandledAccess { - access: self.allowed_access, - incompatible: self.allowed_access & !ruleset.requested_handled_fs, - } - .into()) - } - } -} - -#[test] -fn path_beneath_check_consistency() { - use crate::*; - - let ro_access = AccessFs::ReadDir | AccessFs::ReadFile; - let rx_access = AccessFs::Execute | AccessFs::ReadFile; - assert!(matches!( - Ruleset::from(ABI::Unsupported) - .handle_access(ro_access) - .unwrap() - .create() - .unwrap() - .add_rule(PathBeneath::new(PathFd::new("/").unwrap(), rx_access)) - .unwrap_err(), - RulesetError::AddRules(AddRulesError::Fs(AddRuleError::UnhandledAccess { access, incompatible })) - if access == rx_access && incompatible == AccessFs::Execute - )); -} - -/// Simple helper to open a file or a directory with the `O_PATH` flag. -/// -/// This is the recommended way to identify a path -/// and manage the lifetime of the underlying opened file descriptor. -/// Indeed, using other [`AsFd`] implementations such as [`File`] brings more complexity -/// and may lead to unexpected errors (e.g., denied access). -/// -/// [`File`]: std::fs::File -/// -/// # Example -/// -/// ``` -/// use landlock::{AccessFs, PathBeneath, PathFd, PathFdError}; -/// -/// fn allowed_root_dir(access: AccessFs) -> Result, PathFdError> { -/// let fd = PathFd::new("/")?; -/// Ok(PathBeneath::new(fd, access)) -/// } -/// ``` -#[derive(Debug)] -pub struct PathFd { - fd: OwnedFd, -} - -impl PathFd { - pub fn new(path: T) -> Result - where - T: AsRef, - { - Ok(PathFd { - fd: OpenOptions::new() - .read(true) - // If the O_PATH is not supported, it is automatically ignored (Linux < 2.6.39). - .custom_flags(libc::O_PATH | libc::O_CLOEXEC) - .open(path.as_ref()) - .map_err(|e| PathFdError::OpenCall { - source: e, - path: path.as_ref().into(), - })? - .into(), - }) - } -} - -impl AsFd for PathFd { - fn as_fd(&self) -> BorrowedFd<'_> { - self.fd.as_fd() - } -} - -#[test] -fn path_fd() { - use std::fs::File; - use std::io::Read; - - PathBeneath::new(PathFd::new("/").unwrap(), AccessFs::Execute); - PathBeneath::new(File::open("/").unwrap(), AccessFs::Execute); - - let mut buffer = [0; 1]; - // Checks that PathFd really returns an FD opened with O_PATH (Bad file descriptor error). - File::from(PathFd::new("/etc/passwd").unwrap().fd) - .read(&mut buffer) - .unwrap_err(); -} - -/// Helper to quickly create an iterator of PathBeneath rules. -/// -/// # Note -/// -/// From the kernel's perspective, Landlock rules operate on file descriptors, not paths. -/// This is a helper to create rules based on paths. Here, `path_beneath_rules()` silently ignores -/// paths that cannot be opened, hence making the obtainment of a file descriptor impossible. When -/// possible and for a given path, `path_beneath_rules()` automatically adjusts [access rights](`AccessFs`), -/// depending on whether a directory or a file is present at that said path. -/// -/// This behavior is the result of [`CompatLevel::BestEffort`], which is the default compatibility level of -/// all created rulesets. Thus, it applies to the example below. However, if [`CompatLevel::HardRequirement`] -/// is set using [`Compatible::set_compatibility`], attempting to create an incompatible rule at runtime will cause -/// this crate to raise an error instead. -/// -/// # Example -/// -/// ``` -/// use landlock::{ -/// ABI, Access, AccessFs, Ruleset, RulesetAttr, RulesetCreatedAttr, RulesetStatus, RulesetError, -/// path_beneath_rules, -/// }; -/// -/// fn restrict_thread() -> Result<(), RulesetError> { -/// let abi = ABI::V1; -/// let status = Ruleset::default() -/// .handle_access(AccessFs::from_all(abi))? -/// .create()? -/// // Read-only access to /usr, /etc and /dev. -/// .add_rules(path_beneath_rules(&["/usr", "/etc", "/dev"], AccessFs::from_read(abi)))? -/// // Read-write access to /home and /tmp. -/// .add_rules(path_beneath_rules(&["/home", "/tmp"], AccessFs::from_all(abi)))? -/// .restrict_self()?; -/// match status.ruleset { -/// // The FullyEnforced case must be tested by the developer. -/// RulesetStatus::FullyEnforced => println!("Fully sandboxed."), -/// RulesetStatus::PartiallyEnforced => println!("Partially sandboxed."), -/// // Users should be warned that they are not protected. -/// RulesetStatus::NotEnforced => println!("Not sandboxed! Please update your kernel."), -/// } -/// Ok(()) -/// } -/// ``` -pub fn path_beneath_rules( - paths: I, - access: A, -) -> impl Iterator, RulesetError>> -where - I: IntoIterator, - P: AsRef, - A: Into>, -{ - let access = access.into(); - paths.into_iter().filter_map(move |p| match PathFd::new(p) { - Ok(f) => { - let valid_access = match is_file(&f) { - Ok(true) => access & ACCESS_FILE, - // If the stat call failed, let's blindly rely on the requested access rights. - Err(_) | Ok(false) => access, - }; - Some(Ok(PathBeneath::new(f, valid_access))) - } - Err(_) => None, - }) -} - -#[test] -fn path_beneath_rules_iter() { - let _ = Ruleset::default() - .handle_access(AccessFs::from_all(ABI::V1)) - .unwrap() - .create() - .unwrap() - .add_rules(path_beneath_rules( - &["/usr", "/opt", "/does-not-exist", "/root"], - AccessFs::Execute, - )) - .unwrap(); -} - -#[test] -fn resolve_unix_access() { - // ResolveUnix is only available starting from V9 (oring fork delta). - for abi in ABI::iter() { - let has = AccessFs::from_all(abi).contains(AccessFs::ResolveUnix); - assert_eq!(has, abi >= ABI::V9); - } - // Kept write-side only so read/write stay disjoint (consistent_access_fs_rw). - assert!(!AccessFs::from_read(ABI::V9).contains(AccessFs::ResolveUnix)); - assert!(AccessFs::from_write(ABI::V9).contains(AccessFs::ResolveUnix)); -} diff --git a/third_party/rust-landlock/src/lib.rs b/third_party/rust-landlock/src/lib.rs deleted file mode 100644 index 1681cae..0000000 --- a/third_party/rust-landlock/src/lib.rs +++ /dev/null @@ -1,780 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 OR MIT - -//! Landlock is a security feature available since Linux 5.13. -//! The goal is to enable to restrict ambient rights -//! (e.g., global filesystem access) -//! for a set of processes by creating safe security sandboxes as new security layers -//! in addition to the existing system-wide access-controls. -//! This kind of sandbox is expected to help mitigate the security impact of bugs, -//! unexpected or malicious behaviors in applications. -//! Landlock empowers any process, including unprivileged ones, to securely restrict themselves. -//! More information about Landlock can be found in the [official website](https://landlock.io). -//! -//! This crate provides a safe abstraction for the Landlock system calls, along with some helpers. -//! -//! Minimum Supported Rust Version (MSRV): 1.71 -//! -//! # Use cases -//! -//! This crate is especially useful to protect users' data by sandboxing: -//! * trusted applications dealing with potentially malicious data -//! (e.g., complex file format, network request) that could exploit security vulnerabilities; -//! * sandbox managers, container runtimes or shells launching untrusted applications. -//! -//! # Examples -//! -//! A simple example can be found with the [`path_beneath_rules()`] helper. -//! More complex examples can be found with the [`Ruleset` documentation](Ruleset) -//! and the [sandboxer example](https://github.com/landlock-lsm/rust-landlock/blob/master/examples/sandboxer.rs). -//! -//! # Current limitations -//! -//! This crate exposes the Landlock features available as of Linux 7.0 (Landlock [ABI v8](ABI::V8)) -//! and then inherits some [kernel limitations](https://www.kernel.org/doc/html/latest/userspace-api/landlock.html#current-limitations) -//! that will be addressed with future kernel releases -//! (e.g., arbitrary mounts are always denied). -//! -//! # Compatibility -//! -//! Types defined in this crate are designed to enable the strictest Landlock configuration -//! for the given kernel on which the program runs. -//! In the default [best-effort](CompatLevel::BestEffort) mode, -//! [`Ruleset`] will determine compatibility -//! with the intersection of the currently running kernel's features -//! and those required by the caller. -//! This way, callers can distinguish between -//! Landlock compatibility issues inherent to the current system -//! (e.g., file names that don't exist) -//! and misconfiguration that should be fixed in the program -//! (e.g., empty or inconsistent access rights). -//! [`RulesetError`] identifies such kind of errors. -//! -//! With [`set_compatibility(CompatLevel::BestEffort)`](Compatible::set_compatibility), -//! users of the crate may mark Landlock features that are deemed required -//! and other features that may be downgraded to use lower security on systems -//! where they can't be enforced. -//! It is discouraged to compare the system's provided [Landlock ABI](ABI) version directly, -//! as it is difficult to track detailed ABI differences -//! which are handled thanks to the [`Compatible`] trait. -//! -//! To make it easier to migrate to a new version of this library, -//! we use the builder pattern -//! and designed objects to require the minimal set of method arguments. -//! Most `enum` are marked as `non_exhaustive` to enable backward-compatible evolutions. -//! -//! ## Test strategy -//! -//! Developers should test their sandboxed applications -//! with a kernel that supports all requested Landlock features -//! and check that [`RulesetCreated::restrict_self()`] returns a status matching -//! [`Ok(RestrictionStatus { ruleset: RulesetStatus::FullyEnforced, no_new_privs: true, })`](RestrictionStatus) -//! to make sure everything works as expected in an enforced sandbox. -//! Alternatively, using [`set_compatibility(CompatLevel::HardRequirement)`](Compatible::set_compatibility) -//! will immediately inform about unsupported Landlock features. -//! These configurations should only depend on the test environment -//! (e.g. [by checking an environment variable](https://github.com/landlock-lsm/rust-landlock/search?q=LANDLOCK_CRATE_TEST_ABI)). -//! However, applications should only check that no error is returned (i.e. `Ok(_)`) -//! and optionally log and inform users that the application is not fully sandboxed -//! because of missing features from the running kernel. -//! -//! ## Audit logging -//! -//! Landlock ABI v7 adds control over audit logging through boolean setters, especially -//! [`log_new_exec()`](RulesetCreatedAttr::log_new_exec)) which is useful (but noisy) for sandboxer -//! tools. -//! -//! ## Multithreaded processes -//! -//! By default `landlock_restrict_self()` only restricts the calling thread. Landlock ABI v8 adds -//! [`all_threads()`](RestrictSelfAttr::all_threads) to atomically enforce the configuration on -//! every thread of the process. On an older kernel this is silently dropped in the default -//! best-effort mode, leaving sibling and parent threads unrestricted, so multithreaded programs -//! that need this guarantee should require it (see -//! [`all_threads()`](RestrictSelfAttr::all_threads)). - -#[cfg(test)] -#[macro_use] -extern crate lazy_static; - -pub use access::{Access, HandledAccess}; -pub use compat::{CompatLevel, Compatible, LandlockStatus, ABI}; -pub use enumflags2::{make_bitflags, BitFlags}; -pub use errata::Erratum; -pub use errors::{ - AccessError, AddRuleError, AddRulesError, CompatError, CreateRulesetError, Errno, - HandleAccessError, HandleAccessesError, PathBeneathError, PathFdError, RestrictSelfError, - RulesetError, ScopeError, SyscallFlagError, -}; -pub use flags::{RestrictSelfFlag, SyscallFlag}; -pub use fs::{path_beneath_rules, AccessFs, PathBeneath, PathFd}; -pub use net::{AccessNet, NetPort}; -pub use restrict_self::{RestrictSelf, RestrictSelfAttr, RestrictSelfStatus}; -pub use ruleset::{ - RestrictionStatus, Rule, Ruleset, RulesetAttr, RulesetCreated, RulesetCreatedAttr, - RulesetStatus, -}; -pub use scope::Scope; - -use access::PrivateHandledAccess; -use compat::{CompatResult, CompatState, Compatibility, TailoredCompatLevel, TryCompat}; -use ruleset::PrivateRule; - -#[cfg(test)] -use compat::{can_emulate, get_errno_from_landlock_status}; -#[cfg(test)] -use errors::TestRulesetError; -#[cfg(test)] -use strum::IntoEnumIterator; - -mod access; -mod compat; -mod errata; -mod errors; -mod flags; -mod fs; -mod net; -mod prctl; -mod restrict_self; -mod ruleset; -mod scope; -mod uapi; - -// Makes sure private traits cannot be implemented outside of this crate. -mod private { - pub trait Sealed {} - - impl Sealed for crate::AccessFs {} - impl Sealed for crate::AccessNet {} - impl Sealed for crate::Scope {} - impl Sealed for crate::RestrictSelfFlag {} -} - -#[cfg(test)] -mod tests { - use crate::*; - - // These integration tests exercise the full builder-to-syscall path via - // check_ruleset_support(). Other tests in compat.rs and errata.rs make - // read-only kernel queries (LandlockStatus::current(), Erratum::current()) - // but do not create rulesets or restrict threads. All remaining tests use - // Ruleset::from(ABI) to exercise the builder logic and compatibility - // engine without kernel interaction. - - // Emulate old kernel supports. Iterates each ABI variant, mocks the - // builder via B::from(abi), runs the closure on a dedicated thread, and - // dispatches to the caller's assertion closures based on whether the - // mocked ABI is emulatable on the running kernel. - fn check_support( - partial: ABI, - full: Option, - check: F, - assert_ok: OkFn, - assert_err: ErrFn, - ) where - B: From + Send + 'static, - F: Fn(B) -> Result + Send + Copy + 'static, - S: std::fmt::Debug + Send + 'static, - OkFn: Fn(ABI, Result), - ErrFn: Fn(Result), - { - // If there is no partial support, it means that `full == partial`. - assert!(partial <= full.unwrap_or(partial)); - for abi in ABI::iter() { - // Ensures restrict_self() is called on a dedicated thread to avoid inconsistent tests. - let ret = std::thread::spawn(move || check(B::from(abi))) - .join() - .unwrap(); - - // Useful for failed tests and with cargo test -- --show-output - println!("Checking ABI {abi:?}: received {ret:#?}"); - if can_emulate(abi, partial, full) { - assert_ok(abi, ret); - } else { - assert_err(ret); - } - } - } - - fn check_ruleset_support( - partial: ABI, - full: Option, - check: F, - error_if_abi_lt_partial: bool, - ) where - F: Fn(Ruleset) -> Result + Send + Copy + 'static, - { - check_support( - partial, - full, - check, - |abi, ret| { - if abi < partial && error_if_abi_lt_partial { - // TODO: Check exact error type; this may require better error types. - assert!(matches!(ret, Err(TestRulesetError::Ruleset(_)))); - } else { - let full_support = if let Some(full_inner) = full { - abi >= full_inner - } else { - false - }; - let ruleset_status = if full_support { - RulesetStatus::FullyEnforced - } else if abi >= partial { - RulesetStatus::PartiallyEnforced - } else { - RulesetStatus::NotEnforced - }; - let landlock_status = abi.into(); - println!("Expecting ruleset status {ruleset_status:?}"); - println!("Expecting Landlock status {landlock_status:?}"); - assert!(matches!( - ret, - Ok(RestrictionStatus { - ruleset, - landlock, - no_new_privs: true, - .. - }) if ruleset == ruleset_status && landlock == landlock_status - )) - } - }, - |ret| { - // The errno value should be ENOSYS, EOPNOTSUPP, EINVAL (e.g. when an unknown - // access right is provided), or E2BIG (e.g. when there is an unknown field in a - // Landlock syscall attribute). - let errno = get_errno_from_landlock_status(); - println!("Expecting error {errno:?}"); - match ret { - Err( - ref error @ TestRulesetError::Ruleset(RulesetError::CreateRuleset( - CreateRulesetError::CreateRulesetCall { ref source }, - )), - ) => { - assert_eq!(source.raw_os_error(), Some(*Errno::from(error))); - match (source.raw_os_error(), errno) { - (Some(e1), Some(e2)) => assert_eq!(e1, e2), - (Some(e1), None) => assert!(matches!(e1, libc::EINVAL | libc::E2BIG)), - _ => unreachable!(), - } - } - // restrict_self flags may be rejected by the kernel with EINVAL - // when the mock ABI is higher than the running kernel's ABI. - Err(TestRulesetError::Ruleset(RulesetError::RestrictSelf( - RestrictSelfError::RestrictSelfCall { ref source }, - ))) => { - assert_eq!(source.raw_os_error(), Some(libc::EINVAL)); - } - _ => unreachable!(), - } - }, - ); - } - - // Emulate old kernel supports for the domain-less RestrictSelf builder. - // - // Unlike check_ruleset_support, RestrictSelf does not create a Landlock domain, so there is no - // ruleset enforcement status to assert. We verify the kernel probe (status.landlock) and - // propagate any syscall error. RestrictSelf::apply() enforces PR_SET_NO_NEW_PRIVS by default. - fn check_restrict_self_support(partial: ABI, full: Option, check: F) - where - F: Fn(RestrictSelf) -> Result + Send + Copy + 'static, - { - check_support( - partial, - full, - check, - |abi, ret| { - let landlock_status: LandlockStatus = abi.into(); - println!("Expecting Landlock status {landlock_status:?}"); - assert!(matches!( - ret, - Ok(RestrictSelfStatus { landlock, .. }) if landlock == landlock_status - )); - }, - |ret| { - // The errno value should be ENOSYS, EOPNOTSUPP, or EINVAL (e.g. when actual_flags - // carries bits unknown to the running kernel). Unlike check_ruleset_support, - // landlock_restrict_self() is the first syscall here, so ENOSYS is possible when - // Landlock is unavailable. - let errno = get_errno_from_landlock_status(); - println!("Expecting error {errno:?}"); - match ret { - Err( - ref error @ TestRulesetError::Ruleset(RulesetError::RestrictSelf( - RestrictSelfError::RestrictSelfCall { ref source }, - )), - ) => { - assert_eq!(source.raw_os_error(), Some(*Errno::from(error))); - match (source.raw_os_error(), errno) { - (Some(e1), Some(e2)) => assert_eq!(e1, e2), - (Some(e1), None) => assert!(matches!(e1, libc::EINVAL | libc::E2BIG)), - _ => unreachable!(), - } - } - _ => unreachable!(), - } - }, - ); - } - - #[test] - fn allow_root_compat() { - let abi = ABI::V1; - - check_ruleset_support( - abi, - Some(abi), - move |ruleset: Ruleset| -> _ { - Ok(ruleset - .handle_access(AccessFs::from_all(abi))? - .create()? - .add_rule(PathBeneath::new(PathFd::new("/")?, AccessFs::from_all(abi)))? - .restrict_self()?) - }, - false, - ); - } - - #[test] - fn too_much_access_rights_for_a_file() { - let abi = ABI::V1; - - check_ruleset_support( - abi, - Some(abi), - move |ruleset: Ruleset| -> _ { - Ok(ruleset - .handle_access(AccessFs::from_all(abi))? - .create()? - // Same code as allow_root_compat() but with /etc/passwd instead of / - .add_rule(PathBeneath::new( - PathFd::new("/etc/passwd")?, - // Only allow legitimate access rights on a file. - AccessFs::from_file(abi), - ))? - .restrict_self()?) - }, - false, - ); - - check_ruleset_support( - abi, - None, - move |ruleset: Ruleset| -> _ { - Ok(ruleset - .handle_access(AccessFs::from_all(abi))? - .create()? - // Same code as allow_root_compat() but with /etc/passwd instead of / - .add_rule(PathBeneath::new( - PathFd::new("/etc/passwd")?, - // Tries to allow all access rights on a file. - AccessFs::from_all(abi), - ))? - .restrict_self()?) - }, - false, - ); - } - - #[test] - fn path_beneath_rules_with_too_much_access_rights_for_a_file() { - let abi = ABI::V1; - - check_ruleset_support( - abi, - Some(abi), - move |ruleset: Ruleset| -> _ { - Ok(ruleset - .handle_access(AccessFs::from_all(ABI::V1))? - .create()? - // Same code as too_much_access_rights_for_a_file() but using path_beneath_rules() - .add_rules(path_beneath_rules(["/etc/passwd"], AccessFs::from_all(abi)))? - .restrict_self()?) - }, - false, - ); - } - - #[test] - fn allow_root_fragile() { - let abi = ABI::V1; - - check_ruleset_support( - abi, - Some(abi), - move |ruleset: Ruleset| -> _ { - // Sets default support requirement: abort the whole sandboxing for any Landlock error. - Ok(ruleset - // Must have at least the execute check… - .set_compatibility(CompatLevel::HardRequirement) - .handle_access(AccessFs::Execute)? - // …and possibly others. - .set_compatibility(CompatLevel::BestEffort) - .handle_access(AccessFs::from_all(abi))? - .create()? - .no_new_privs(true) - .add_rule(PathBeneath::new(PathFd::new("/")?, AccessFs::from_all(abi)))? - .restrict_self()?) - }, - true, - ); - } - - #[test] - fn ruleset_enforced() { - let abi = ABI::V1; - - check_ruleset_support( - abi, - Some(abi), - move |ruleset: Ruleset| -> _ { - Ok(ruleset - // Restricting without rule exceptions is legitimate to forbid a set of actions. - .handle_access(AccessFs::Execute)? - .create()? - .restrict_self()?) - }, - false, - ); - } - - #[test] - fn abi_v2_exec_refer() { - check_ruleset_support( - ABI::V1, - Some(ABI::V2), - move |ruleset: Ruleset| -> _ { - Ok(ruleset - .handle_access(AccessFs::Execute)? - // AccessFs::Refer is not supported by ABI::V1 (best-effort). - .handle_access(AccessFs::Refer)? - .create()? - .restrict_self()?) - }, - false, - ); - } - - #[test] - fn abi_v2_refer_only() { - // When no access is handled, do not try to create a ruleset without access. - check_ruleset_support( - ABI::V2, - Some(ABI::V2), - move |ruleset: Ruleset| -> _ { - Ok(ruleset - .handle_access(AccessFs::Refer)? - .create()? - .restrict_self()?) - }, - false, - ); - } - - #[test] - fn abi_v3_truncate() { - check_ruleset_support( - ABI::V2, - Some(ABI::V3), - move |ruleset: Ruleset| -> _ { - Ok(ruleset - .handle_access(AccessFs::Refer)? - .handle_access(AccessFs::Truncate)? - .create()? - .add_rule(PathBeneath::new(PathFd::new("/")?, AccessFs::Refer))? - .restrict_self()?) - }, - false, - ); - } - - #[test] - fn ruleset_created_try_clone() { - check_ruleset_support( - ABI::V1, - Some(ABI::V1), - move |ruleset: Ruleset| -> _ { - Ok(ruleset - .handle_access(AccessFs::Execute)? - .create()? - .add_rule(PathBeneath::new(PathFd::new("/")?, AccessFs::Execute))? - .try_clone()? - .restrict_self()?) - }, - false, - ); - } - - #[test] - fn abi_v4_tcp() { - check_ruleset_support( - ABI::V3, - Some(ABI::V4), - move |ruleset: Ruleset| -> _ { - Ok(ruleset - .handle_access(AccessFs::Truncate)? - .handle_access(AccessNet::BindTcp | AccessNet::ConnectTcp)? - .create()? - .add_rule(NetPort::new(1, AccessNet::ConnectTcp))? - .restrict_self()?) - }, - false, - ); - } - - #[test] - fn abi_v5_ioctl_dev() { - check_ruleset_support( - ABI::V4, - Some(ABI::V5), - move |ruleset: Ruleset| -> _ { - Ok(ruleset - .handle_access(AccessNet::BindTcp)? - .handle_access(AccessFs::IoctlDev)? - .create()? - .add_rule(PathBeneath::new(PathFd::new("/")?, AccessFs::IoctlDev))? - .restrict_self()?) - }, - false, - ); - } - - #[test] - fn abi_v6_scope_mix() { - check_ruleset_support( - ABI::V5, - Some(ABI::V6), - move |ruleset: Ruleset| -> _ { - Ok(ruleset - .handle_access(AccessFs::IoctlDev)? - .scope(Scope::AbstractUnixSocket | Scope::Signal)? - .create()? - .restrict_self()?) - }, - false, - ); - } - - #[test] - fn abi_v6_scope_only() { - check_ruleset_support( - ABI::V6, - Some(ABI::V6), - move |ruleset: Ruleset| -> _ { - Ok(ruleset - .scope(Scope::AbstractUnixSocket | Scope::Signal)? - .create()? - .restrict_self()?) - }, - false, - ); - } - - #[test] - fn abi_v7_log_flags() { - // Uses Scope::Signal to get partial enforcement at V6 (scopes supported - // but log flags not). - check_ruleset_support( - ABI::V6, - Some(ABI::V7), - move |ruleset: Ruleset| -> _ { - let status = ruleset - .scope(Scope::Signal)? - .create()? - .log_same_exec(false)? - .log_new_exec(true)? - .log_subdomains(false)? - .restrict_self()?; - - if status.ruleset == RulesetStatus::FullyEnforced { - assert!(!status.log_same_exec); - assert!(status.log_new_exec); - assert!(!status.log_subdomains); - } else { - assert!(status.log_same_exec); - assert!(!status.log_new_exec); - assert!(status.log_subdomains); - } - - Ok(status) - }, - false, - ); - } - - #[test] - fn ruleset_created_try_clone_ownedfd() { - use std::os::unix::io::{AsRawFd, OwnedFd}; - - let abi = ABI::V1; - check_ruleset_support( - abi, - Some(abi), - move |ruleset: Ruleset| -> _ { - let ruleset1 = ruleset.handle_access(AccessFs::from_all(abi))?.create()?; - let ruleset2 = ruleset1.try_clone().unwrap(); - let ruleset3 = ruleset2.try_clone().unwrap(); - - let some1: Option = ruleset1.into(); - if let Some(fd1) = some1 { - assert!(fd1.as_raw_fd() >= 0); - - let some2: Option = ruleset2.into(); - let fd2 = some2.unwrap(); - assert!(fd2.as_raw_fd() >= 0); - - assert_ne!(fd1.as_raw_fd(), fd2.as_raw_fd()); - } - Ok(ruleset3.restrict_self()?) - }, - false, - ); - } - - #[test] - fn restrict_self_log_subdomains() { - check_restrict_self_support(ABI::V7, Some(ABI::V7), move |rs: RestrictSelf| -> _ { - Ok(rs.log_subdomains(false)?.apply()?) - }); - } - - // ABI v8's LANDLOCK_RESTRICT_SELF_TSYNC (exposed as all_threads()) applies - // the domain to every thread of the process, not just the calling one. On - // ABI v8 this is process-wide and irreversible, so it cannot run in the - // cargo-test runner. To exercise the same code path on every kernel, each - // all_threads() setting always runs in a dedicated child process created by - // re-executing this test binary. A bare fork() is deliberately avoided: - // forking the multithreaded test runner without exec is unreliable under - // the UML kernels used in CI, where it intermittently fails to set the FP - // registers and panics the guest. This is not tied to a kernel version - // (seen from 5.10 through 6.x) and happens even when Landlock is - // unavailable. A fork()+exec() child instead starts from a clean, - // single-threaded process, then hosts exactly two threads (its main thread - // and one sibling) that both list "/" (which needs AccessFs::ReadDir, - // handled by from_all(ABI::V1)) before and after enforcement. - // - // Expected outcomes are pinned from TEST_ABI, the running kernel's Landlock - // ABI: set per kernel by CI, auto-detected locally, and asserted to match - // the kernel by current_kernel_abi(). It is an oracle independent of the - // restrict_self() status under test: the calling thread must be denied iff - // Landlock is enforced at all, and the sibling must be denied iff TSYNC is - // both requested and supported (ABI v8+). Both the kernel's real behavior - // and the crate's reported status are checked against this truth, so a - // silently dropped or otherwise ineffective TSYNC is caught on every kernel - // in the CI matrix, from unsupported up to v8. - #[test] - fn abi_v8_all_threads() { - use crate::compat::TEST_ABI; - use std::sync::{Arc, Barrier}; - use std::thread; - - // Child exit code: OK, the OR of the mismatched checks, or PANICKED. - const OK: i32 = 0; - const MAIN_BEFORE_DENIED: i32 = 1 << 0; - const SIBLING_BEFORE_DENIED: i32 = 1 << 1; - const MAIN_AFTER_MISMATCH: i32 = 1 << 2; - const SIBLING_AFTER_MISMATCH: i32 = 1 << 3; - const STATUS_RULESET_MISMATCH: i32 = 1 << 4; - const STATUS_TSYNC_MISMATCH: i32 = 1 << 5; - const PANICKED: i32 = 1 << 6; - - // Environment variable carrying the all_threads() setting ("0" or "1") - // from the parent to the re-executed child. - const CHILD_ENV: &str = "LANDLOCK_TEST_ALL_THREADS"; - - // Listing "/" needs AccessFs::ReadDir: allowed before enforcement, - // denied afterwards for every thread the domain covers. - fn can_list_root() -> bool { - std::fs::read_dir("/").is_ok() - } - - // Ground truth for the running kernel, independent of the status object. - let landlock_supported = *TEST_ABI != ABI::Unsupported; - let tsync_supported = *TEST_ABI >= ABI::V8; - - // Child role: re-executed with CHILD_ENV set, this is a fresh - // single-threaded process (fork()+exec()), so enforcing an irreversible - // process-wide domain cannot affect the test runner. - if let Ok(setting) = std::env::var(CHILD_ENV) { - let all_threads = setting == "1"; - // The sibling is covered only when TSYNC is requested and supported. - let expect_sibling_denied = all_threads && tsync_supported; - - // Any panic (builder error, spawn/join failure) becomes a non-zero - // code rather than being swallowed as a pass. - let code = std::panic::catch_unwind(|| { - let barrier = Arc::new(Barrier::new(2)); - let sibling = { - let barrier = Arc::clone(&barrier); - thread::spawn(move || { - let before = can_list_root(); - // Both threads probed before enforcing. - barrier.wait(); - // Main has called restrict_self(). - barrier.wait(); - (before, can_list_root()) - }) - }; - - let main_before = can_list_root(); - barrier.wait(); - - let status = Ruleset::default() - .handle_access(AccessFs::from_all(ABI::V1)) - .unwrap() - .create() - .unwrap() - .all_threads(all_threads) - .unwrap() - .restrict_self() - .unwrap(); - barrier.wait(); - - let main_denied = !can_list_root(); - let (sibling_before, sibling_after) = sibling.join().unwrap(); - let sibling_denied = !sibling_after; - let ruleset_enforced = status.ruleset != RulesetStatus::NotEnforced; - - let mut code = OK; - // Both threads can list "/" before enforcement. - if !main_before { - code |= MAIN_BEFORE_DENIED; - } - if !sibling_before { - code |= SIBLING_BEFORE_DENIED; - } - // Kernel reality matches the independent expectation. - if main_denied != landlock_supported { - code |= MAIN_AFTER_MISMATCH; - } - if sibling_denied != expect_sibling_denied { - code |= SIBLING_AFTER_MISMATCH; - } - // The crate's reported status matches it too. - if ruleset_enforced != landlock_supported { - code |= STATUS_RULESET_MISMATCH; - } - if status.all_threads != expect_sibling_denied { - code |= STATUS_TSYNC_MISMATCH; - } - code - }) - .unwrap_or(PANICKED); - // Report the result only through the exit code; a plain return - // would let libtest declare the test passed and exit 0 instead. - std::process::exit(code); - } - - // Parent role: run the child once per all_threads() setting by - // re-executing this test binary filtered to this single test. - let exe = std::env::current_exe().unwrap(); - for all_threads in [false, true] { - let status = std::process::Command::new(&exe) - .args(["--test-threads=1", "abi_v8_all_threads"]) - .env(CHILD_ENV, if all_threads { "1" } else { "0" }) - .status() - .unwrap(); - assert_eq!(status.code(), Some(OK), "all_threads({all_threads})"); - } - } -} diff --git a/third_party/rust-landlock/src/net.rs b/third_party/rust-landlock/src/net.rs deleted file mode 100644 index 8f30f5e..0000000 --- a/third_party/rust-landlock/src/net.rs +++ /dev/null @@ -1,232 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 OR MIT - -use crate::compat::private::OptionCompatLevelMut; -use crate::{ - uapi, Access, AddRuleError, AddRulesError, CompatError, CompatLevel, CompatResult, CompatState, - Compatible, HandleAccessError, HandleAccessesError, HandledAccess, PrivateHandledAccess, - PrivateRule, Rule, Ruleset, RulesetCreated, TailoredCompatLevel, TryCompat, ABI, -}; -use enumflags2::{bitflags, BitFlags}; -use std::mem::zeroed; - -/// Network access right. -/// -/// Each variant of `AccessNet` is an [access right](https://www.kernel.org/doc/html/latest/userspace-api/landlock.html#access-rights) -/// for the network. -/// A set of access rights can be created with [`BitFlags`](BitFlags). -/// -/// # Example -/// -/// ``` -/// use landlock::{ABI, Access, AccessNet, BitFlags, make_bitflags}; -/// -/// let bind = AccessNet::BindTcp; -/// -/// let bind_set: BitFlags = bind.into(); -/// -/// let bind_connect = make_bitflags!(AccessNet::{BindTcp | ConnectTcp}); -/// -/// let net_v4 = AccessNet::from_all(ABI::V4); -/// -/// assert_eq!(bind_connect, net_v4); -/// ``` -/// -/// # Warning -/// -/// To avoid unknown restrictions **don't use `BitFlags::::all()` nor `BitFlags::ALL`**, -/// but use a version you tested and vetted instead, -/// for instance [`AccessNet::from_all(ABI::V4)`](Access::from_all). -/// Direct use of **the [`BitFlags`] API is deprecated**. -/// See [`ABI`] for the rationale and help to test it. -#[bitflags] -#[repr(u64)] -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -#[non_exhaustive] -pub enum AccessNet { - /// Bind to a TCP port. - BindTcp = uapi::LANDLOCK_ACCESS_NET_BIND_TCP as u64, - /// Connect to a TCP port. - ConnectTcp = uapi::LANDLOCK_ACCESS_NET_CONNECT_TCP as u64, -} - -/// # Warning -/// -/// If `ABI <= ABI::V3`, `AccessNet::from_all()` returns an empty `BitFlags`, which -/// makes `Ruleset::handle_access(AccessNet::from_all(ABI::V3))` return an error. -impl Access for AccessNet { - fn from_all(abi: ABI) -> BitFlags { - match abi { - ABI::Unsupported | ABI::V1 | ABI::V2 | ABI::V3 => BitFlags::EMPTY, - ABI::V4 | ABI::V5 | ABI::V6 | ABI::V7 | ABI::V8 | ABI::V9 => { - AccessNet::BindTcp | AccessNet::ConnectTcp - } - } - } -} - -impl HandledAccess for AccessNet {} - -impl PrivateHandledAccess for AccessNet { - fn ruleset_handle_access( - ruleset: &mut Ruleset, - access: BitFlags, - ) -> Result<(), HandleAccessesError> { - // We need to record the requested accesses for PrivateRule::check_consistency(). - ruleset.requested_handled_net |= access; - ruleset.actual_handled_net |= match access - .try_compat( - ruleset.compat.abi(), - ruleset.compat.level, - &mut ruleset.compat.state, - ) - .map_err(HandleAccessError::Compat)? - { - Some(a) => a, - None => return Ok(()), - }; - Ok(()) - } - - fn into_add_rules_error(error: AddRuleError) -> AddRulesError { - AddRulesError::Net(error) - } - - fn into_handle_accesses_error(error: HandleAccessError) -> HandleAccessesError { - HandleAccessesError::Net(error) - } -} - -/// Landlock rule for a network port. -/// -/// # Example -/// -/// ``` -/// use landlock::{AccessNet, NetPort}; -/// -/// fn bind_http() -> NetPort { -/// NetPort::new(80, AccessNet::BindTcp) -/// } -/// ``` -#[derive(Debug)] -pub struct NetPort { - attr: uapi::landlock_net_port_attr, - // Only 16-bit port make sense for now. - port: u16, - allowed_access: BitFlags, - compat_level: Option, -} - -// If we need support for 32 or 64 ports, we'll add a new_32() or a new_64() method returning a -// Result with a potential overflow error. -impl NetPort { - /// Creates a new TCP port rule. - /// - /// As defined by the Linux ABI, `port` with a value of `0` means that TCP bindings will be - /// allowed for a port range defined by `/proc/sys/net/ipv4/ip_local_port_range`. - pub fn new(port: u16, access: A) -> Self - where - A: Into>, - { - NetPort { - // Invalid access-rights until as_ptr() is called. - attr: unsafe { zeroed() }, - port, - allowed_access: access.into(), - compat_level: None, - } - } -} - -impl Rule for NetPort {} - -impl PrivateRule for NetPort { - const TYPE_ID: uapi::landlock_rule_type = uapi::landlock_rule_type_LANDLOCK_RULE_NET_PORT; - - fn as_ptr(&mut self) -> *const libc::c_void { - self.attr.port = self.port as u64; - self.attr.allowed_access = self.allowed_access.bits(); - &self.attr as *const _ as _ - } - - fn check_consistency(&self, ruleset: &RulesetCreated) -> Result<(), AddRulesError> { - // Checks that this rule doesn't contain a superset of the access-rights handled by the - // ruleset. This check is about requested access-rights but not actual access-rights. - // Indeed, we want to get a deterministic behavior, i.e. not based on the running kernel - // (which is handled by Ruleset and RulesetCreated). - if ruleset.requested_handled_net.contains(self.allowed_access) { - Ok(()) - } else { - Err(AddRuleError::UnhandledAccess { - access: self.allowed_access, - incompatible: self.allowed_access & !ruleset.requested_handled_net, - } - .into()) - } - } -} - -#[test] -fn net_port_check_consistency() { - use crate::*; - - let bind = AccessNet::BindTcp; - let bind_connect = bind | AccessNet::ConnectTcp; - - assert!(matches!( - Ruleset::from(ABI::Unsupported) - .handle_access(bind) - .unwrap() - .create() - .unwrap() - .add_rule(NetPort::new(1, bind_connect)) - .unwrap_err(), - RulesetError::AddRules(AddRulesError::Net(AddRuleError::UnhandledAccess { access, incompatible })) - if access == bind_connect && incompatible == AccessNet::ConnectTcp - )); -} - -impl TryCompat for NetPort { - fn try_compat_children( - mut self, - abi: ABI, - parent_level: L, - compat_state: &mut CompatState, - ) -> Result, CompatError> - where - L: Into, - { - // Checks with our own compatibility level, if any. - self.allowed_access = match self.allowed_access.try_compat( - abi, - self.tailored_compat_level(parent_level), - compat_state, - )? { - Some(a) => a, - None => return Ok(None), - }; - Ok(Some(self)) - } - - fn try_compat_inner( - &mut self, - _abi: ABI, - ) -> Result, CompatError> { - Ok(CompatResult::Full) - } -} - -impl OptionCompatLevelMut for NetPort { - fn as_option_compat_level_mut(&mut self) -> &mut Option { - &mut self.compat_level - } -} - -impl OptionCompatLevelMut for &mut NetPort { - fn as_option_compat_level_mut(&mut self) -> &mut Option { - &mut self.compat_level - } -} - -impl Compatible for NetPort {} - -impl Compatible for &mut NetPort {} diff --git a/third_party/rust-landlock/src/prctl.rs b/third_party/rust-landlock/src/prctl.rs deleted file mode 100644 index d8814cf..0000000 --- a/third_party/rust-landlock/src/prctl.rs +++ /dev/null @@ -1,71 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 OR MIT - -//! `prctl(2)` wrappers used by Landlock enforcement. -//! -//! These helpers are pub(crate) and shared between -//! [`RulesetCreated::restrict_self()`](crate::RulesetCreated::restrict_self) -//! and [`RestrictSelf::apply()`](crate::RestrictSelf::apply). - -use crate::compat::Compatibility; -use crate::{CompatLevel, CompatState, RestrictSelfError}; -use std::io::Error; - -fn prctl_set_no_new_privs() -> Result<(), Error> { - match unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } { - 0 => Ok(()), - _ => Err(Error::last_os_error()), - } -} - -fn support_no_new_privs() -> bool { - // Only Linux < 3.5 or kernel with seccomp filters should return an error. - matches!( - unsafe { libc::prctl(libc::PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0) }, - 0 | 1 - ) -} - -/// Calls `prctl(PR_SET_NO_NEW_PRIVS, 1)` and dispatches errors based on the -/// current compatibility level. -/// -/// Returns `Ok(true)` if `no_new_privs` was successfully enforced. Returns -/// `Ok(false)` if `prctl` failed but the failure was tolerated by the -/// current [`CompatLevel`] (silently dropped under `BestEffort`, transitions -/// to `Dummy` under `SoftRequirement`). Returns `Err` if `prctl` failed -/// under `HardRequirement`, or if the running kernel claims to support -/// `no_new_privs` (or Landlock itself) but the call still failed. -pub(crate) fn try_set_no_new_privs(compat: &mut Compatibility) -> Result { - if let Err(e) = prctl_set_no_new_privs() { - match compat.level.into() { - CompatLevel::BestEffort => {} - CompatLevel::SoftRequirement => { - compat.update(CompatState::Dummy); - } - CompatLevel::HardRequirement => { - return Err(RestrictSelfError::SetNoNewPrivsCall { source: e }); - } - } - // To get a consistent behavior, calls this prctl whether or not - // Landlock is supported by the running kernel. - let support_nnp = support_no_new_privs(); - match compat.state { - // It should not be an error for kernel (older than 3.5) not supporting - // no_new_privs. - CompatState::Init | CompatState::No | CompatState::Dummy => { - if support_nnp { - // The kernel seems to be between 3.5 (included) and 5.13 (excluded), - // or Landlock is not enabled; no_new_privs should be supported anyway. - return Err(RestrictSelfError::SetNoNewPrivsCall { source: e }); - } - } - // A kernel supporting Landlock should also support no_new_privs (unless - // filtered by seccomp). - CompatState::Full | CompatState::Partial => { - return Err(RestrictSelfError::SetNoNewPrivsCall { source: e }) - } - } - Ok(false) - } else { - Ok(true) - } -} diff --git a/third_party/rust-landlock/src/restrict_self.rs b/third_party/rust-landlock/src/restrict_self.rs deleted file mode 100644 index 657e709..0000000 --- a/third_party/rust-landlock/src/restrict_self.rs +++ /dev/null @@ -1,598 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 OR MIT - -//! Restrict_self flag configuration. -//! -//! The [`RestrictSelfAttr`] trait provides the -//! [`log_subdomains()`](RestrictSelfAttr::log_subdomains) and -//! [`all_threads()`](RestrictSelfAttr::all_threads) setters shared -//! between [`RulesetCreated`](crate::RulesetCreated) (with a domain) and -//! [`RestrictSelf`] (without a domain). -//! -//! Domain-specific setters ([`log_same_exec()`](crate::RulesetCreatedAttr::log_same_exec), -//! [`log_new_exec()`](crate::RulesetCreatedAttr::log_new_exec)) are on -//! [`RulesetCreatedAttr`](crate::RulesetCreatedAttr) which requires -//! `RestrictSelfAttr` as a supertrait. - -use crate::compat::private::OptionCompatLevelMut; -use crate::compat::Compatibility; -use crate::flags::{RestrictSelfFlag, SyscallFlagExt}; -use crate::prctl::try_set_no_new_privs; -use crate::{ - uapi, CompatLevel, CompatState, Compatible, LandlockStatus, RestrictSelfError, RulesetError, -}; -use private::RestrictSelfFlagsState; - -#[cfg(test)] -use crate::ABI; - -pub(crate) mod private { - use crate::RulesetError; - - /// Private plumbing trait for types that store restrict_self flags. - /// - /// Follows the same pattern as - /// [`OptionCompatLevelMut`](crate::compat::private::OptionCompatLevelMut) - /// for [`Compatible`](crate::Compatible). - /// - /// The `try_set_flag()` method encapsulates all internal state access - /// (requested/actual flags and compat state) to avoid exposing - /// `pub(crate)` types in the trait interface. - pub trait RestrictSelfFlagsState { - fn try_set_flag( - &mut self, - flag: super::RestrictSelfFlag, - set: bool, - ) -> Result<(), RulesetError>; - } -} - -/// Trait for types that accept restrict_self flag configuration. -/// -/// Provides [`log_subdomains()`](Self::log_subdomains) and -/// [`all_threads()`](Self::all_threads) which work both with and without a -/// Landlock domain. -/// -/// Implemented by [`RulesetCreated`](crate::RulesetCreated) (via -/// [`RulesetCreatedAttr`](crate::RulesetCreatedAttr) supertrait) and -/// [`RestrictSelf`]. -/// -/// Domain-specific setters (`log_same_exec`, `log_new_exec`) are on -/// [`RulesetCreatedAttr`](crate::RulesetCreatedAttr). -pub trait RestrictSelfAttr: Sized + private::RestrictSelfFlagsState { - /// Controls logging of denied accesses from nested Landlock domains. - /// Logging is **enabled** by default. See the - /// [kernel documentation](https://docs.kernel.org/userspace-api/landlock.html#enforcing-a-ruleset). - /// - /// Calling with `false` sets the `LANDLOCK_RESTRICT_SELF_LOG_SUBDOMAINS_OFF` flag. - /// Setters are last-call-wins: calling again with a different boolean - /// re-configures the flag (e.g., `log_subdomains(false).log_subdomains(true)` - /// leaves logging enabled). - /// - /// Setting to the default value never triggers a compatibility check, - /// so it cannot error even under - /// [`CompatLevel::HardRequirement`](crate::CompatLevel::HardRequirement) - /// on an unsupported kernel. - /// - /// Available since Landlock [ABI v7](crate::ABI::V7). - /// - /// On error, returns a wrapped - /// [`SyscallFlagError`](crate::SyscallFlagError). - fn log_subdomains(mut self, set: bool) -> Result { - self.try_set_flag(RestrictSelfFlag::LogSubdomains, set)?; - Ok(self) - } - - /// Applies the Landlock configuration to **all threads** of the calling - /// process, rather than only the calling thread. Disabled by default. - /// - /// Calling with `true` sets the `LANDLOCK_RESTRICT_SELF_TSYNC` flag, which - /// atomically enforces the domain and logging configuration on every - /// thread of the process. If the calling thread runs with `no_new_privs`, - /// the kernel also enables it on the sibling threads. - /// Calling with `false` is a no-op (the default behavior). - /// - /// When enforcing with a Landlock domain (via - /// [`RulesetCreated`](crate::RulesetCreated)), this works on its own. - /// On the domain-less [`RestrictSelf`] builder the kernel only accepts - /// this flag together with - /// [`log_subdomains(false)`](Self::log_subdomains); calling - /// [`apply()`](RestrictSelf::apply) with `all_threads(true)` alone returns - /// a wrapped [`RestrictSelfError`]. The crate does not pre-check this so - /// as not to restrict what the kernel allows. - /// - /// Setting to the default value never triggers a compatibility check, - /// so it cannot error even under - /// [`CompatLevel::HardRequirement`](crate::CompatLevel::HardRequirement) - /// on an unsupported kernel. - /// - /// Available since Landlock [ABI v8](crate::ABI::V8). - /// - /// # Warning - /// - /// On a kernel older than ABI v8, this flag is not supported. With the - /// default [`CompatLevel::BestEffort`](crate::CompatLevel::BestEffort) it - /// is silently dropped. When enforcing a domain (via - /// [`RulesetCreated`](crate::RulesetCreated)) this leaves **only the - /// calling thread and its future children restricted, not the sibling and - /// parent threads**, a weaker guarantee than requested for a multithreaded - /// process. On the domain-less [`RestrictSelf`] builder the remaining - /// configuration (e.g. [`log_subdomains()`](Self::log_subdomains)) then - /// applies only to the calling thread, and if this flag was the only - /// request the enforcement syscall is skipped entirely. - /// Applications that require process-wide enforcement should use - /// [`CompatLevel::HardRequirement`](crate::CompatLevel::HardRequirement) - /// (which errors on an unsupported kernel) or inspect the `all_threads` - /// field of the returned status. - /// - /// On error, returns a wrapped - /// [`SyscallFlagError`](crate::SyscallFlagError). - fn all_threads(mut self, set: bool) -> Result { - self.try_set_flag(RestrictSelfFlag::AllThreads, set)?; - Ok(self) - } -} - -/// Builder for calling `landlock_restrict_self()` without creating a -/// Landlock domain. -/// -/// Use this when you want to configure `landlock_restrict_self()` flags -/// without creating a ruleset or a Landlock domain (e.g., muting -/// subdomain audit logs for nested domains). -/// -/// [`log_subdomains()`](RestrictSelfAttr::log_subdomains) and -/// [`all_threads()`](RestrictSelfAttr::all_threads) are available on this -/// builder. On this domain-less path the kernel only accepts -/// [`all_threads()`](RestrictSelfAttr::all_threads) together with -/// [`log_subdomains(false)`](RestrictSelfAttr::log_subdomains). Domain-specific -/// setters ([`log_same_exec()`](crate::RulesetCreatedAttr::log_same_exec), -/// [`log_new_exec()`](crate::RulesetCreatedAttr::log_new_exec)) require a -/// Landlock domain via [`RulesetCreated`](crate::RulesetCreated). -/// -/// Available since Landlock [ABI v7](crate::ABI::V7). -/// -/// `no_new_privs` is enforced by default; call -/// [`no_new_privs(false)`](Self::no_new_privs) to opt out. -/// -/// # Example -/// -/// ```no_run -/// use landlock::*; -/// -/// let status = RestrictSelf::default() -/// .log_subdomains(false)? -/// .apply()?; -/// println!("Landlock status: {:?}", status.landlock); -/// # Ok::<(), RulesetError>(()) -/// ``` -/// -/// Use [`set_compatibility()`](Compatible::set_compatibility) to control -/// how unsupported flags are handled. -/// -/// [`apply()`](Self::apply) returns a [`RestrictSelfStatus`] with the -/// Landlock support status and the effective flag states. Its name -/// differs from [`RulesetCreated::restrict_self()`](crate::RulesetCreated::restrict_self) -/// to avoid the redundant `RestrictSelf::restrict_self()`. -#[derive(Debug)] -pub struct RestrictSelf { - requested_flags: u32, - actual_flags: u32, - no_new_privs: bool, - compat: Compatibility, -} - -impl Default for RestrictSelf { - /// Returns a new `RestrictSelf`. - /// This call automatically probes the running kernel to know if it - /// supports Landlock. - fn default() -> Self { - Self { - requested_flags: 0, - actual_flags: 0, - no_new_privs: true, - compat: Compatibility::new(), - } - } -} - -#[cfg(test)] -impl From for RestrictSelf { - fn from(abi: ABI) -> Self { - Self { - requested_flags: 0, - actual_flags: 0, - no_new_privs: true, - compat: Compatibility::from(abi), - } - } -} - -impl RestrictSelfFlagsState for RestrictSelf { - fn try_set_flag(&mut self, flag: RestrictSelfFlag, set: bool) -> Result<(), RulesetError> { - let raw_bit = flag.raw_bit(); - // Last-call-wins: requested tracks non-default user intent, actual - // tracks the bit that will be passed to the kernel. - // - // requested_flags is updated unconditionally; actual_flags is - // updated only if try_compat succeeds. On HardRequirement + - // unsupported, try_compat returns Err and requested_flags is - // left in a "user requested this" state; the builder is consumed - // by `?` on error so this inconsistency is not observable. - if set == flag.default_value() { - self.requested_flags &= !raw_bit; - } else { - self.requested_flags |= raw_bit; - } - if flag.try_compat(set, &mut self.compat)? { - self.actual_flags |= raw_bit; - } else { - self.actual_flags &= !raw_bit; - } - Ok(()) - } -} - -impl RestrictSelfAttr for RestrictSelf {} - -impl OptionCompatLevelMut for RestrictSelf { - fn as_option_compat_level_mut(&mut self) -> &mut Option { - &mut self.compat.level - } -} - -impl Compatible for RestrictSelf {} - -/// Status returned by [`RestrictSelf::apply()`]. -/// -/// This is a proper subset of [`RestrictionStatus`](crate::RestrictionStatus): -/// `log_same_exec` and `log_new_exec` are domain-specific and not configurable -/// on [`RestrictSelf`], so they are not reported here; `ruleset` does not -/// apply without a domain. -#[derive(Debug, PartialEq, Eq)] -#[non_exhaustive] -pub struct RestrictSelfStatus { - /// Landlock support status of the running system. - pub landlock: LandlockStatus, - /// `no_new_privs` was successfully enforced via - /// `prctl(PR_SET_NO_NEW_PRIVS, 1)`. - pub no_new_privs: bool, - /// Subdomain logging is enabled (default: true). - pub log_subdomains: bool, - /// The configuration was applied to all threads of the process (default: false). - pub all_threads: bool, -} - -impl RestrictSelf { - /// Configures whether to call `prctl(PR_SET_NO_NEW_PRIVS)` during - /// [`apply()`](Self::apply). Defaults to `true`. - /// - /// This `prctl(2)` call is never ignored, even if an error was - /// encountered while [`CompatLevel::SoftRequirement`] was set. - /// - /// See [`RestrictSelfAttr::log_subdomains()`] for compat-state - /// behavior when toggling this setter on unsupported kernels. - pub fn no_new_privs(mut self, yes: bool) -> Self { - self.no_new_privs = yes; - self - } - - /// Applies the configured restrict_self flags by calling - /// `landlock_restrict_self(-1, flags)`. - /// - /// If `no_new_privs` is configured (default), also calls - /// `prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)` first, since the kernel - /// requires `no_new_privs` (or `CAP_SYS_ADMIN`) for - /// `landlock_restrict_self()`. See - /// [`no_new_privs()`](Self::no_new_privs) to opt out. - /// - /// Returns a [`RestrictSelfStatus`] with the Landlock support status. - /// Skips the restrict_self syscall if no flags are enforceable. - pub fn apply(mut self) -> Result { - let enforced_nnp = if self.no_new_privs { - try_set_no_new_privs(&mut self.compat)? - } else { - false - }; - - let log_subdomains = RestrictSelfFlag::LogSubdomains.is_set(self.actual_flags); - let all_threads = RestrictSelfFlag::AllThreads.is_set(self.actual_flags); - - let status = RestrictSelfStatus { - landlock: self.compat.status(), - no_new_privs: enforced_nnp, - log_subdomains, - all_threads, - }; - - // Skip the syscall when the compat state indicates no features are - // enforceable, mirroring RulesetCreated::restrict_self(). - match self.compat.state { - CompatState::Init | CompatState::No | CompatState::Dummy => return Ok(status), - CompatState::Full | CompatState::Partial => { - if self.actual_flags == 0 { - return Ok(status); - } - } - } - - match unsafe { uapi::landlock_restrict_self(-1, self.actual_flags) } { - 0 => Ok(status), - _ => Err(RestrictSelfError::RestrictSelfCall { - source: std::io::Error::last_os_error(), - } - .into()), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::uapi; - use crate::*; - - #[test] - fn restrict_self_default() { - let rs = RestrictSelf::default(); - assert_eq!(rs.requested_flags, 0); - assert_eq!(rs.actual_flags, 0); - - // apply() on an unconfigured RestrictSelf returns the kernel's default - // flag states. The compat state is Init, so no real syscall is made. - let status = rs.apply().unwrap(); - assert!(status.log_subdomains); - assert!(!status.all_threads); - } - - #[test] - fn restrict_self_log_subdomains() { - // With mocked V7: flag should be set. - // TODO: Add real kernel test with audit parsing for end-to-end validation. - let rs = RestrictSelf { - requested_flags: 0, - actual_flags: 0, - no_new_privs: true, - compat: ABI::V7.into(), - }; - let rs = rs.log_subdomains(false).unwrap(); - assert_ne!(rs.requested_flags, 0); - assert_ne!(rs.actual_flags, 0); - assert_eq!(rs.requested_flags, rs.actual_flags); - } - - #[test] - fn restrict_self_compatibility() { - // HardRequirement on unsupported ABI returns error. - let rs = RestrictSelf { - requested_flags: 0, - actual_flags: 0, - no_new_privs: true, - compat: ABI::Unsupported.into(), - }; - assert!(matches!( - rs.set_compatibility(CompatLevel::HardRequirement) - .log_subdomains(false) - .unwrap_err(), - RulesetError::RestrictSelfFlags(SyscallFlagError::NotSupported { - flag: RestrictSelfFlag::LogSubdomains, - set: false, - }) - )); - } - - #[test] - fn restrict_self_no_flags() { - // apply() with no flags set should skip the syscall. - let rs = RestrictSelf { - requested_flags: 0, - actual_flags: 0, - no_new_privs: true, - compat: ABI::V7.into(), - }; - let status = rs.apply().unwrap(); - assert!(matches!(status.landlock, LandlockStatus::Available { .. })); - assert!(status.log_subdomains); // default: enabled - } - - #[test] - fn restrict_self_partial_no_op() { - // When all flags are dropped by BestEffort (actual_flags == 0), - // apply() should still return the Landlock status from the kernel probe. - let mut compat: Compatibility = ABI::V7.into(); - compat.update(CompatState::No); - assert_eq!(compat.state, CompatState::No); - - let rs = RestrictSelf { - requested_flags: 0b01, - actual_flags: 0, - no_new_privs: true, - compat, - }; - let status = rs.apply().unwrap(); - assert!(matches!(status.landlock, LandlockStatus::Available { .. })); - assert!(status.log_subdomains); // default: enabled (flag was dropped) - } - - #[test] - fn restrict_self_best_effort_drops_unsupported() { - // On an unsupported ABI, BestEffort silently drops all flags. - let rs = RestrictSelf { - requested_flags: 0, - actual_flags: 0, - no_new_privs: true, - compat: ABI::Unsupported.into(), - }; - let rs = rs.log_subdomains(false).unwrap(); - assert_ne!(rs.requested_flags, 0); - assert_eq!(rs.actual_flags, 0); - let status = rs.apply().unwrap(); - assert_eq!(status.landlock, LandlockStatus::NotImplemented); - assert!(status.log_subdomains); // flag was dropped, logging still enabled - } - - #[test] - fn restrict_self_soft_requirement_drops_unsupported() { - // On an unsupported ABI, SoftRequirement transitions to Dummy and - // silently drops the flag (without erroring like HardRequirement). - let rs = RestrictSelf { - requested_flags: 0, - actual_flags: 0, - no_new_privs: true, - compat: ABI::Unsupported.into(), - }; - let rs = rs - .set_compatibility(CompatLevel::SoftRequirement) - .log_subdomains(false) - .unwrap(); - assert_ne!(rs.requested_flags, 0); - assert_eq!(rs.actual_flags, 0); - let status = rs.apply().unwrap(); - assert_eq!(status.landlock, LandlockStatus::NotImplemented); - assert!(status.log_subdomains); // flag was dropped, logging still enabled - } - - #[test] - fn restrict_self_subdomains_applied() { - let rs = RestrictSelf { - requested_flags: 0, - actual_flags: 0, - no_new_privs: true, - compat: ABI::V7.into(), - }; - let rs = rs.log_subdomains(false).unwrap(); - assert_ne!(rs.actual_flags, 0); - assert_ne!( - rs.actual_flags & uapi::LANDLOCK_RESTRICT_SELF_LOG_SUBDOMAINS_OFF, - 0 - ); - } - - #[test] - fn restrict_self_all_threads_applied() { - // all_threads(true) is supported since ABI v8; on a mocked V8 the - // TSYNC bit is set in the actual flags. This is a pure flag-composition - // check: apply() is not called, so the kernel is not involved. - // log_subdomains(false) is set alongside because that is the only flag - // combination the kernel would accept on the domain-less path - // (ruleset_fd == -1); here it only documents that pairing. - let rs = RestrictSelf { - requested_flags: 0, - actual_flags: 0, - no_new_privs: true, - compat: ABI::V8.into(), - }; - let rs = rs.log_subdomains(false).unwrap().all_threads(true).unwrap(); - assert_ne!(rs.actual_flags & uapi::LANDLOCK_RESTRICT_SELF_TSYNC, 0); - assert_ne!( - rs.actual_flags & uapi::LANDLOCK_RESTRICT_SELF_LOG_SUBDOMAINS_OFF, - 0 - ); - } - - #[test] - fn restrict_self_all_threads_best_effort_drops_v7() { - // On a kernel that predates ABI v8 (here mocked V7), BestEffort - // silently drops all_threads(true): the TSYNC bit is requested but not - // applied, so only the calling thread would be restricted. - let rs = RestrictSelf { - requested_flags: 0, - actual_flags: 0, - no_new_privs: true, - compat: ABI::V7.into(), - }; - let rs = rs.all_threads(true).unwrap(); - assert_ne!(rs.requested_flags & uapi::LANDLOCK_RESTRICT_SELF_TSYNC, 0); - assert_eq!(rs.actual_flags & uapi::LANDLOCK_RESTRICT_SELF_TSYNC, 0); - let status = rs.apply().unwrap(); - assert!(!status.all_threads); - } - - #[test] - fn restrict_self_all_threads_hard_requirement_v7() { - // HardRequirement on a pre-v8 ABI returns an error rather than - // silently restricting only the calling thread. - let rs = RestrictSelf { - requested_flags: 0, - actual_flags: 0, - no_new_privs: true, - compat: ABI::V7.into(), - }; - assert!(matches!( - rs.set_compatibility(CompatLevel::HardRequirement) - .all_threads(true) - .unwrap_err(), - RulesetError::RestrictSelfFlags(SyscallFlagError::NotSupported { - flag: RestrictSelfFlag::AllThreads, - set: true, - }) - )); - } - - #[test] - fn restrict_self_hard_requirement_supported() { - let rs = RestrictSelf { - requested_flags: 0, - actual_flags: 0, - no_new_privs: true, - compat: ABI::V7.into(), - }; - let rs = rs - .set_compatibility(CompatLevel::HardRequirement) - .log_subdomains(false) - .unwrap(); - assert_ne!(rs.requested_flags, 0); - assert_ne!(rs.actual_flags, 0); - } - - #[test] - fn restrict_self_last_call_wins() { - // Setting a flag to non-default then back to default should clear - // both requested and actual (last call wins). - let rs = RestrictSelf { - requested_flags: 0, - actual_flags: 0, - no_new_privs: true, - compat: ABI::V7.into(), - }; - let rs = rs - .log_subdomains(false) - .unwrap() - .log_subdomains(true) - .unwrap(); - assert_eq!(rs.requested_flags, 0); - assert_eq!(rs.actual_flags, 0); - } - - #[test] - fn restrict_self_default_after_hard_requirement() { - // Setting a flag to its default value never requires a compat check, - // so HardRequirement on an unsupported ABI does not error. - let rs = RestrictSelf { - requested_flags: 0, - actual_flags: 0, - no_new_privs: true, - compat: ABI::Unsupported.into(), - }; - let rs = rs - .set_compatibility(CompatLevel::HardRequirement) - .log_subdomains(true) - .unwrap(); - assert_eq!(rs.requested_flags, 0); - assert_eq!(rs.actual_flags, 0); - } - - #[test] - fn restrict_self_no_nnp() { - // With no_new_privs(false) and Unsupported (state Init, syscall - // skipped), apply() reports no_new_privs: false without calling - // prctl. - let rs = RestrictSelf { - requested_flags: 0, - actual_flags: 0, - no_new_privs: true, - compat: ABI::Unsupported.into(), - }; - let status = rs.no_new_privs(false).apply().unwrap(); - assert!(!status.no_new_privs); - assert_eq!(status.landlock, LandlockStatus::NotImplemented); - } -} diff --git a/third_party/rust-landlock/src/ruleset.rs b/third_party/rust-landlock/src/ruleset.rs deleted file mode 100644 index 95b9790..0000000 --- a/third_party/rust-landlock/src/ruleset.rs +++ /dev/null @@ -1,1499 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 OR MIT - -use crate::compat::private::OptionCompatLevelMut; -use crate::flags::{RestrictSelfFlag, SyscallFlagExt}; -use crate::prctl::try_set_no_new_privs; -use crate::restrict_self::private::RestrictSelfFlagsState; -use crate::{ - uapi, AccessFs, AccessNet, AddRuleError, AddRulesError, BitFlags, CompatLevel, CompatState, - Compatibility, Compatible, CreateRulesetError, HandledAccess, LandlockStatus, - PrivateHandledAccess, RestrictSelfAttr, RestrictSelfError, RulesetError, Scope, ScopeError, - TryCompat, -}; -use std::io::Error; -use std::mem::size_of_val; -use std::os::unix::io::{AsRawFd, FromRawFd, OwnedFd}; - -#[cfg(test)] -use crate::*; - -// Public interface without methods and which is impossible to implement outside this crate. -pub trait Rule: PrivateRule -where - T: HandledAccess, -{ -} - -// PrivateRule is not public outside this crate. -pub trait PrivateRule -where - Self: TryCompat + Compatible, - T: HandledAccess, -{ - const TYPE_ID: uapi::landlock_rule_type; - - /// Returns a raw pointer to the rule's inner attribute. - /// - /// The caller must ensure that the rule outlives the pointer this function returns, or else it - /// will end up pointing to garbage. - fn as_ptr(&mut self) -> *const libc::c_void; - - fn check_consistency(&self, ruleset: &RulesetCreated) -> Result<(), AddRulesError>; -} - -/// Enforcement status of a ruleset. -#[derive(Debug, PartialEq, Eq)] -pub enum RulesetStatus { - /// All requested restrictions are enforced. - FullyEnforced, - /// Some requested restrictions are enforced, - /// following a best-effort approach. - PartiallyEnforced, - /// The running system doesn't support Landlock - /// or a subset of the requested Landlock features. - NotEnforced, -} - -impl From for RulesetStatus { - fn from(state: CompatState) -> Self { - match state { - CompatState::Init | CompatState::No | CompatState::Dummy => RulesetStatus::NotEnforced, - CompatState::Full => RulesetStatus::FullyEnforced, - CompatState::Partial => RulesetStatus::PartiallyEnforced, - } - } -} - -// The Debug, PartialEq and Eq implementations are useful for crate users to debug and check the -// result of a Landlock ruleset enforcement. -/// Status of a [`RulesetCreated`] -/// after calling [`restrict_self()`](RulesetCreated::restrict_self). -#[derive(Debug, PartialEq, Eq)] -#[non_exhaustive] -pub struct RestrictionStatus { - /// Status of the Landlock ruleset enforcement. - pub ruleset: RulesetStatus, - /// Status of `prctl(2)`'s `PR_SET_NO_NEW_PRIVS` enforcement. - pub no_new_privs: bool, - /// Status of Landlock for the running kernel. - pub landlock: LandlockStatus, - /// Same-exec logging is enabled (default: true). - pub log_same_exec: bool, - /// New-exec logging is enabled (default: false). - pub log_new_exec: bool, - /// Subdomain logging is enabled (default: true). - pub log_subdomains: bool, - /// The ruleset was applied to all threads of the process (default: false). - pub all_threads: bool, -} - -/// Landlock ruleset builder. -/// -/// `Ruleset` enables to create a Landlock ruleset in a flexible way -/// following the builder pattern. -/// Most build steps return a [`Result`] with [`RulesetError`]. -/// -/// You should probably not create more than one ruleset per application. -/// Creating multiple rulesets is only useful when gradually restricting an application -/// (e.g., a first set of generic restrictions before reading any file, -/// then a second set of tailored restrictions after reading the configuration). -/// -/// # Simple example -/// -/// Simple helper handling only Landlock-related errors. -/// -/// ``` -/// use landlock::{ -/// Access, AccessFs, PathBeneath, PathFd, RestrictionStatus, Ruleset, RulesetAttr, -/// RulesetCreatedAttr, RulesetError, ABI, -/// }; -/// use std::os::unix::io::AsFd; -/// -/// fn restrict_fd(hierarchy: T) -> Result -/// where -/// T: AsFd, -/// { -/// // The Landlock ABI should be incremented (and tested) regularly. -/// let abi = ABI::V1; -/// let access_all = AccessFs::from_all(abi); -/// let access_read = AccessFs::from_read(abi); -/// Ok(Ruleset::default() -/// .handle_access(access_all)? -/// .create()? -/// .add_rule(PathBeneath::new(hierarchy, access_read))? -/// .restrict_self()?) -/// } -/// -/// let fd = PathFd::new("/home").expect("failed to open /home"); -/// let status = restrict_fd(fd).expect("failed to build the ruleset"); -/// ``` -/// -/// # Generic example -/// -/// More generic helper handling a set of file hierarchies -/// and multiple types of error (i.e. [`RulesetError`](crate::RulesetError) -/// and [`PathFdError`](crate::PathFdError). -/// -/// ``` -/// use landlock::{ -/// Access, AccessFs, PathBeneath, PathFd, PathFdError, RestrictionStatus, Ruleset, -/// RulesetAttr, RulesetCreatedAttr, RulesetError, ABI, -/// }; -/// use thiserror::Error; -/// -/// #[derive(Debug, Error)] -/// enum MyRestrictError { -/// #[error(transparent)] -/// Ruleset(#[from] RulesetError), -/// #[error(transparent)] -/// AddRule(#[from] PathFdError), -/// } -/// -/// fn restrict_paths(hierarchies: &[&str]) -> Result { -/// // The Landlock ABI should be incremented (and tested) regularly. -/// let abi = ABI::V1; -/// let access_all = AccessFs::from_all(abi); -/// let access_read = AccessFs::from_read(abi); -/// Ok(Ruleset::default() -/// .handle_access(access_all)? -/// .create()? -/// .add_rules( -/// hierarchies -/// .iter() -/// .map::, _>(|p| { -/// Ok(PathBeneath::new(PathFd::new(p)?, access_read)) -/// }), -/// )? -/// .restrict_self()?) -/// } -/// -/// let status = restrict_paths(&["/usr", "/home"]).expect("failed to build the ruleset"); -/// ``` -#[derive(Debug)] -pub struct Ruleset { - pub(crate) requested_handled_fs: BitFlags, - pub(crate) requested_handled_net: BitFlags, - pub(crate) requested_scoped: BitFlags, - pub(crate) actual_handled_fs: BitFlags, - pub(crate) actual_handled_net: BitFlags, - pub(crate) actual_scoped: BitFlags, - pub(crate) compat: Compatibility, -} - -impl From for Ruleset { - fn from(compat: Compatibility) -> Self { - Ruleset { - // Non-working default handled FS accesses to force users to set them explicitely. - requested_handled_fs: Default::default(), - requested_handled_net: Default::default(), - requested_scoped: Default::default(), - actual_handled_fs: Default::default(), - actual_handled_net: Default::default(), - actual_scoped: Default::default(), - compat, - } - } -} - -#[cfg(test)] -impl From for Ruleset { - fn from(abi: ABI) -> Self { - Ruleset::from(Compatibility::from(abi)) - } -} - -#[test] -fn ruleset_add_rule_iter() { - assert!(matches!( - Ruleset::from(ABI::Unsupported) - .handle_access(AccessFs::Execute) - .unwrap() - .create() - .unwrap() - .add_rule(PathBeneath::new( - PathFd::new("/").unwrap(), - AccessFs::ReadFile - )) - .unwrap_err(), - RulesetError::AddRules(AddRulesError::Fs(AddRuleError::UnhandledAccess { .. })) - )); -} - -impl Default for Ruleset { - /// Returns a new `Ruleset`. - /// This call automatically probes the running kernel to know if it supports Landlock. - /// - /// To be able to successfully call [`create()`](Ruleset::create), - /// it is required to set the handled accesses with - /// [`handle_access()`](Ruleset::handle_access). - fn default() -> Self { - // The API should be future-proof: one Rust program or library should have the same - // behavior if built with an old or a newer crate (e.g. with an extended ruleset_attr - // enum). It should then not be possible to give an "all-possible-handled-accesses" to the - // Ruleset builder because this value would be relative to the running kernel. - Compatibility::new().into() - } -} - -impl Ruleset { - #[allow(clippy::new_without_default)] - #[deprecated(note = "Use Ruleset::default() instead")] - pub fn new() -> Self { - Ruleset::default() - } - - /// Attempts to create a real Landlock ruleset (if supported by the running kernel). - /// The returned [`RulesetCreated`] is also a builder. - /// - /// On error, returns a wrapped [`CreateRulesetError`]. - pub fn create(mut self) -> Result { - let body = || -> Result { - match self.compat.state { - CompatState::Init => { - // Checks that there is at least one requested access (e.g. - // requested_handled_fs): one call to handle_access(). - Err(CreateRulesetError::MissingHandledAccess) - } - CompatState::No | CompatState::Dummy => { - // There is at least one requested access. - #[cfg(test)] - assert!( - !self.requested_handled_fs.is_empty() - || !self.requested_handled_net.is_empty() - || !self.requested_scoped.is_empty() - ); - - // CompatState::No should be handled as CompatState::Dummy because it is not - // possible to create an actual ruleset. - self.compat.update(CompatState::Dummy); - match self.compat.level.into() { - CompatLevel::HardRequirement => { - Err(CreateRulesetError::MissingHandledAccess) - } - _ => Ok(RulesetCreated::new(self, None)), - } - } - CompatState::Full | CompatState::Partial => { - // There is at least one actual handled access. - #[cfg(test)] - assert!( - !self.actual_handled_fs.is_empty() - || !self.actual_handled_net.is_empty() - || !self.actual_scoped.is_empty() - ); - - let attr = uapi::landlock_ruleset_attr { - handled_access_fs: self.actual_handled_fs.bits(), - handled_access_net: self.actual_handled_net.bits(), - scoped: self.actual_scoped.bits(), - }; - match unsafe { uapi::landlock_create_ruleset(&attr, size_of_val(&attr), 0) } { - fd if fd >= 0 => Ok(RulesetCreated::new( - self, - Some(unsafe { OwnedFd::from_raw_fd(fd) }), - )), - _ => Err(CreateRulesetError::CreateRulesetCall { - source: Error::last_os_error(), - }), - } - } - } - }; - Ok(body()?) - } -} - -impl OptionCompatLevelMut for Ruleset { - fn as_option_compat_level_mut(&mut self) -> &mut Option { - &mut self.compat.level - } -} - -impl OptionCompatLevelMut for &mut Ruleset { - fn as_option_compat_level_mut(&mut self) -> &mut Option { - &mut self.compat.level - } -} - -impl Compatible for Ruleset {} - -impl Compatible for &mut Ruleset {} - -impl AsMut for Ruleset { - fn as_mut(&mut self) -> &mut Ruleset { - self - } -} - -// Tests unambiguous type. -#[test] -fn ruleset_as_mut() { - let mut ruleset = Ruleset::from(ABI::Unsupported); - let _ = ruleset.as_mut(); - - let mut ruleset_created = Ruleset::from(ABI::Unsupported) - .handle_access(AccessFs::Execute) - .unwrap() - .create() - .unwrap(); - let _ = ruleset_created.as_mut(); -} - -pub trait RulesetAttr: Sized + AsMut + Compatible { - /// Attempts to add a set of access rights that will be supported by this ruleset. - /// By default, all actions requiring these access rights will be denied. - /// Consecutive calls to `handle_access()` will be interpreted as logical ORs - /// with the previous handled accesses. - /// - /// On error, returns a wrapped [`HandleAccessesError`](crate::HandleAccessesError). - /// E.g., `RulesetError::HandleAccesses(HandleAccessesError::Fs(HandleAccessError))` - fn handle_access(mut self, access: T) -> Result - where - T: Into>, - U: HandledAccess + PrivateHandledAccess, - { - U::ruleset_handle_access(self.as_mut(), access.into())?; - Ok(self) - } - - /// Attempts to add a set of scopes that will be supported by this ruleset. - /// Consecutive calls to `scope()` will be interpreted as logical ORs - /// with the previous scopes. - /// - /// On error, returns a wrapped [`ScopeError`](crate::ScopeError). - /// E.g., `RulesetError::Scope(ScopeError)` - fn scope(mut self, scope: T) -> Result - where - T: Into>, - { - let scope = scope.into(); - let ruleset = self.as_mut(); - ruleset.requested_scoped |= scope; - if let Some(a) = scope - .try_compat( - ruleset.compat.abi(), - ruleset.compat.level, - &mut ruleset.compat.state, - ) - .map_err(ScopeError::Compat)? - { - ruleset.actual_scoped |= a; - } - Ok(self) - } -} - -impl RulesetAttr for Ruleset {} - -impl RulesetAttr for &mut Ruleset {} - -#[test] -fn ruleset_attr() { - let mut ruleset = Ruleset::from(ABI::Unsupported); - let ruleset_ref = &mut ruleset; - - // Can pass this reference to prepare the ruleset... - ruleset_ref - .set_compatibility(CompatLevel::BestEffort) - .handle_access(AccessFs::Execute) - .unwrap() - .handle_access(AccessFs::ReadFile) - .unwrap(); - - // ...and finally create the ruleset (thanks to non-lexical lifetimes). - ruleset - .set_compatibility(CompatLevel::BestEffort) - .handle_access(AccessFs::Execute) - .unwrap() - .handle_access(AccessFs::WriteFile) - .unwrap() - .create() - .unwrap(); -} - -#[test] -fn ruleset_created_handle_access_fs() { - let access = make_bitflags!(AccessFs::{Execute | ReadDir}); - - // Tests AccessFs::ruleset_handle_access() - let ruleset = Ruleset::from(ABI::V1).handle_access(access).unwrap(); - assert_eq!(ruleset.requested_handled_fs, access); - assert_eq!(ruleset.actual_handled_fs, access); - - // Tests composition (binary OR) of handled accesses. - let ruleset = Ruleset::from(ABI::V1) - .handle_access(AccessFs::Execute) - .unwrap() - .handle_access(AccessFs::ReadDir) - .unwrap() - .handle_access(AccessFs::Execute) - .unwrap(); - assert_eq!(ruleset.requested_handled_fs, access); - assert_eq!(ruleset.actual_handled_fs, access); - - // Tests that only the required handled accesses are reported as incompatible: - // access should not contains AccessFs::Execute. - assert!(matches!(Ruleset::from(ABI::Unsupported) - .handle_access(AccessFs::Execute) - .unwrap() - .set_compatibility(CompatLevel::HardRequirement) - .handle_access(AccessFs::ReadDir) - .unwrap_err(), - RulesetError::HandleAccesses(HandleAccessesError::Fs(HandleAccessError::Compat( - CompatError::Access(AccessError::Incompatible { access }) - ))) if access == AccessFs::ReadDir - )); -} - -#[test] -fn ruleset_created_handle_access_net_tcp() { - let access = make_bitflags!(AccessNet::{BindTcp | ConnectTcp}); - - // Tests AccessNet::ruleset_handle_access() with ABI that doesn't support TCP rights. - let ruleset = Ruleset::from(ABI::V3).handle_access(access).unwrap(); - assert_eq!(ruleset.requested_handled_net, access); - assert_eq!(ruleset.actual_handled_net, BitFlags::::EMPTY); - - // Tests AccessNet::ruleset_handle_access() with ABI that supports TCP rights. - let ruleset = Ruleset::from(ABI::V4).handle_access(access).unwrap(); - assert_eq!(ruleset.requested_handled_net, access); - assert_eq!(ruleset.actual_handled_net, access); - - // Tests composition (binary OR) of handled accesses. - let ruleset = Ruleset::from(ABI::V4) - .handle_access(AccessNet::BindTcp) - .unwrap() - .handle_access(AccessNet::ConnectTcp) - .unwrap() - .handle_access(AccessNet::BindTcp) - .unwrap(); - assert_eq!(ruleset.requested_handled_net, access); - assert_eq!(ruleset.actual_handled_net, access); - - // Tests that only the required handled accesses are reported as incompatible: - // access should not contains AccessNet::BindTcp. - assert!(matches!(Ruleset::from(ABI::Unsupported) - .handle_access(AccessNet::BindTcp) - .unwrap() - .set_compatibility(CompatLevel::HardRequirement) - .handle_access(AccessNet::ConnectTcp) - .unwrap_err(), - RulesetError::HandleAccesses(HandleAccessesError::Net(HandleAccessError::Compat( - CompatError::Access(AccessError::Incompatible { access }) - ))) if access == AccessNet::ConnectTcp - )); -} - -#[test] -fn ruleset_created_scope() { - let scopes = make_bitflags!(Scope::{AbstractUnixSocket | Signal}); - - // Tests Ruleset::scope() with ABI that doesn't support scopes. - let ruleset = Ruleset::from(ABI::V5).scope(scopes).unwrap(); - assert_eq!(ruleset.requested_scoped, scopes); - assert_eq!(ruleset.actual_scoped, BitFlags::::EMPTY); - - // Tests Ruleset::scope() with ABI that supports scopes. - let ruleset = Ruleset::from(ABI::V6).scope(scopes).unwrap(); - assert_eq!(ruleset.requested_scoped, scopes); - assert_eq!(ruleset.actual_scoped, scopes); - - // Tests composition (binary OR) of scopes. - let ruleset = Ruleset::from(ABI::V6) - .scope(Scope::AbstractUnixSocket) - .unwrap() - .scope(Scope::Signal) - .unwrap() - .scope(Scope::AbstractUnixSocket) - .unwrap(); - assert_eq!(ruleset.requested_scoped, scopes); - assert_eq!(ruleset.actual_scoped, scopes); - - // Tests that only the required scopes are reported as incompatible: - // scope should not contain Scope::AbstractUnixSocket. - assert!(matches!(Ruleset::from(ABI::Unsupported) - .scope(Scope::AbstractUnixSocket) - .unwrap() - .set_compatibility(CompatLevel::HardRequirement) - .scope(Scope::Signal) - .unwrap_err(), - RulesetError::Scope(ScopeError::Compat( - CompatError::Access(AccessError::Incompatible { access }) - )) if access == Scope::Signal - )); -} - -#[test] -fn ruleset_created_fs_net_scope() { - let access_fs = make_bitflags!(AccessFs::{Execute | ReadDir}); - let access_net = make_bitflags!(AccessNet::{BindTcp | ConnectTcp}); - let scopes = make_bitflags!(Scope::{AbstractUnixSocket | Signal}); - - // Tests composition (binary OR) of handled accesses. - let ruleset = Ruleset::from(ABI::V5) - .handle_access(access_fs) - .unwrap() - .scope(scopes) - .unwrap() - .handle_access(access_net) - .unwrap(); - assert_eq!(ruleset.requested_handled_fs, access_fs); - assert_eq!(ruleset.actual_handled_fs, access_fs); - assert_eq!(ruleset.requested_handled_net, access_net); - assert_eq!(ruleset.actual_handled_net, access_net); - assert_eq!(ruleset.requested_scoped, scopes); - assert_eq!(ruleset.actual_scoped, BitFlags::::EMPTY); - - // Tests composition (binary OR) of handled accesses and scopes. - let ruleset = Ruleset::from(ABI::V6) - .handle_access(access_fs) - .unwrap() - .scope(scopes) - .unwrap() - .handle_access(access_net) - .unwrap(); - assert_eq!(ruleset.requested_handled_fs, access_fs); - assert_eq!(ruleset.actual_handled_fs, access_fs); - assert_eq!(ruleset.requested_handled_net, access_net); - assert_eq!(ruleset.actual_handled_net, access_net); - assert_eq!(ruleset.requested_scoped, scopes); - assert_eq!(ruleset.actual_scoped, scopes); -} - -#[test] -fn ruleset_created_log_flags() { - let all_raw = uapi::LANDLOCK_RESTRICT_SELF_LOG_SAME_EXEC_OFF - | uapi::LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON - | uapi::LANDLOCK_RESTRICT_SELF_LOG_SUBDOMAINS_OFF; - - // Tests log flags with BestEffort on unsupported ABI: flags are requested but not applied. - let ruleset_created = Ruleset::from(ABI::Unsupported) - .handle_access(AccessFs::Execute) - .unwrap() - .create() - .unwrap() - .log_same_exec(false) - .unwrap() - .log_new_exec(true) - .unwrap() - .log_subdomains(false) - .unwrap(); - assert_eq!(ruleset_created.requested_restrict_self_flags, all_raw); - assert_eq!(ruleset_created.actual_restrict_self_flags, 0); - - // Tests that calling with default values is a no-op. - let ruleset_created = Ruleset::from(ABI::Unsupported) - .handle_access(AccessFs::Execute) - .unwrap() - .create() - .unwrap() - .log_same_exec(true) - .unwrap() - .log_new_exec(false) - .unwrap() - .log_subdomains(true) - .unwrap(); - assert_eq!(ruleset_created.requested_restrict_self_flags, 0); - assert_eq!(ruleset_created.actual_restrict_self_flags, 0); - - // Tests SoftRequirement on unsupported ABI: flag silently dropped, state becomes Dummy. - let ruleset_created = Ruleset::from(ABI::Unsupported) - .handle_access(AccessFs::Execute) - .unwrap() - .create() - .unwrap() - .set_compatibility(CompatLevel::SoftRequirement) - .log_same_exec(false) - .unwrap(); - assert_eq!( - ruleset_created.requested_restrict_self_flags, - uapi::LANDLOCK_RESTRICT_SELF_LOG_SAME_EXEC_OFF - ); - assert_eq!(ruleset_created.actual_restrict_self_flags, 0); - - // Default values with HardRequirement on unsupported ABI: no-ops bypass compat entirely. - Ruleset::from(ABI::Unsupported) - .handle_access(AccessFs::Execute) - .unwrap() - .create() - .unwrap() - .set_compatibility(CompatLevel::HardRequirement) - .log_same_exec(true) - .unwrap() - .log_new_exec(false) - .unwrap() - .log_subdomains(true) - .unwrap(); - - // Tests HardRequirement error for unsupported log flags. - assert!(matches!( - Ruleset::from(ABI::Unsupported) - .handle_access(AccessFs::Execute) - .unwrap() - .create() - .unwrap() - .set_compatibility(CompatLevel::HardRequirement) - .log_same_exec(false) - .unwrap_err(), - RulesetError::RestrictSelfFlags(SyscallFlagError::NotSupported { - flag: RestrictSelfFlag::LogSameExec, - set: false, - }) - )); -} - -#[test] -fn ruleset_created_all_threads() { - // Uses ABI::Unsupported throughout so create() does not call the real - // landlock_create_ruleset() syscall. The "flag applied" path on a - // supported ABI is covered by the RestrictSelf mock test and the forked - // integration test. - - // all_threads(true) requested but dropped by BestEffort on an unsupported - // ABI: only the calling thread would be restricted. - let ruleset_created = Ruleset::from(ABI::Unsupported) - .handle_access(AccessFs::Execute) - .unwrap() - .create() - .unwrap() - .all_threads(true) - .unwrap(); - assert_eq!( - ruleset_created.requested_restrict_self_flags, - uapi::LANDLOCK_RESTRICT_SELF_TSYNC - ); - assert_eq!(ruleset_created.actual_restrict_self_flags, 0); - - // all_threads(false) is the default: a no-op that bypasses the compat - // check, so it never errors even under HardRequirement on an unsupported - // ABI. - let ruleset_created = Ruleset::from(ABI::Unsupported) - .handle_access(AccessFs::Execute) - .unwrap() - .create() - .unwrap() - .set_compatibility(CompatLevel::HardRequirement) - .all_threads(false) - .unwrap(); - assert_eq!(ruleset_created.requested_restrict_self_flags, 0); - assert_eq!(ruleset_created.actual_restrict_self_flags, 0); - - // HardRequirement errors when all_threads(true) is unsupported. - assert!(matches!( - Ruleset::from(ABI::Unsupported) - .handle_access(AccessFs::Execute) - .unwrap() - .create() - .unwrap() - .set_compatibility(CompatLevel::HardRequirement) - .all_threads(true) - .unwrap_err(), - RulesetError::RestrictSelfFlags(SyscallFlagError::NotSupported { - flag: RestrictSelfFlag::AllThreads, - set: true, - }) - )); -} - -impl OptionCompatLevelMut for RulesetCreated { - fn as_option_compat_level_mut(&mut self) -> &mut Option { - &mut self.compat.level - } -} - -impl OptionCompatLevelMut for &mut RulesetCreated { - fn as_option_compat_level_mut(&mut self) -> &mut Option { - &mut self.compat.level - } -} - -impl Compatible for RulesetCreated {} - -impl Compatible for &mut RulesetCreated {} - -impl RestrictSelfFlagsState for RulesetCreated { - fn try_set_flag(&mut self, flag: RestrictSelfFlag, set: bool) -> Result<(), RulesetError> { - let raw_bit = flag.raw_bit(); - // Last-call-wins: requested tracks non-default user intent, actual - // tracks the bit that will be passed to the kernel. - // - // requested_restrict_self_flags is updated unconditionally; the - // actual bitmask is updated only if try_compat succeeds. On - // HardRequirement + unsupported, try_compat returns Err and - // requested_restrict_self_flags is left in a "user requested this" - // state; the builder is consumed by `?` on error so this - // inconsistency is not observable. - if set == flag.default_value() { - self.requested_restrict_self_flags &= !raw_bit; - } else { - self.requested_restrict_self_flags |= raw_bit; - } - if flag.try_compat(set, &mut self.compat)? { - self.actual_restrict_self_flags |= raw_bit; - } else { - self.actual_restrict_self_flags &= !raw_bit; - } - Ok(()) - } -} - -impl RestrictSelfFlagsState for &mut RulesetCreated { - fn try_set_flag(&mut self, flag: RestrictSelfFlag, set: bool) -> Result<(), RulesetError> { - (**self).try_set_flag(flag, set) - } -} - -impl RestrictSelfAttr for RulesetCreated {} -impl RestrictSelfAttr for &mut RulesetCreated {} - -pub trait RulesetCreatedAttr: - Sized + AsMut + Compatible + RestrictSelfAttr -{ - /// Attempts to add a new rule to the ruleset. - /// - /// On error, returns a wrapped [`AddRulesError`]. - fn add_rule(mut self, rule: T) -> Result - where - T: Rule, - U: HandledAccess + PrivateHandledAccess, - { - let body = || -> Result { - let self_ref = self.as_mut(); - rule.check_consistency(self_ref)?; - let mut compat_rule = match rule - .try_compat( - self_ref.compat.abi(), - self_ref.compat.level, - &mut self_ref.compat.state, - ) - .map_err(AddRuleError::Compat)? - { - Some(r) => r, - None => return Ok(self), - }; - match self_ref.compat.state { - CompatState::Init | CompatState::No | CompatState::Dummy => Ok(self), - CompatState::Full | CompatState::Partial => { - #[cfg(test)] - assert!(self_ref.fd.is_some()); - let fd = self_ref.fd.as_ref().map(|f| f.as_raw_fd()).unwrap_or(-1); - match unsafe { - uapi::landlock_add_rule(fd, T::TYPE_ID, compat_rule.as_ptr(), 0) - } { - 0 => Ok(self), - _ => Err(AddRuleError::::AddRuleCall { - source: Error::last_os_error(), - } - .into()), - } - } - } - }; - Ok(body()?) - } - - /// Attempts to add a set of new rules to the ruleset. - /// - /// On error, returns a (double) wrapped [`AddRulesError`]. - /// - /// # Example - /// - /// Create a custom iterator to read paths from environment variable. - /// - /// ``` - /// use landlock::{ - /// Access, AccessFs, BitFlags, PathBeneath, PathFd, PathFdError, RestrictionStatus, Ruleset, - /// RulesetAttr, RulesetCreatedAttr, RulesetError, ABI, - /// }; - /// use std::env; - /// use std::ffi::OsStr; - /// use std::os::unix::ffi::{OsStrExt, OsStringExt}; - /// use thiserror::Error; - /// - /// #[derive(Debug, Error)] - /// enum PathEnvError<'a> { - /// #[error(transparent)] - /// Ruleset(#[from] RulesetError), - /// #[error(transparent)] - /// AddRuleIter(#[from] PathFdError), - /// #[error("missing environment variable {0}")] - /// MissingVar(&'a str), - /// } - /// - /// struct PathEnv { - /// paths: Vec, - /// access: BitFlags, - /// } - /// - /// impl PathEnv { - /// // env_var is the name of an environment variable - /// // containing paths requested to be allowed. - /// // Paths are separated with ":", e.g. "/bin:/lib:/usr:/proc". - /// // In case an empty string is provided, - /// // no restrictions are applied. - /// // `access` is the set of access rights allowed for each of the parsed paths. - /// fn new<'a>( - /// env_var: &'a str, access: BitFlags - /// ) -> Result> { - /// Ok(Self { - /// paths: env::var_os(env_var) - /// .ok_or(PathEnvError::MissingVar(env_var))? - /// .into_vec(), - /// access, - /// }) - /// } - /// - /// fn iter( - /// &self, - /// ) -> impl Iterator, PathEnvError<'static>>> + '_ { - /// let is_empty = self.paths.is_empty(); - /// self.paths - /// .split(|b| *b == b':') - /// // Skips the first empty element from of an empty string. - /// .skip_while(move |_| is_empty) - /// .map(OsStr::from_bytes) - /// .map(move |path| - /// Ok(PathBeneath::new(PathFd::new(path)?, self.access))) - /// } - /// } - /// - /// fn restrict_env() -> Result> { - /// Ok(Ruleset::default() - /// .handle_access(AccessFs::from_all(ABI::V1))? - /// .create()? - /// // In the shell: export EXECUTABLE_PATH="/usr:/bin:/sbin" - /// .add_rules(PathEnv::new("EXECUTABLE_PATH", AccessFs::Execute.into())?.iter())? - /// .restrict_self()?) - /// } - /// ``` - fn add_rules(mut self, rules: I) -> Result - where - I: IntoIterator>, - T: Rule, - U: HandledAccess + PrivateHandledAccess, - E: From, - { - for rule in rules { - self = self.add_rule(rule?)?; - } - Ok(self) - } - - /// Configures the ruleset to call `prctl(2)` with the `PR_SET_NO_NEW_PRIVS` command - /// in [`restrict_self()`](RulesetCreated::restrict_self). - /// - /// This `prctl(2)` call is never ignored, even if an error was encountered on a [`Ruleset`] or - /// [`RulesetCreated`] method call while [`CompatLevel::SoftRequirement`] was set. - fn no_new_privs(mut self, yes: bool) -> Self { - >::as_mut(&mut self).no_new_privs = yes; - self - } - - /// Alias for [`no_new_privs()`](Self::no_new_privs). - #[deprecated(note = "Use no_new_privs() instead.")] - fn set_no_new_privs(self, yes: bool) -> Self { - self.no_new_privs(yes) - } - - /// Controls logging of denied accesses for the creating thread and its children - /// running the same executable (before `execve(2)`). - /// Logging is **enabled** by default. See - /// [kernel documentation](https://docs.kernel.org/userspace-api/landlock.html#enforcing-a-ruleset). - /// - /// Calling with `false` sets the `LANDLOCK_RESTRICT_SELF_LOG_SAME_EXEC_OFF` flag. - /// Calling with `true` is a no-op (the default behavior). - /// - /// This setter only applies when restricting with a domain. - /// - /// On error, returns a wrapped - /// [`SyscallFlagError`](crate::SyscallFlagError). - fn log_same_exec(mut self, set: bool) -> Result { - self.try_set_flag(RestrictSelfFlag::LogSameExec, set)?; - Ok(self) - } - - /// Controls logging of denied accesses after an `execve(2)` call. - /// Logging is **disabled** by default. See - /// [kernel documentation](https://docs.kernel.org/userspace-api/landlock.html#enforcing-a-ruleset). - /// - /// Calling with `true` sets the `LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON` flag. - /// Calling with `false` is a no-op (the default behavior). - /// - /// This setter only applies when restricting with a domain. - /// - /// On error, returns a wrapped - /// [`SyscallFlagError`](crate::SyscallFlagError). - fn log_new_exec(mut self, set: bool) -> Result { - self.try_set_flag(RestrictSelfFlag::LogNewExec, set)?; - Ok(self) - } -} - -/// Ruleset created with [`Ruleset::create()`]. -#[derive(Debug)] -pub struct RulesetCreated { - fd: Option, - no_new_privs: bool, - pub(crate) requested_handled_fs: BitFlags, - pub(crate) requested_handled_net: BitFlags, - requested_restrict_self_flags: u32, - actual_restrict_self_flags: u32, - compat: Compatibility, -} - -impl RulesetCreated { - pub(crate) fn new(ruleset: Ruleset, fd: Option) -> Self { - // The compatibility state is initialized by Ruleset::create(). - #[cfg(test)] - assert!(!matches!(ruleset.compat.state, CompatState::Init)); - - RulesetCreated { - fd, - no_new_privs: true, - requested_handled_fs: ruleset.requested_handled_fs, - requested_handled_net: ruleset.requested_handled_net, - requested_restrict_self_flags: 0, - actual_restrict_self_flags: 0, - compat: ruleset.compat, - } - } - - /// Attempts to restrict the calling thread with the ruleset - /// according to the best-effort configuration - /// (see [`RulesetCreated::set_compatibility()`] and [`CompatLevel::BestEffort`]). - /// Call `prctl(2)` with the `PR_SET_NO_NEW_PRIVS` - /// according to the ruleset configuration. - /// - /// On error, returns a wrapped [`RestrictSelfError`]. - pub fn restrict_self(mut self) -> Result { - let mut body = || -> Result { - // Enforce no_new_privs even if something failed with SoftRequirement. The rationale is - // that no_new_privs should not be an issue on its own if it is not explicitly - // deactivated. - let enforced_nnp = if self.no_new_privs { - try_set_no_new_privs(&mut self.compat)? - } else { - false - }; - - let raw = self.actual_restrict_self_flags; - let log_same_exec = RestrictSelfFlag::LogSameExec.is_set(raw); - let log_new_exec = RestrictSelfFlag::LogNewExec.is_set(raw); - let log_subdomains = RestrictSelfFlag::LogSubdomains.is_set(raw); - let all_threads = RestrictSelfFlag::AllThreads.is_set(raw); - - match self.compat.state { - CompatState::Init | CompatState::No | CompatState::Dummy => Ok(RestrictionStatus { - ruleset: self.compat.state.into(), - landlock: self.compat.status(), - no_new_privs: enforced_nnp, - log_same_exec, - log_new_exec, - log_subdomains, - all_threads, - }), - CompatState::Full | CompatState::Partial => { - #[cfg(test)] - assert!(self.fd.is_some()); - // Does not consume ruleset FD, which will be automatically closed after this block. - let fd = self.fd.as_ref().map(|f| f.as_raw_fd()).unwrap_or(-1); - match unsafe { - uapi::landlock_restrict_self(fd, self.actual_restrict_self_flags) - } { - 0 => { - self.compat.update(CompatState::Full); - Ok(RestrictionStatus { - ruleset: self.compat.state.into(), - landlock: self.compat.status(), - no_new_privs: enforced_nnp, - log_same_exec, - log_new_exec, - log_subdomains, - all_threads, - }) - } - // TODO: match specific Landlock restrict self errors - _ => Err(RestrictSelfError::RestrictSelfCall { - source: Error::last_os_error(), - }), - } - } - } - }; - Ok(body()?) - } - - /// Creates a new `RulesetCreated` instance by duplicating the underlying file descriptor. - /// Rule modification will affect both `RulesetCreated` instances simultaneously. - /// - /// On error, returns [`std::io::Error`]. - pub fn try_clone(&self) -> std::io::Result { - Ok(RulesetCreated { - fd: self.fd.as_ref().map(|f| f.try_clone()).transpose()?, - no_new_privs: self.no_new_privs, - requested_handled_fs: self.requested_handled_fs, - requested_handled_net: self.requested_handled_net, - requested_restrict_self_flags: self.requested_restrict_self_flags, - actual_restrict_self_flags: self.actual_restrict_self_flags, - compat: self.compat, - }) - } -} - -impl From for Option { - fn from(ruleset: RulesetCreated) -> Self { - ruleset.fd - } -} - -#[test] -fn ruleset_created_ownedfd_none() { - let ruleset = Ruleset::from(ABI::Unsupported) - .handle_access(AccessFs::Execute) - .unwrap() - .create() - .unwrap(); - let fd: Option = ruleset.into(); - assert!(fd.is_none()); -} - -impl AsMut for RulesetCreated { - fn as_mut(&mut self) -> &mut RulesetCreated { - self - } -} - -impl RulesetCreatedAttr for RulesetCreated {} - -impl RulesetCreatedAttr for &mut RulesetCreated {} - -#[test] -fn ruleset_created_attr() { - let mut ruleset_created = Ruleset::from(ABI::Unsupported) - .handle_access(AccessFs::Execute) - .unwrap() - .create() - .unwrap(); - let ruleset_created_ref = &mut ruleset_created; - - // Can pass this reference to populate the ruleset... - ruleset_created_ref - .set_compatibility(CompatLevel::BestEffort) - .add_rule(PathBeneath::new( - PathFd::new("/usr").unwrap(), - AccessFs::Execute, - )) - .unwrap() - .add_rule(PathBeneath::new( - PathFd::new("/etc").unwrap(), - AccessFs::Execute, - )) - .unwrap(); - - // ...and finally restrict with the last rules (thanks to non-lexical lifetimes). - assert_eq!( - ruleset_created - .set_compatibility(CompatLevel::BestEffort) - .add_rule(PathBeneath::new( - PathFd::new("/tmp").unwrap(), - AccessFs::Execute, - )) - .unwrap() - .add_rule(PathBeneath::new( - PathFd::new("/var").unwrap(), - AccessFs::Execute, - )) - .unwrap() - .restrict_self() - .unwrap(), - RestrictionStatus { - ruleset: RulesetStatus::NotEnforced, - landlock: LandlockStatus::NotImplemented, - no_new_privs: true, - log_same_exec: true, - log_new_exec: false, - log_subdomains: true, - all_threads: false, - } - ); -} - -#[test] -fn ruleset_compat_dummy() { - for level in [CompatLevel::BestEffort, CompatLevel::SoftRequirement] { - println!("level: {:?}", level); - - // ABI:Unsupported does not support AccessFs::Execute. - let ruleset = Ruleset::from(ABI::Unsupported); - assert_eq!(ruleset.compat.state, CompatState::Init); - - let ruleset = ruleset.set_compatibility(level); - assert_eq!(ruleset.compat.state, CompatState::Init); - - let ruleset = ruleset.handle_access(AccessFs::Execute).unwrap(); - assert_eq!( - ruleset.compat.state, - match level { - CompatLevel::BestEffort => CompatState::No, - CompatLevel::SoftRequirement => CompatState::Dummy, - _ => unreachable!(), - } - ); - - let ruleset_created = ruleset.create().unwrap(); - // Because the compatibility state was either No or Dummy, calling create() updates it to - // Dummy. - assert_eq!(ruleset_created.compat.state, CompatState::Dummy); - - let ruleset_created = ruleset_created - .add_rule(PathBeneath::new( - PathFd::new("/usr").unwrap(), - AccessFs::Execute, - )) - .unwrap(); - assert_eq!(ruleset_created.compat.state, CompatState::Dummy); - } -} - -#[test] -fn ruleset_compat_partial() { - // CompatLevel::BestEffort - let ruleset = Ruleset::from(ABI::V1); - assert_eq!(ruleset.compat.state, CompatState::Init); - - // ABI::V1 does not support AccessFs::Refer. - let ruleset = ruleset.handle_access(AccessFs::Refer).unwrap(); - assert_eq!(ruleset.compat.state, CompatState::No); - - let ruleset = ruleset.handle_access(AccessFs::Execute).unwrap(); - assert_eq!(ruleset.compat.state, CompatState::Partial); - - // Requesting to handle another unsupported handled access does not change anything. - let ruleset = ruleset.handle_access(AccessFs::Refer).unwrap(); - assert_eq!(ruleset.compat.state, CompatState::Partial); -} - -#[test] -fn ruleset_unsupported() { - assert_eq!( - Ruleset::from(ABI::Unsupported) - // BestEffort for Ruleset. - .handle_access(AccessFs::Execute) - .unwrap() - .create() - .unwrap() - .restrict_self() - .unwrap(), - RestrictionStatus { - ruleset: RulesetStatus::NotEnforced, - landlock: LandlockStatus::NotImplemented, - // With BestEffort, no_new_privs is still enabled. - no_new_privs: true, - log_same_exec: true, - log_new_exec: false, - log_subdomains: true, - all_threads: false, - } - ); - - assert_eq!( - Ruleset::from(ABI::Unsupported) - // SoftRequirement for Ruleset. - .set_compatibility(CompatLevel::SoftRequirement) - .handle_access(AccessFs::Execute) - .unwrap() - .create() - .unwrap() - .restrict_self() - .unwrap(), - RestrictionStatus { - ruleset: RulesetStatus::NotEnforced, - landlock: LandlockStatus::NotImplemented, - // With SoftRequirement, no_new_privs is still enabled. - no_new_privs: true, - log_same_exec: true, - log_new_exec: false, - log_subdomains: true, - all_threads: false, - } - ); - - // Incompatible handled access because of the compatibility level. - assert!(matches!( - Ruleset::from(ABI::Unsupported) - // HardRequirement for Ruleset. - .set_compatibility(CompatLevel::HardRequirement) - .handle_access(AccessFs::Execute) - .unwrap_err(), - RulesetError::HandleAccesses(HandleAccessesError::Fs(HandleAccessError::Compat( - CompatError::Access(AccessError::Incompatible { .. }) - ))) - )); - - // Incompatible scope because of the compatibility level. - assert!(matches!( - Ruleset::from(ABI::Unsupported) - // HardRequirement for Ruleset. - .set_compatibility(CompatLevel::HardRequirement) - .scope(Scope::Signal) - .unwrap_err(), - RulesetError::Scope(ScopeError::Compat(CompatError::Access( - AccessError::Incompatible { .. } - ))) - )); - - assert_eq!( - Ruleset::from(ABI::Unsupported) - .handle_access(AccessFs::Execute) - .unwrap() - .create() - .unwrap() - // SoftRequirement for RulesetCreated without any rule. - .set_compatibility(CompatLevel::SoftRequirement) - .restrict_self() - .unwrap(), - RestrictionStatus { - ruleset: RulesetStatus::NotEnforced, - landlock: LandlockStatus::NotImplemented, - // With SoftRequirement, no_new_privs is untouched if there is no error (e.g. no rule). - no_new_privs: true, - log_same_exec: true, - log_new_exec: false, - log_subdomains: true, - all_threads: false, - } - ); - - // Don't explicitly call create() on a CI that doesn't support Landlock. - if compat::can_emulate(ABI::V1, ABI::V1, Some(ABI::V2)) { - assert_eq!( - Ruleset::from(ABI::V1) - .handle_access(make_bitflags!(AccessFs::{Execute | Refer})) - .unwrap() - .create() - .unwrap() - // SoftRequirement for RulesetCreated with a rule. - .set_compatibility(CompatLevel::SoftRequirement) - .add_rule(PathBeneath::new(PathFd::new("/").unwrap(), AccessFs::Refer)) - .unwrap() - .restrict_self() - .unwrap(), - RestrictionStatus { - ruleset: RulesetStatus::NotEnforced, - landlock: LandlockStatus::Available { - effective_abi: ABI::V1, - kernel_abi: None, - }, - // With SoftRequirement, no_new_privs is still enabled, even if there is an error - // (e.g. unsupported access right). - no_new_privs: true, - log_same_exec: true, - log_new_exec: false, - log_subdomains: true, - all_threads: false, - } - ); - } - - assert_eq!( - Ruleset::from(ABI::Unsupported) - .handle_access(AccessFs::Execute) - .unwrap() - .create() - .unwrap() - .no_new_privs(false) - .restrict_self() - .unwrap(), - RestrictionStatus { - ruleset: RulesetStatus::NotEnforced, - landlock: LandlockStatus::NotImplemented, - no_new_privs: false, - log_same_exec: true, - log_new_exec: false, - log_subdomains: true, - all_threads: false, - } - ); - - // Checks empty handled access with moot ruleset. - assert!(matches!( - Ruleset::from(ABI::Unsupported) - // Empty access-rights - .handle_access(AccessFs::from_all(ABI::Unsupported)) - .unwrap_err(), - RulesetError::HandleAccesses(HandleAccessesError::Fs(HandleAccessError::Compat( - CompatError::Access(AccessError::Empty) - ))) - )); - - assert!(matches!( - Ruleset::from(ABI::Unsupported) - // No handle_access() nor scope() call. - .create() - .unwrap_err(), - RulesetError::CreateRuleset(CreateRulesetError::MissingHandledAccess) - )); - - // Checks empty handled access with minimal ruleset. - assert!(matches!( - Ruleset::from(ABI::V1) - // Empty access-rights - .handle_access(AccessFs::from_all(ABI::Unsupported)) - .unwrap_err(), - RulesetError::HandleAccesses(HandleAccessesError::Fs(HandleAccessError::Compat( - CompatError::Access(AccessError::Empty) - ))) - )); - - // Checks empty scope with moot ruleset. - assert!(matches!( - Ruleset::from(ABI::Unsupported) - .scope(Scope::from_all(ABI::Unsupported)) - .unwrap_err(), - RulesetError::Scope(ScopeError::Compat(CompatError::Access(AccessError::Empty))) - )); - - // Checks empty scope with minimal ruleset. - assert!(matches!( - Ruleset::from(ABI::V1) - .scope(Scope::from_all(ABI::Unsupported)) - .unwrap_err(), - RulesetError::Scope(ScopeError::Compat(CompatError::Access(AccessError::Empty))) - )); - - // Scope with SoftRequirement on unsupported ABI: silently dropped, state becomes Dummy. - let ruleset = Ruleset::from(ABI::V1) - .handle_access(AccessFs::Execute) - .unwrap() - .set_compatibility(CompatLevel::SoftRequirement) - .scope(Scope::Signal) - .unwrap(); - assert_eq!(ruleset.requested_scoped, BitFlags::from(Scope::Signal)); - assert_eq!(ruleset.actual_scoped, BitFlags::::EMPTY); - - // Log flags with BestEffort on unsupported ABI are silently ignored. - assert_eq!( - Ruleset::from(ABI::Unsupported) - .handle_access(AccessFs::Execute) - .unwrap() - .create() - .unwrap() - .log_same_exec(false) - .unwrap() - .restrict_self() - .unwrap(), - RestrictionStatus { - ruleset: RulesetStatus::NotEnforced, - landlock: LandlockStatus::NotImplemented, - no_new_privs: true, - log_same_exec: true, - log_new_exec: false, - log_subdomains: true, - all_threads: false, - } - ); - - // Log flags with HardRequirement on unsupported ABI return an error. - assert!(matches!( - Ruleset::from(ABI::Unsupported) - .handle_access(AccessFs::Execute) - .unwrap() - .create() - .unwrap() - .set_compatibility(CompatLevel::HardRequirement) - .log_new_exec(true) - .unwrap_err(), - RulesetError::RestrictSelfFlags(SyscallFlagError::NotSupported { - flag: RestrictSelfFlag::LogNewExec, - set: true, - }) - )); - - // Tests inconsistency between the ruleset handled access-rights and the rule access-rights. - for handled_access in &[ - make_bitflags!(AccessFs::{Execute | WriteFile}), - AccessFs::Execute.into(), - ] { - let ruleset = Ruleset::from(ABI::V1) - .handle_access(*handled_access) - .unwrap(); - // Fakes a call to create() to test without involving the kernel (i.e. no - // landlock_ruleset_create() call). - let ruleset_created = RulesetCreated::new(ruleset, None); - assert!(matches!( - ruleset_created - .add_rule(PathBeneath::new( - PathFd::new("/").unwrap(), - AccessFs::ReadFile - )) - .unwrap_err(), - RulesetError::AddRules(AddRulesError::Fs(AddRuleError::UnhandledAccess { .. })) - )); - } -} - -#[test] -fn ignore_abi_v2_with_abi_v1() { - // We don't need kernel/CI support for Landlock because no related syscalls should actually be - // performed. - assert_eq!( - Ruleset::from(ABI::V1) - .set_compatibility(CompatLevel::HardRequirement) - .handle_access(AccessFs::from_all(ABI::V1)) - .unwrap() - .set_compatibility(CompatLevel::SoftRequirement) - // Because Ruleset only supports V1, Refer will be ignored. - .handle_access(AccessFs::Refer) - .unwrap() - .create() - .unwrap() - .add_rule(PathBeneath::new( - PathFd::new("/tmp").unwrap(), - AccessFs::from_all(ABI::V2) - )) - .unwrap() - .add_rule(PathBeneath::new( - PathFd::new("/usr").unwrap(), - make_bitflags!(AccessFs::{ReadFile | ReadDir}) - )) - .unwrap() - .restrict_self() - .unwrap(), - RestrictionStatus { - ruleset: RulesetStatus::NotEnforced, - landlock: LandlockStatus::Available { - effective_abi: ABI::V1, - kernel_abi: None, - }, - no_new_privs: true, - log_same_exec: true, - log_new_exec: false, - log_subdomains: true, - all_threads: false, - } - ); -} - -#[test] -fn unsupported_handled_access() { - assert!(matches!( - Ruleset::from(ABI::V3) - .handle_access(AccessNet::from_all(ABI::V3)) - .unwrap_err(), - RulesetError::HandleAccesses(HandleAccessesError::Net(HandleAccessError::Compat( - CompatError::Access(AccessError::Empty) - ))) - )); -} - -#[test] -fn unsupported_handled_access_errno() { - assert_eq!( - Errno::from( - Ruleset::from(ABI::V3) - .handle_access(AccessNet::from_all(ABI::V3)) - .unwrap_err() - ), - Errno::new(libc::EINVAL) - ); -} diff --git a/third_party/rust-landlock/src/scope.rs b/third_party/rust-landlock/src/scope.rs deleted file mode 100644 index 69a4f1b..0000000 --- a/third_party/rust-landlock/src/scope.rs +++ /dev/null @@ -1,57 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 OR MIT - -use crate::{uapi, Access, ABI}; -use enumflags2::{bitflags, BitFlags}; - -/// Scope right. -/// -/// Each variant of `Scope` is a -/// [scope flag](https://www.kernel.org/doc/html/latest/userspace-api/landlock.html#scope-flags). -/// A set of scopes can be created with [`BitFlags`](BitFlags). -/// -/// # Example -/// -/// ``` -/// use landlock::{ABI, Access, Scope, BitFlags, make_bitflags}; -/// -/// let signal = Scope::Signal; -/// -/// let signal_set: BitFlags = signal.into(); -/// -/// let signal_uds = make_bitflags!(Scope::{Signal | AbstractUnixSocket}); -/// -/// let scope_v6 = Scope::from_all(ABI::V6); -/// -/// assert_eq!(signal_uds, scope_v6); -/// ``` -/// -/// # Warning -/// -/// To avoid unknown restrictions **don't use `BitFlags::::all()` nor `BitFlags::ALL`**, -/// but use a version you tested and vetted instead, -/// for instance [`Scope::from_all(ABI::V6)`](Access::from_all). -/// Direct use of **the [`BitFlags`] API is deprecated**. -/// See [`ABI`] for the rationale and help to test it. -#[bitflags] -#[repr(u64)] -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -#[non_exhaustive] -pub enum Scope { - /// Restrict from connecting to abstract UNIX sockets created outside the sandbox. - AbstractUnixSocket = uapi::LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET as u64, - /// Restrict from sending signals to processes outside the sandbox. - Signal = uapi::LANDLOCK_SCOPE_SIGNAL as u64, -} - -/// # Warning -/// -/// If `ABI <= ABI::V5`, `Scope::from_all()` returns an empty `BitFlags`, which -/// makes `Ruleset::handle_access(AccessScope::from_all(ABI::V5))` return an error. -impl Access for Scope { - fn from_all(abi: ABI) -> BitFlags { - match abi { - ABI::Unsupported | ABI::V1 | ABI::V2 | ABI::V3 | ABI::V4 | ABI::V5 => BitFlags::EMPTY, - ABI::V6 | ABI::V7 | ABI::V8 | ABI::V9 => Scope::AbstractUnixSocket | Scope::Signal, - } - } -} diff --git a/third_party/rust-landlock/src/uapi/bindgen.sh b/third_party/rust-landlock/src/uapi/bindgen.sh deleted file mode 100755 index 45c0da9..0000000 --- a/third_party/rust-landlock/src/uapi/bindgen.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bash -# SPDX-License-Identifier: Apache-2.0 OR MIT - -set -u -e -o pipefail - -if [[ $# -ne 1 ]]; then - echo "usage $(basename -- "${BASH_SOURCE[0]}") " >&2 - exit 1 -fi - -HEADER="$(readlink -f -- "$1")/include/uapi/linux/landlock.h" - -if [[ ! -f "${HEADER}" ]]; then - echo "File not found: ${HEADER}" >&2 - exit 1 -fi - -cd "$(dirname "${BASH_SOURCE[0]}")" - -MSRV="$(sed -n 's/^rust-version = "\(.*\)"/\1/p' ../../Cargo.toml)" - -bindgen_landlock() { - local arch="$1" - local output="$2" - shift 2 - - bindgen \ - "$@" \ - --rust-target "${MSRV}" \ - --allowlist-type "landlock_.*" \ - --allowlist-var "LANDLOCK_.*" \ - --no-doc-comments \ - --no-derive-default \ - --output "${output}" \ - "${HEADER}" \ - -- \ - --target="${arch}-linux-gnu" -} - -for ARCH in x86_64 i686; do - echo "Generating bindings with tests for ${ARCH}." - bindgen_landlock "${ARCH}" "landlock_${ARCH}.rs" -done - -# The Landlock ABI is architecture-agnostic (except for std::os::raw and memory -# alignment). -echo "Generating bindings without tests." -bindgen_landlock x86_64 "landlock_all.rs" --no-layout-tests diff --git a/third_party/rust-landlock/src/uapi/landlock_all.rs b/third_party/rust-landlock/src/uapi/landlock_all.rs deleted file mode 100644 index 17fa52f..0000000 --- a/third_party/rust-landlock/src/uapi/landlock_all.rs +++ /dev/null @@ -1,53 +0,0 @@ -/* automatically generated by rust-bindgen 0.72.1 */ - -pub const LANDLOCK_CREATE_RULESET_VERSION: u32 = 1; -pub const LANDLOCK_CREATE_RULESET_ERRATA: u32 = 2; -pub const LANDLOCK_RESTRICT_SELF_LOG_SAME_EXEC_OFF: u32 = 1; -pub const LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON: u32 = 2; -pub const LANDLOCK_RESTRICT_SELF_LOG_SUBDOMAINS_OFF: u32 = 4; -pub const LANDLOCK_RESTRICT_SELF_TSYNC: u32 = 8; -pub const LANDLOCK_ACCESS_FS_EXECUTE: u32 = 1; -pub const LANDLOCK_ACCESS_FS_WRITE_FILE: u32 = 2; -pub const LANDLOCK_ACCESS_FS_READ_FILE: u32 = 4; -pub const LANDLOCK_ACCESS_FS_READ_DIR: u32 = 8; -pub const LANDLOCK_ACCESS_FS_REMOVE_DIR: u32 = 16; -pub const LANDLOCK_ACCESS_FS_REMOVE_FILE: u32 = 32; -pub const LANDLOCK_ACCESS_FS_MAKE_CHAR: u32 = 64; -pub const LANDLOCK_ACCESS_FS_MAKE_DIR: u32 = 128; -pub const LANDLOCK_ACCESS_FS_MAKE_REG: u32 = 256; -pub const LANDLOCK_ACCESS_FS_MAKE_SOCK: u32 = 512; -pub const LANDLOCK_ACCESS_FS_MAKE_FIFO: u32 = 1024; -pub const LANDLOCK_ACCESS_FS_MAKE_BLOCK: u32 = 2048; -pub const LANDLOCK_ACCESS_FS_MAKE_SYM: u32 = 4096; -pub const LANDLOCK_ACCESS_FS_REFER: u32 = 8192; -pub const LANDLOCK_ACCESS_FS_TRUNCATE: u32 = 16384; -pub const LANDLOCK_ACCESS_FS_IOCTL_DEV: u32 = 32768; -pub const LANDLOCK_ACCESS_FS_RESOLVE_UNIX: u32 = 65536; -pub const LANDLOCK_ACCESS_NET_BIND_TCP: u32 = 1; -pub const LANDLOCK_ACCESS_NET_CONNECT_TCP: u32 = 2; -pub const LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET: u32 = 1; -pub const LANDLOCK_SCOPE_SIGNAL: u32 = 2; -pub type __s32 = ::std::os::raw::c_int; -pub type __u64 = ::std::os::raw::c_ulonglong; -#[repr(C)] -#[derive(Debug, Copy, Clone)] -pub struct landlock_ruleset_attr { - pub handled_access_fs: __u64, - pub handled_access_net: __u64, - pub scoped: __u64, -} -pub const landlock_rule_type_LANDLOCK_RULE_PATH_BENEATH: landlock_rule_type = 1; -pub const landlock_rule_type_LANDLOCK_RULE_NET_PORT: landlock_rule_type = 2; -pub type landlock_rule_type = ::std::os::raw::c_uint; -#[repr(C, packed)] -#[derive(Debug, Copy, Clone)] -pub struct landlock_path_beneath_attr { - pub allowed_access: __u64, - pub parent_fd: __s32, -} -#[repr(C)] -#[derive(Debug, Copy, Clone)] -pub struct landlock_net_port_attr { - pub allowed_access: __u64, - pub port: __u64, -} diff --git a/third_party/rust-landlock/src/uapi/landlock_i686.rs b/third_party/rust-landlock/src/uapi/landlock_i686.rs deleted file mode 100644 index 9349ca6..0000000 --- a/third_party/rust-landlock/src/uapi/landlock_i686.rs +++ /dev/null @@ -1,136 +0,0 @@ -/* automatically generated by rust-bindgen 0.72.1 */ - -pub const LANDLOCK_CREATE_RULESET_VERSION: u32 = 1; -pub const LANDLOCK_CREATE_RULESET_ERRATA: u32 = 2; -pub const LANDLOCK_RESTRICT_SELF_LOG_SAME_EXEC_OFF: u32 = 1; -pub const LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON: u32 = 2; -pub const LANDLOCK_RESTRICT_SELF_LOG_SUBDOMAINS_OFF: u32 = 4; -pub const LANDLOCK_RESTRICT_SELF_TSYNC: u32 = 8; -pub const LANDLOCK_ACCESS_FS_EXECUTE: u32 = 1; -pub const LANDLOCK_ACCESS_FS_WRITE_FILE: u32 = 2; -pub const LANDLOCK_ACCESS_FS_READ_FILE: u32 = 4; -pub const LANDLOCK_ACCESS_FS_READ_DIR: u32 = 8; -pub const LANDLOCK_ACCESS_FS_REMOVE_DIR: u32 = 16; -pub const LANDLOCK_ACCESS_FS_REMOVE_FILE: u32 = 32; -pub const LANDLOCK_ACCESS_FS_MAKE_CHAR: u32 = 64; -pub const LANDLOCK_ACCESS_FS_MAKE_DIR: u32 = 128; -pub const LANDLOCK_ACCESS_FS_MAKE_REG: u32 = 256; -pub const LANDLOCK_ACCESS_FS_MAKE_SOCK: u32 = 512; -pub const LANDLOCK_ACCESS_FS_MAKE_FIFO: u32 = 1024; -pub const LANDLOCK_ACCESS_FS_MAKE_BLOCK: u32 = 2048; -pub const LANDLOCK_ACCESS_FS_MAKE_SYM: u32 = 4096; -pub const LANDLOCK_ACCESS_FS_REFER: u32 = 8192; -pub const LANDLOCK_ACCESS_FS_TRUNCATE: u32 = 16384; -pub const LANDLOCK_ACCESS_FS_IOCTL_DEV: u32 = 32768; -pub const LANDLOCK_ACCESS_FS_RESOLVE_UNIX: u32 = 65536; -pub const LANDLOCK_ACCESS_NET_BIND_TCP: u32 = 1; -pub const LANDLOCK_ACCESS_NET_CONNECT_TCP: u32 = 2; -pub const LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET: u32 = 1; -pub const LANDLOCK_SCOPE_SIGNAL: u32 = 2; -pub type __s32 = ::std::os::raw::c_int; -pub type __u64 = ::std::os::raw::c_ulonglong; -#[repr(C)] -#[derive(Debug, Copy, Clone)] -pub struct landlock_ruleset_attr { - pub handled_access_fs: __u64, - pub handled_access_net: __u64, - pub scoped: __u64, -} -#[test] -fn bindgen_test_layout_landlock_ruleset_attr() { - const UNINIT: ::std::mem::MaybeUninit = - ::std::mem::MaybeUninit::uninit(); - let ptr = UNINIT.as_ptr(); - assert_eq!( - ::std::mem::size_of::(), - 24usize, - "Size of landlock_ruleset_attr" - ); - assert_eq!( - ::std::mem::align_of::(), - 4usize, - "Alignment of landlock_ruleset_attr" - ); - assert_eq!( - unsafe { ::std::ptr::addr_of!((*ptr).handled_access_fs) as usize - ptr as usize }, - 0usize, - "Offset of field: landlock_ruleset_attr::handled_access_fs" - ); - assert_eq!( - unsafe { ::std::ptr::addr_of!((*ptr).handled_access_net) as usize - ptr as usize }, - 8usize, - "Offset of field: landlock_ruleset_attr::handled_access_net" - ); - assert_eq!( - unsafe { ::std::ptr::addr_of!((*ptr).scoped) as usize - ptr as usize }, - 16usize, - "Offset of field: landlock_ruleset_attr::scoped" - ); -} -pub const landlock_rule_type_LANDLOCK_RULE_PATH_BENEATH: landlock_rule_type = 1; -pub const landlock_rule_type_LANDLOCK_RULE_NET_PORT: landlock_rule_type = 2; -pub type landlock_rule_type = ::std::os::raw::c_uint; -#[repr(C, packed)] -#[derive(Debug, Copy, Clone)] -pub struct landlock_path_beneath_attr { - pub allowed_access: __u64, - pub parent_fd: __s32, -} -#[test] -fn bindgen_test_layout_landlock_path_beneath_attr() { - const UNINIT: ::std::mem::MaybeUninit = - ::std::mem::MaybeUninit::uninit(); - let ptr = UNINIT.as_ptr(); - assert_eq!( - ::std::mem::size_of::(), - 12usize, - "Size of landlock_path_beneath_attr" - ); - assert_eq!( - ::std::mem::align_of::(), - 1usize, - "Alignment of landlock_path_beneath_attr" - ); - assert_eq!( - unsafe { ::std::ptr::addr_of!((*ptr).allowed_access) as usize - ptr as usize }, - 0usize, - "Offset of field: landlock_path_beneath_attr::allowed_access" - ); - assert_eq!( - unsafe { ::std::ptr::addr_of!((*ptr).parent_fd) as usize - ptr as usize }, - 8usize, - "Offset of field: landlock_path_beneath_attr::parent_fd" - ); -} -#[repr(C)] -#[derive(Debug, Copy, Clone)] -pub struct landlock_net_port_attr { - pub allowed_access: __u64, - pub port: __u64, -} -#[test] -fn bindgen_test_layout_landlock_net_port_attr() { - const UNINIT: ::std::mem::MaybeUninit = - ::std::mem::MaybeUninit::uninit(); - let ptr = UNINIT.as_ptr(); - assert_eq!( - ::std::mem::size_of::(), - 16usize, - "Size of landlock_net_port_attr" - ); - assert_eq!( - ::std::mem::align_of::(), - 4usize, - "Alignment of landlock_net_port_attr" - ); - assert_eq!( - unsafe { ::std::ptr::addr_of!((*ptr).allowed_access) as usize - ptr as usize }, - 0usize, - "Offset of field: landlock_net_port_attr::allowed_access" - ); - assert_eq!( - unsafe { ::std::ptr::addr_of!((*ptr).port) as usize - ptr as usize }, - 8usize, - "Offset of field: landlock_net_port_attr::port" - ); -} diff --git a/third_party/rust-landlock/src/uapi/landlock_x86_64.rs b/third_party/rust-landlock/src/uapi/landlock_x86_64.rs deleted file mode 100644 index 0d5d243..0000000 --- a/third_party/rust-landlock/src/uapi/landlock_x86_64.rs +++ /dev/null @@ -1,136 +0,0 @@ -/* automatically generated by rust-bindgen 0.72.1 */ - -pub const LANDLOCK_CREATE_RULESET_VERSION: u32 = 1; -pub const LANDLOCK_CREATE_RULESET_ERRATA: u32 = 2; -pub const LANDLOCK_RESTRICT_SELF_LOG_SAME_EXEC_OFF: u32 = 1; -pub const LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON: u32 = 2; -pub const LANDLOCK_RESTRICT_SELF_LOG_SUBDOMAINS_OFF: u32 = 4; -pub const LANDLOCK_RESTRICT_SELF_TSYNC: u32 = 8; -pub const LANDLOCK_ACCESS_FS_EXECUTE: u32 = 1; -pub const LANDLOCK_ACCESS_FS_WRITE_FILE: u32 = 2; -pub const LANDLOCK_ACCESS_FS_READ_FILE: u32 = 4; -pub const LANDLOCK_ACCESS_FS_READ_DIR: u32 = 8; -pub const LANDLOCK_ACCESS_FS_REMOVE_DIR: u32 = 16; -pub const LANDLOCK_ACCESS_FS_REMOVE_FILE: u32 = 32; -pub const LANDLOCK_ACCESS_FS_MAKE_CHAR: u32 = 64; -pub const LANDLOCK_ACCESS_FS_MAKE_DIR: u32 = 128; -pub const LANDLOCK_ACCESS_FS_MAKE_REG: u32 = 256; -pub const LANDLOCK_ACCESS_FS_MAKE_SOCK: u32 = 512; -pub const LANDLOCK_ACCESS_FS_MAKE_FIFO: u32 = 1024; -pub const LANDLOCK_ACCESS_FS_MAKE_BLOCK: u32 = 2048; -pub const LANDLOCK_ACCESS_FS_MAKE_SYM: u32 = 4096; -pub const LANDLOCK_ACCESS_FS_REFER: u32 = 8192; -pub const LANDLOCK_ACCESS_FS_TRUNCATE: u32 = 16384; -pub const LANDLOCK_ACCESS_FS_IOCTL_DEV: u32 = 32768; -pub const LANDLOCK_ACCESS_FS_RESOLVE_UNIX: u32 = 65536; -pub const LANDLOCK_ACCESS_NET_BIND_TCP: u32 = 1; -pub const LANDLOCK_ACCESS_NET_CONNECT_TCP: u32 = 2; -pub const LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET: u32 = 1; -pub const LANDLOCK_SCOPE_SIGNAL: u32 = 2; -pub type __s32 = ::std::os::raw::c_int; -pub type __u64 = ::std::os::raw::c_ulonglong; -#[repr(C)] -#[derive(Debug, Copy, Clone)] -pub struct landlock_ruleset_attr { - pub handled_access_fs: __u64, - pub handled_access_net: __u64, - pub scoped: __u64, -} -#[test] -fn bindgen_test_layout_landlock_ruleset_attr() { - const UNINIT: ::std::mem::MaybeUninit = - ::std::mem::MaybeUninit::uninit(); - let ptr = UNINIT.as_ptr(); - assert_eq!( - ::std::mem::size_of::(), - 24usize, - "Size of landlock_ruleset_attr" - ); - assert_eq!( - ::std::mem::align_of::(), - 8usize, - "Alignment of landlock_ruleset_attr" - ); - assert_eq!( - unsafe { ::std::ptr::addr_of!((*ptr).handled_access_fs) as usize - ptr as usize }, - 0usize, - "Offset of field: landlock_ruleset_attr::handled_access_fs" - ); - assert_eq!( - unsafe { ::std::ptr::addr_of!((*ptr).handled_access_net) as usize - ptr as usize }, - 8usize, - "Offset of field: landlock_ruleset_attr::handled_access_net" - ); - assert_eq!( - unsafe { ::std::ptr::addr_of!((*ptr).scoped) as usize - ptr as usize }, - 16usize, - "Offset of field: landlock_ruleset_attr::scoped" - ); -} -pub const landlock_rule_type_LANDLOCK_RULE_PATH_BENEATH: landlock_rule_type = 1; -pub const landlock_rule_type_LANDLOCK_RULE_NET_PORT: landlock_rule_type = 2; -pub type landlock_rule_type = ::std::os::raw::c_uint; -#[repr(C, packed)] -#[derive(Debug, Copy, Clone)] -pub struct landlock_path_beneath_attr { - pub allowed_access: __u64, - pub parent_fd: __s32, -} -#[test] -fn bindgen_test_layout_landlock_path_beneath_attr() { - const UNINIT: ::std::mem::MaybeUninit = - ::std::mem::MaybeUninit::uninit(); - let ptr = UNINIT.as_ptr(); - assert_eq!( - ::std::mem::size_of::(), - 12usize, - "Size of landlock_path_beneath_attr" - ); - assert_eq!( - ::std::mem::align_of::(), - 1usize, - "Alignment of landlock_path_beneath_attr" - ); - assert_eq!( - unsafe { ::std::ptr::addr_of!((*ptr).allowed_access) as usize - ptr as usize }, - 0usize, - "Offset of field: landlock_path_beneath_attr::allowed_access" - ); - assert_eq!( - unsafe { ::std::ptr::addr_of!((*ptr).parent_fd) as usize - ptr as usize }, - 8usize, - "Offset of field: landlock_path_beneath_attr::parent_fd" - ); -} -#[repr(C)] -#[derive(Debug, Copy, Clone)] -pub struct landlock_net_port_attr { - pub allowed_access: __u64, - pub port: __u64, -} -#[test] -fn bindgen_test_layout_landlock_net_port_attr() { - const UNINIT: ::std::mem::MaybeUninit = - ::std::mem::MaybeUninit::uninit(); - let ptr = UNINIT.as_ptr(); - assert_eq!( - ::std::mem::size_of::(), - 16usize, - "Size of landlock_net_port_attr" - ); - assert_eq!( - ::std::mem::align_of::(), - 8usize, - "Alignment of landlock_net_port_attr" - ); - assert_eq!( - unsafe { ::std::ptr::addr_of!((*ptr).allowed_access) as usize - ptr as usize }, - 0usize, - "Offset of field: landlock_net_port_attr::allowed_access" - ); - assert_eq!( - unsafe { ::std::ptr::addr_of!((*ptr).port) as usize - ptr as usize }, - 8usize, - "Offset of field: landlock_net_port_attr::port" - ); -} diff --git a/third_party/rust-landlock/src/uapi/mod.rs b/third_party/rust-landlock/src/uapi/mod.rs deleted file mode 100644 index 583a213..0000000 --- a/third_party/rust-landlock/src/uapi/mod.rs +++ /dev/null @@ -1,87 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 OR MIT - -// Use architecture-specific bindings for native x86_64 and x86 architectures. -// They contain minimal Landlock-only bindings with layout tests. -#[allow(dead_code)] -#[allow(non_camel_case_types)] -#[allow(non_snake_case)] -#[allow(non_upper_case_globals)] -#[cfg(target_arch = "x86_64")] -#[path = "landlock_x86_64.rs"] -mod landlock; - -#[allow(dead_code)] -#[allow(non_camel_case_types)] -#[allow(non_snake_case)] -#[allow(non_upper_case_globals)] -#[cfg(target_arch = "x86")] -#[path = "landlock_i686.rs"] -mod landlock; - -// For all other architectures, use the architecture-agnostic landlock_all.rs -// bindings without layout tests. -#[allow(dead_code)] -#[allow(non_camel_case_types)] -#[allow(non_snake_case)] -#[allow(non_upper_case_globals)] -#[cfg(not(any(target_arch = "x86_64", target_arch = "x86")))] -#[path = "landlock_all.rs"] -mod landlock; - -#[rustfmt::skip] -pub use self::landlock::{ - landlock_net_port_attr, - landlock_path_beneath_attr, - landlock_rule_type, - landlock_rule_type_LANDLOCK_RULE_NET_PORT, - landlock_rule_type_LANDLOCK_RULE_PATH_BENEATH, - landlock_ruleset_attr, - LANDLOCK_ACCESS_FS_EXECUTE, - LANDLOCK_ACCESS_FS_WRITE_FILE, - LANDLOCK_ACCESS_FS_READ_FILE, - LANDLOCK_ACCESS_FS_READ_DIR, - LANDLOCK_ACCESS_FS_REMOVE_DIR, - LANDLOCK_ACCESS_FS_REMOVE_FILE, - LANDLOCK_ACCESS_FS_MAKE_CHAR, - LANDLOCK_ACCESS_FS_MAKE_DIR, - LANDLOCK_ACCESS_FS_MAKE_REG, - LANDLOCK_ACCESS_FS_MAKE_SOCK, - LANDLOCK_ACCESS_FS_MAKE_FIFO, - LANDLOCK_ACCESS_FS_MAKE_BLOCK, - LANDLOCK_ACCESS_FS_MAKE_SYM, - LANDLOCK_ACCESS_FS_REFER, - LANDLOCK_ACCESS_FS_TRUNCATE, - LANDLOCK_ACCESS_FS_IOCTL_DEV, - LANDLOCK_ACCESS_FS_RESOLVE_UNIX, - LANDLOCK_ACCESS_NET_BIND_TCP, - LANDLOCK_ACCESS_NET_CONNECT_TCP, - LANDLOCK_CREATE_RULESET_VERSION, - LANDLOCK_CREATE_RULESET_ERRATA, - LANDLOCK_RESTRICT_SELF_LOG_SAME_EXEC_OFF, - LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON, - LANDLOCK_RESTRICT_SELF_LOG_SUBDOMAINS_OFF, - LANDLOCK_RESTRICT_SELF_TSYNC, - LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET, - LANDLOCK_SCOPE_SIGNAL, -}; - -use libc::{ - __u32, c_int, c_void, size_t, syscall, SYS_landlock_add_rule, SYS_landlock_create_ruleset, - SYS_landlock_restrict_self, -}; - -#[rustfmt::skip] -pub unsafe fn landlock_create_ruleset(attr: *const landlock_ruleset_attr, size: size_t, - flags: __u32) -> c_int { - syscall(SYS_landlock_create_ruleset, attr, size, flags) as c_int -} - -#[rustfmt::skip] -pub unsafe fn landlock_add_rule(ruleset_fd: c_int, rule_type: landlock_rule_type, - rule_attr: *const c_void, flags: __u32) -> c_int { - syscall(SYS_landlock_add_rule, ruleset_fd, rule_type, rule_attr, flags) as c_int -} - -pub unsafe fn landlock_restrict_self(ruleset_fd: c_int, flags: __u32) -> c_int { - syscall(SYS_landlock_restrict_self, ruleset_fd, flags) as c_int -}