diff --git a/Makefile b/Makefile index 701bb0c..10def8f 100644 --- a/Makefile +++ b/Makefile @@ -17,8 +17,16 @@ check-compiler: repro_home_b="$$(/usr/bin/mktemp -d -- "$${TMPDIR:-/tmp}/orange-repro-home.XXXXXXXX")"; \ trap '/usr/bin/rm -rf -- "$$cargo_home" "$$repro_home_b"' EXIT; \ repro_home_b="$$(CDPATH= cd -- "$$repro_home_b" && pwd -P)"; \ + gate_tools="$$(/usr/bin/mktemp -d -- "$${TMPDIR:-/tmp}/orange-gate-tools.XXXXXXXX")"; \ + trap '/usr/bin/rm -rf -- "$$cargo_home" "$$repro_home_b" "$$gate_tools"' EXIT; \ + gate_tools="$$(CDPATH= cd -- "$$gate_tools" && pwd -P)"; \ + /usr/bin/mkdir -- "$$cargo_home/home" "$$cargo_home/tmp" "$$gate_tools/toolchain"; \ + toolchain_root="$$(/usr/bin/env -i HOME="$$HOME" LANG=C LC_ALL=C PATH="$$PATH" RUSTUP_TOOLCHAIN=1.96.1 rustc --print sysroot)"; \ + toolchain_root="$$(CDPATH= cd -- "$$toolchain_root" && pwd -P)"; \ + [[ -f "$$toolchain_root/bin/cargo" && ! -L "$$toolchain_root/bin/cargo" && -x "$$toolchain_root/bin/cargo" && -f "$$toolchain_root/bin/rustc" && ! -L "$$toolchain_root/bin/rustc" && -x "$$toolchain_root/bin/rustc" ]] || { printf '%s\n' 'selected Rust toolchain root is invalid' >&2; exit 1; }; \ gate_uid="$$(/usr/bin/id -u)"; \ gate_gid="$$(/usr/bin/id -g)"; \ + namespace_setup='set -euo pipefail; toolchain_root="$$1"; gate_tools="$$2"; shift 2; /usr/bin/mount --bind "$$toolchain_root" "$$gate_tools/toolchain"; /usr/bin/mount --options remount,bind,ro,nosuid,nodev "$$gate_tools/toolchain"; /usr/bin/mount --types tmpfs --options mode=755,nosuid,nodev,noexec tmpfs /home; exec "$$@"'; \ namespace_runner=( \ /usr/bin/unshare \ --user \ @@ -30,6 +38,13 @@ check-compiler: --kill-child=KILL \ --mount-proc \ --net \ + /bin/bash \ + -p \ + -c \ + "$$namespace_setup" \ + gate-namespace \ + "$$toolchain_root" \ + "$$gate_tools" \ /usr/bin/setpriv \ --bounding-set=-all \ --inh-caps=-all \ @@ -48,6 +63,13 @@ check-compiler: --kill-child=KILL \ --mount-proc \ --net \ + /bin/bash \ + -p \ + -c \ + "$$namespace_setup" \ + gate-namespace \ + "$$toolchain_root" \ + "$$gate_tools" \ /usr/bin/setpriv \ --bounding-set=-all \ --inh-caps=-all \ @@ -64,18 +86,34 @@ check-compiler: exec 8<&- 9<&-; \ cd -- /; \ "$${namespace_runner[@]}" \ + "$$gate_tools/fs-sandbox" \ + --dir / \ + --ro /usr \ + --ro /etc \ + --ro /proc \ + --ro /sys \ + --ro "$$gate_tools" \ + --ro "$$gate_tools/toolchain" \ + --rw /dev/null \ + --rw /dev/zero \ + --rw /dev/random \ + --rw /dev/urandom \ + --rw "$$cargo_home" \ + --rw "$$repro_home_b" \ + -- \ /usr/bin/env -i \ CARGO_HOME="$$cargo_home" \ CARGO_NET_OFFLINE=true \ CARGO_TARGET_DIR="$$cargo_home/target" \ CARGO_TERM_COLOR=never \ - HOME="$$HOME" \ + HOME="$$cargo_home/home" \ LANG=C \ LC_ALL=C \ - PATH="$$PATH" \ + PATH="$$gate_tools/toolchain/bin:/usr/bin:/bin" \ RUSTDOCFLAGS="-D warnings" \ RUSTUP_TOOLCHAIN=1.96.1 \ SOURCE_DATE_EPOCH=0 \ + TMPDIR="$$cargo_home/tmp" \ TZ=UTC \ "$$@" \ ); \ @@ -117,8 +155,10 @@ check-compiler: /usr/bin/cmp --silent -- "$$repro_source_paths" "$$repro_source_paths_after" || { printf '%s\n' 'tracked source membership changed during archive capture' >&2; exit 1; }; \ /usr/bin/rm -- "$$repro_source_paths_after"; \ verify_capture_identity; \ + /usr/bin/env -i LANG=C LC_ALL=C PATH=/usr/bin:/bin /usr/bin/cc -std=c17 -O2 -D_FORTIFY_SOURCE=3 -fPIE -pie -Wall -Wextra -Werror -pedantic -Wl,-z,relro,-z,now "$$cargo_home/check-src/tools/fs_sandbox.c" -o "$$gate_tools/fs-sandbox"; \ + [[ -f "$$gate_tools/fs-sandbox" && ! -L "$$gate_tools/fs-sandbox" && -x "$$gate_tools/fs-sandbox" ]] || { printf '%s\n' 'filesystem sandbox build is invalid' >&2; exit 1; }; \ manifest="$$cargo_home/check-src/compiler/Cargo.toml"; \ - run_cargo /bin/bash -p -c 'for capability_set in CapInh CapPrm CapEff CapBnd CapAmb; do [[ "$$(/usr/bin/sed -n "s/^$${capability_set}:[[:space:]]*//p" /proc/self/status)" == 0000000000000000 ]] || exit 1; done; [[ $$$$ == 1 && $$PPID == 0 && "$$(/usr/bin/id -u)" == "$$1" && "$$(/usr/bin/id -g)" == "$$2" && "$$(/usr/bin/sed -n "s/^NoNewPrivs:[[:space:]]*//p" /proc/self/status)" == 1 && ! -e /proc/self/fd/8 && ! -e /proc/self/fd/9 && -z "$$(/usr/bin/sed -n "2p" /proc/net/route)" ]]' gate-isolation "$$gate_uid" "$$gate_gid"; \ + run_cargo /bin/bash -p -c 'for capability_set in CapInh CapPrm CapEff CapBnd CapAmb; do [[ "$$(/usr/bin/sed -n "s/^$${capability_set}:[[:space:]]*//p" /proc/self/status)" == 0000000000000000 ]] || exit 1; done; for descriptor in /proc/self/fd/*; do [[ ! -e "$$descriptor" || "$${descriptor##*/}" =~ ^[012]$$ ]] || exit 1; done; ! /usr/bin/head -c 1 -- "$$3/Makefile" >/dev/null 2>&1 || exit 1; [[ $$$$ == 1 && $$PPID == 0 && "$$(/usr/bin/id -u)" == "$$1" && "$$(/usr/bin/id -g)" == "$$2" && "$$HOME" == "$$4" && "$$PATH" == "$$5/toolchain/bin:/usr/bin:/bin" && "$$(/usr/bin/sed -n "s/^NoNewPrivs:[[:space:]]*//p" /proc/self/status)" == 1 && -z "$$(/usr/bin/sed -n "2p" /proc/net/route)" ]]' gate-isolation "$$gate_uid" "$$gate_gid" "$$repository_root" "$$cargo_home/home" "$$gate_tools"; \ run_cargo /usr/bin/env PYTHONHASHSEED=0 /usr/bin/python3 -S -P -B -X utf8 -W error::ResourceWarning "$$cargo_home/check-src/tools/validate_foundation.py"; \ run_cargo /usr/bin/env PYTHONHASHSEED=0 PYTHONPYCACHEPREFIX="$$cargo_home/snapshot-python-cache" /usr/bin/python3 -S -P -B -X utf8 -W error::ResourceWarning -c 'import sys, unittest; sys.path.insert(0, sys.argv.pop(1)); unittest.main(module=None)' "$$cargo_home/check-src" discover -s "$$cargo_home/check-src/tools/tests" -p 'test_*.py'; \ run_cargo /usr/bin/env PYTHONHASHSEED=0 /usr/bin/python3 -S -P -B -X utf8 -W error::ResourceWarning "$$cargo_home/check-src/tools/validate_foundation.py"; \ diff --git a/compiler/README.md b/compiler/README.md index a93c421..af26ecf 100644 --- a/compiler/README.md +++ b/compiler/README.md @@ -64,12 +64,19 @@ The user-namespace path retains its namespace-granted capabilities only long enough for `setpriv` to remove every capability from the inheritable, permitted, effective, bounding, and ambient sets. The privileged supervisor removes the same sets while restoring the invoking identity. Both paths assert the restored -identity, five empty capability sets, private process view, and empty route table -before copied code runs. The namespace -supervisor kills its child if supervision is interrupted. Trusted gate operations -alone retain the descriptors used for later extraction and identity checks. The -copied validator first policy-checks that exact exported tree before its -foundation test modules import. +identity, five empty capability sets, private process view, and empty route table. +Before the drop, the namespace supervisor bind-mounts the selected toolchain +read-only and covers `/home` with a private non-executable `tmpfs`. A protected C +launcher built from the captured tree requires Landlock ABI 3 or newer, permits +directory-name traversal but grants file reads and execution only to the system +roots and selected toolchain, and grants writes only to the two gate roots and +four admitted character devices. It also closes every inherited descriptor above +standard error before execution. Copied commands receive isolated `HOME`, +`TMPDIR`, and `PATH` values; a runtime assertion confirms that the original +checkout is unreadable. The namespace supervisor kills its child if supervision +is interrupted. Trusted gate operations alone retain the descriptors used for +later extraction and identity checks. The copied validator first policy-checks +that exact exported tree before its foundation test modules import. After those tests, it policy-checks the tree again before Cargo, so Python-test drift cannot reach Rust execution. Formatting, linting, documentation, and Rust tests use the same extracted check root. A third policy check runs after all Rust @@ -86,6 +93,12 @@ non-symlink files with identical complete modes and bytes. This is source-relocated same-host reproducibility evidence, not a cross-platform or independently rebuilt claim. +Landlock does not mediate every metadata operation, and the gate intentionally +allows directory listing without file contents so the selected Rust toolchain can +discover its sysroot. Standard input, output, and error remain caller-provided; +the system C compiler, launcher source, kernel, mount implementation, and +allowlisted system trees remain trusted boundaries. + `orangec` accepts up to 256 source inputs in argument order. Argument parsing inspects at most 4 MiB (`4 * 1024 * 1024` bytes) of encoded command-line arguments per invocation, charged before each argument is interpreted. diff --git a/docs/operations/CI_DEPENDENCIES.md b/docs/operations/CI_DEPENDENCIES.md index c26d7c1..d053740 100644 --- a/docs/operations/CI_DEPENDENCIES.md +++ b/docs/operations/CI_DEPENDENCIES.md @@ -258,7 +258,7 @@ tools and services are ambient rather than admitted, fixed inputs: | Bash and Python 3 standard library | First-party checks and composite Actions | Runner-provided executables; exact versions and binary/package digests are not captured. No PyPI packages are installed by first-party checks | | Git | Checkout implementation and validator repository inventory | The first-party validator selects `/usr/bin/git`, fixes child-command lookup to `/usr/bin:/bin`, requires a literal local `.git` directory with regular config/index files and no common-directory or object alternates, clears inherited/system/global Git controls, and overrides the local settings relevant to its commands; other local config remains an ambient input, and the runner executable's exact version, package provenance, license, and binary digest are not captured | | Docker daemon, kernel, and CPU | zizmor and Scorecard containers | Runner-provided execution boundary; versions, configuration, and host identity are not captured | -| `cmp`, `curl`, `sha256sum`, `tar`, `stat`, `install`, `mktemp`, `rm`, `uname`, `sudo`, `unshare`, and `setpriv` | Compare captured sources; download, verify, extract, inspect, install, clean temporary tools, create private mount, PID, `/proc`, and network namespaces, and restore an unprivileged copied-command identity when user-namespace mapping is unavailable | Runner-provided system tools; exact versions, provenance, package licenses, and binary digests are not captured. Namespace availability, passwordless namespace-setup policy, privilege dropping, and enforcement depend on the mutable runner image and kernel | +| `cc`, `cmp`, `curl`, `mount`, `sha256sum`, `tar`, `stat`, `install`, `mktemp`, `rm`, `uname`, `sudo`, `unshare`, and `setpriv` | Build the protected Landlock launcher; compare captured sources; download, verify, extract, inspect, install, clean temporary tools; bind the selected toolchain read-only; hide `/home`; create private mount, PID, `/proc`, and network namespaces; and restore an unprivileged copied-command identity when user-namespace mapping is unavailable | Runner-provided system tools; exact versions, provenance, package licenses, and binary digests are not captured. C compilation, Landlock ABI and enforcement, mount/namespace availability, passwordless namespace-setup policy, and privilege dropping depend on the mutable runner image and kernel | | GNU Make | Protected compiler recipe and optional local `make check` entry point | Required hosted CI invokes the compiler target with built-in rules and variables disabled; exact binary version, provenance, and package license are unrecorded | | Artifact, code-scanning, GHCR, and external web services | Storage, analysis upload, image retrieval, and link observations | Mutable hosted services; terms and service behavior are external assumptions, not repository-pinned software inputs | @@ -279,7 +279,7 @@ recorded identities and digests. | Result | What is currently fixed | What the result actually establishes | Why it is not reproducible evidence yet | | --- | --- | --- | --- | -| `scripts/ci/check-repository` | Repository bytes, Cargo lock, selected Rust version, allowlisted Python and Cargo environments, fresh bytecode/build cache roots, and private mount, PID, `/proc`, and network namespaces for copied commands; the gate uses an unprivileged user namespace where available or a privilege-dropping namespace supervisor otherwise, removes every inheritable, permitted, effective, bounding, and ambient capability before copied code, and runs Cargo locked/offline after the toolchain exists | Rust formatting, lint, docs, debug and optimized unit/CLI tests, repository invariants, and synthetic/adversarial fixture expectations pass under the executing toolchain and OS without copied-code visibility of the trusted parent process, Linux capabilities, privilege-reacquisition permission, or a new external network path | Rust archives, Python, shell, Make, kernel, namespace and privilege-drop implementations, filesystem, and host tools are not content-fixed; no signed execution manifest or independently replayable environment is emitted | +| `scripts/ci/check-repository` | Repository bytes, Cargo lock, selected Rust version, allowlisted Python and Cargo environments, fresh bytecode/build cache roots, private mount, PID, `/proc`, and network namespaces, a read-only selected-toolchain bind, hidden `/home`, and a protected Landlock allowlist for copied commands; the gate uses an unprivileged user namespace where available or a privilege-dropping namespace supervisor otherwise, removes all five capability sets, closes nonstandard descriptors, and runs Cargo locked/offline | Rust formatting, lint, docs, debug and optimized unit/CLI tests, repository invariants, and synthetic/adversarial fixture expectations pass under the executing toolchain and OS without copied-code file contents outside admitted roots, trusted-parent process visibility, inherited nonstandard descriptors, Linux capabilities, privilege-reacquisition permission, or a new external network path | Rust archives, C compiler, Python, shell, Make, kernel, Landlock, namespace and privilege-drop implementations, filesystem, and host tools are not content-fixed; directory names and unmediated metadata operations remain visible, standard descriptors are caller-provided, and no signed execution manifest or independently replayable environment is emitted | | Required hosted CI | Repository revision, direct Action SHAs, actionlint archive digest, and zizmor image digest | The configured repository, Markdown, workflow, and workflow-security methods passed in one hosted run | Runner image, Node, Git, Docker host, Action bundles, and service behavior are not fully archived or fixed | | Dependency Review | Event base/head identities and the selected Action revision | GitHub reported no configured dependency-policy violation for its then-current dependency data | Dependency graph/API state and service implementation are external, time-indexed inputs | | External Links | lychee archive digest, link-check flags, and repository locators | The non-excluded endpoints produced accepted responses during the run | Remote content, DNS, TLS, routing, rate limits, and server policy change independently of repository bytes | diff --git a/docs/security/THREAT_MODEL.md b/docs/security/THREAT_MODEL.md index 1200657..95e9d99 100644 --- a/docs/security/THREAT_MODEL.md +++ b/docs/security/THREAT_MODEL.md @@ -409,7 +409,7 @@ required control. Reviews must replace conditional ranks with deployment facts. | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | TM-001 | ADV-002/004/008; TB-002/003; AS-001/002/003/011 | Compromise or unilateral authority changes or erases authoritative decisions, settings, or history. | CTL-001, CTL-007, CTL-008; ruleset `18810248` constrains ordinary Git updates, and public history aids detection. | Preserve the active no-bypass ruleset and exact required checks; test safe direct-update rejection; maintain recovery material and periodic owner access review; never claim independent governance. | Medium-high: one administrator and zero required approvals, despite protected Git updates | High | High | Owner or platform compromise can weaken settings or abuse admin recovery; zero approvals do not create independent review. | Project owner; each rules/access change and quarterly; `open-current` | | TM-002 | ADV-001/002; TB-001/004; AS-003/004 | Attacker-controlled workflow, script, or metadata reaches a privileged runner and steals credentials or changes state. | CTL-005/006/019; current PR jobs declare read-only source access, no configured repository or environment secrets, only a job-scoped read-only `GITHUB_TOKEN`, timeouts, and no `pull_request_target`. Ruleset `18810248` requires the exact app-bound Required CI and Dependency Review contexts. PR #11 Required CI `29292600483`, Dependency Review `29292600471`, and CodeQL `29292598799` succeeded; post-merge Required CI `29292740885`, Workflow Online Audit `29292740874`, External Links `29292740884`, Scorecard `29292740941`, and dynamic CodeQL `29292740478` succeeded at latest recorded subject revision `23352bcde976b86890db28ea4d375a31e6354bca`. | Policy-check every workflow diff; keep privileged jobs off PR events; validate metadata before shell use; require CI and security review on workflow paths. | Low now: demonstrated PR jobs have read-only permissions; reassess every permission or event change | High | Medium | Actions/runner isolation, sole-owner review, query coverage, open Scorecard posture alerts, and future permission edits remain trusted or residual risks. Green runs do not prove vulnerability absence. | CI/Release authority, currently Bootstrap Steward; every workflow change; `open-current` | -| TM-003 | ADV-003; TB-005/012/013; AS-004/009/013 | Compromised action, tool, image, Rust toolchain, registry, archive, or dependency falsifies results or executes malicious code. | CTL-004/010/020; current full-SHA settings, pinned tool identities, no third-party Rust crates, exact required PR contexts, green exact-revision hosted evidence, and a local hardened gate that discards the caller environment, restricts tool resolution, validates the captured tree before copied test modules run, revalidates it after them before Rust executes and after Rust checks, and compares NUL-safe non-directory membership plus every tracked file's type, mode, and bytes in all three compiler input roots with a fresh extraction of the identity-checked capture. After initial consistency checks, the authoritative archive and inventory are unlinked behind read-only descriptors that copied Python and Rust processes do not inherit. Every copied command runs with private mount, PID, `/proc`, and network namespaces, either through an unprivileged user namespace or a privileged namespace-only supervisor that drops to the invoking identity, empties its inheritable, permitted, effective, bounding, and ambient capability sets, and enables `no_new_privs` before copied code. | Preserve the minimum allowlist and exact admission records; archive dependencies; verify signatures where available; use locked offline checks and separately provisioned owner rebuilds. | Medium: admitted third-party CI and the Rust toolchain execute code | High | High | Environment, namespace isolation, descriptor-confined capture, and final exact comparison prevent caller-selected wrappers, copied-code parent-descriptor traversal, new copied-code network connections, ordinary named-file capture replacement, live-tree drift, added source entries, and unrestored Python-, Rust-test-, or reproducibility-build source drift from silently replacing a passing check, but empty-directory additions are immaterial and an admitted child can restore modified extracted state before comparison; unrelated same-account host processes, kernel namespace enforcement, passwordless namespace-setup policy, the selected account-owned proxy, `sudo`, `unshare`, `setpriv`, and other system tools, copied validator and tests, archive implementation, publisher, registry, runner, host, and solo-admission judgment remain trusted or residual risks. | Project owner; each admission/update and surveillance run; `open-current` | +| TM-003 | ADV-003; TB-005/012/013; AS-004/009/013 | Compromised action, tool, image, Rust toolchain, registry, archive, or dependency falsifies results or executes malicious code. | CTL-004/010/020; current full-SHA settings, pinned tool identities, no third-party Rust crates, exact required PR contexts, green exact-revision hosted evidence, and a local hardened gate that discards the caller environment, restricts tool resolution, validates the captured tree before copied test modules run, revalidates it after them before Rust executes and after Rust checks, and compares NUL-safe non-directory membership plus every tracked file's type, mode, and bytes in all three compiler input roots with a fresh extraction of the identity-checked capture. After initial consistency checks, the authoritative archive and inventory are unlinked behind read-only descriptors that copied Python and Rust processes do not inherit. Every copied command runs with private mount, PID, `/proc`, and network namespaces. The namespace supervisor bind-mounts the selected toolchain read-only, hides `/home`, drops to the invoking identity with all five capability sets empty and `no_new_privs`, then invokes a protected Landlock launcher that limits file contents to admitted roots and closes all nonstandard descriptors. | Preserve the minimum allowlist and exact admission records; archive dependencies; verify signatures where available; use locked offline checks and separately provisioned owner rebuilds. | Medium: admitted third-party CI and the Rust toolchain execute code | High | High | Environment, namespace, Landlock, descriptor-confined capture, and final exact comparison prevent caller-selected wrappers, copied-code parent-descriptor traversal, file-content access outside admitted roots, new copied-code network connections, ordinary named-file capture replacement, live-tree drift, added source entries, and unrestored Python-, Rust-test-, or reproducibility-build source drift from silently replacing a passing check. Directory names, unmediated metadata operations, and caller-provided standard descriptors remain visible; empty-directory additions are immaterial; and an admitted child can restore modified extracted state before comparison. The C compiler, protected launcher, kernel enforcement, passwordless namespace policy, selected account-owned proxy, `mount`, `sudo`, `unshare`, `setpriv`, other system tools, copied validator/tests, archive implementation, publisher, registry, runner, host, and solo-admission judgment remain trusted or residual risks. | Project owner; each admission/update and surveillance run; `open-current` | | TM-004 | ADV-001/002/008; TB-001/002/004/005; AS-003/004/005/010 | Secret enters source, artifact, log, cache, or untrusted job and is used before revocation. | CTL-003/005/006/017; secret scanning and push protection enabled, minimal workflow permissions, no product keys, and a fail-closed lifecycle/playbook is documented. | Exercise synthetic leak/revocation paths; enable broader scanning if available; keep release/root keys out of GitHub; rotate immediately and treat history deletion as insufficient. | Medium: humans and tooling can leak unsupported patterns | High | High | Detection is not prevention for every secret; account custody and incident execution are not independently verified. | Security authority, currently Bootstrap Steward; every alert/credential event and quarterly; `open-current` | | TM-005 | ADV-004/005; TB-008/009/010/012; AS-006/008/009/011 | Valid evidence is rebound, omitted, downgraded, or confused across source, target, artifact, or claim context. | CTL-009/012 specify explicit subjects, digests, contexts, assumptions, and fail-closed outcomes. | Ratify canonical schemas; bind complete claim closure to exact bytes and versions; check bundle traversal through separate owner-executable paths; add substitution, omission, downgrade, and cross-target negative tests. | Future | High | High | Schema validity cannot prove truthful binding; human standards intent remains an assumption. | Project owner; every schema/claim change and release; `future-blocking` | | TM-006 | ADV-005/007; TB-008/009; AS-006 | Malformed or adversarial proofs exploit unsoundness, parser differential, resource exhaustion, or hidden axioms. | CTL-009/013 target a small deterministic checker, axiom ledger, resource bounds, implementation diversity, and adversarial corpus. | Prove the checked relation sound; fuzz and mutate accepted objects; test malformed/cyclic/oversized inputs; enforce canonical decoding and budgets; record external audit as unavailable. | Future | High | Critical | A shared solo-authored semantic error can survive multiple implementations; the missing independent audit remains explicit. | Project owner; every checker/format/axiom change and release; `future-stop-ship` | diff --git a/policy/README.md b/policy/README.md index 914e3f8..d61b32c 100644 --- a/policy/README.md +++ b/policy/README.md @@ -64,9 +64,17 @@ only the remaining namespaces before `setpriv` restores the invoking numeric user and group, clears supplementary groups, and enables `no_new_privs`. Both paths remove and assert empty inheritable, permitted, effective, bounding, and ambient capability sets, then assert the expected identity, private process view, -empty route table, and descriptor closure before copied code runs. The -namespace supervisor kills its child if supervision is interrupted. Trusted gate -operations retain the descriptors for extraction and SHA-256 identity checks. +and empty route table. Before dropping privilege, the supervisor bind-mounts the +selected toolchain read-only and hides `/home` behind a private non-executable +`tmpfs`. The protected `tools/fs_sandbox.c` launcher is built from the checked +capture with fixed hardening flags. It requires Landlock ABI 3 or newer, grants +file reads and execution only to the admitted system/toolchain roots, grants +writes only to the two temporary gate roots and admitted character devices, and +closes every inherited descriptor above standard error. Copied commands receive +isolated `HOME`, `TMPDIR`, and `PATH` values and must prove the original checkout +unreadable before tests begin. The namespace supervisor kills its child if +supervision is interrupted. Trusted gate operations retain the descriptors for +extraction and SHA-256 identity checks. The gate verifies those identities before and after its final comparison, extracts a fresh reference, compares NUL-safe sorted non-directory membership across the tested check root and both relocated reproducibility roots, and @@ -74,11 +82,13 @@ compares every tracked file's type, complete mode, and bytes with the reference. Added source entries or policy-valid tracked-source drift in any compiler input root therefore cannot produce a passing gate unless a trusted child restores the original state before comparison. Empty-directory additions are not source -membership and remain outside this final comparison. The fixed host `unshare`, -`sudo`, and `setpriv` implementations, passwordless namespace-setup policy, -user-namespace availability, kernel namespace enforcement, and unrelated -same-account processes outside the private namespace remain trusted or residual -boundaries. +membership and remain outside this final comparison. Landlock permits global +directory listing for toolchain discovery and does not mediate every metadata +operation; standard descriptors remain caller-controlled. The fixed host C +compiler, `mount`, `unshare`, `sudo`, and `setpriv` implementations, protected +launcher, passwordless namespace-setup policy, user-namespace availability, +kernel namespace/Landlock enforcement, and unrelated same-account processes +outside the private namespace remain trusted or residual boundaries. The validator always binds filesystem scope to the checkout containing `tools/validate_foundation.py`. Its optional `--root PATH` flag is an diff --git a/policy/gate0-repository-policy.json b/policy/gate0-repository-policy.json index 7690c9a..951c8e2 100644 --- a/policy/gate0-repository-policy.json +++ b/policy/gate0-repository-policy.json @@ -1,5 +1,5 @@ { - "policy_version": "0.2.85", + "policy_version": "0.2.86", "repository": "chasebryan/orange", "stage": "solo-bootstrap", "status": "enforced", @@ -197,6 +197,7 @@ "scripts/ci/check-repository", "scripts/ci/install-actionlint", "scripts/ci/install-lychee", + "tools/fs_sandbox.c", "tools/tests/test_validate_foundation.py", "tools/tests/test_validate_foundation_hardening.py", "tools/validate_foundation.py" @@ -251,7 +252,7 @@ "CONTRIBUTING.md": "ee6a23e1c2bca6f86f6a40e2511c4de4c253a77ac2b24d3ae3d975416055b86f", "DEPENDENCY_POLICY.md": "ae5e10534b9081c401d943a55fc85fb2aa4a284cc366129f6139eefdb8389438", "GOVERNANCE.md": "8cbf5da50c63908948d181b1525c86e0f8a554eaa71fc98cf2f0ec47f6776103", - "Makefile": "1da62caf5494fdddafc8b90a66599c76117c270b2a7feff96866f4258756af42", + "Makefile": "61dab2c308edef22ec24aaa056d675d15bad1a68e097170c971e0c793e0643c1", "README.md": "82767e5ee4eebabcdaab249a95171d0feae55664d4868c00ca12f103f9382182", "RELEASE_POLICY.md": "f8a3f0fa3494eb28bdd9fc3e6d18ddc8df2fdf63a4c628a5f6c9d72762586e45", "SECURITY.md": "1a801158996153650a2d94a4dbf5043d0a08ce9b96e4aefa9abdcd66344a0ede", @@ -290,13 +291,13 @@ "docs/DECISIONS.md": "5ba13656b29a404aa7ffc047fe1a02df9a60bf43d440912557889bffb5493047", "docs/LANGUAGE_2026.md": "28bcb8741e67adad12c92fa3e0ad8d4b759cf6625333eea5af6dd5a663c014bd", "docs/SEMANTICS_2026.md": "a465aca6c98344fb1271944ce9cdd5af3575afcf295442dfbbd6e05a95182b33", - "docs/operations/CI_DEPENDENCIES.md": "69dfc3370c1361626985fa216c7ee74491381265f6787e1a634eb3b328354e18", + "docs/operations/CI_DEPENDENCIES.md": "ae94e2522700dfeca587e040e31e0d18d5ad01d60d969bfe942e7051e2c443f6", "docs/operations/GITHUB_CONTROLS.md": "f86bdf0234e9db17256f4be07e20e65a9913de45e96e1fdd55e2c57d056ae94b", "docs/security/OSPS_BASELINE.md": "38efd43d1e4e15f335c9189c7cf921b58eb9b15ff8305acb75c7a47ff9fd2d72", "docs/security/SECRETS_AND_INCIDENTS.md": "93332edb737f84c7a3f74f256b5fb603537bf6f524388f62013140cb9906f6a6", - "docs/security/THREAT_MODEL.md": "aa1077e7053c7073eb107e573349f8fc561a27ac1228821c88e6b8838b98e146", - "policy/README.md": "6d22a2077e72ce3292508670f768fc48ff8a74f0dbef13fb2f6d7daab64822f7", - "policy/makefile-entrypoint-contract-v0.1.json": "11e8db3b5c73df8ad570c056a9051e0d44b8552a58de8561f4a9bfb668fd55bc", + "docs/security/THREAT_MODEL.md": "edc28f96d11578d82f468634ba99c242f24d6ff48b396e285700b4a30e235f38", + "policy/README.md": "5a82045a6e1f5adbddc6124b9d6f80e6cd8dcc27b15f57d8e2f8cfc73e0aaa5a", + "policy/makefile-entrypoint-contract-v0.1.json": "6d9d2dae9d47074e00ad3fcc3ec31430bbab564dfd3dfe201fe0b47902cd4456", "schemas/README.md": "39a7b91e15a316c1221cfce5082608eb453f20ea58b5e1c5a0cf32a07a81d774", "schemas/gate0/claim-record-v0.1.schema.json": "a287dde9ddf114da30af61d050aa96406f23e480d62e0f796d66943489579131", "schemas/gate0/evidence-manifest-v0.1.schema.json": "987ba1cddb23aaaf67a1234456fbffde8f80d45678b9671b8df97ad256742efd", @@ -307,8 +308,9 @@ "scripts/ci/check-repository": "7d896bcffff1d737852c2bf740c8262e97b2f2fad6e9a1fdd03283c887d6feff", "scripts/ci/install-actionlint": "ac2e08280a32b7d213fb3993f43e5f9d9431dc3f960a41dedea551be2d9433b5", "scripts/ci/install-lychee": "21dd0050173bdd6b461ce7893dc4f77f74dde67b051d76697d6e8ec466b6c41f", + "tools/fs_sandbox.c": "5e9b749a20a1824ab6e12ccf0c3c0ef74bcf6cbe1feee73fd6369501dc7d2f27", "tools/tests/test_validate_foundation.py": "428996f350509478ba525b5c63b55fa1a1a52c6d9d65f4cba4d354ad76bfacb0", - "tools/tests/test_validate_foundation_hardening.py": "ae0962d3573e1b59e376780deda99eeb19d20fe8881485e64449b3464ea98fcb" + "tools/tests/test_validate_foundation_hardening.py": "e9e40dceaefd3552864318f551d0aa01d6371cb13578390fb363d688ef81f7c6" }, "executable_paths": [ "scripts/ci/check-external-links", diff --git a/policy/makefile-entrypoint-contract-v0.1.json b/policy/makefile-entrypoint-contract-v0.1.json index 09be89c..7ce15a0 100644 --- a/policy/makefile-entrypoint-contract-v0.1.json +++ b/policy/makefile-entrypoint-contract-v0.1.json @@ -31,8 +31,9 @@ "ls-files --cached -z > \"$$repro_source_paths_after\"", "/usr/bin/cmp --silent -- \"$$repro_source_paths\" \"$$repro_source_paths_after\"", "/usr/bin/rm -- \"$$repro_source_paths_after\"; \\\n\tverify_capture_identity", + "/usr/bin/env -i LANG=C LC_ALL=C PATH=/usr/bin:/bin /usr/bin/cc -std=c17 -O2 -D_FORTIFY_SOURCE=3 -fPIE -pie -Wall -Wextra -Werror -pedantic -Wl,-z,relro,-z,now \"$$cargo_home/check-src/tools/fs_sandbox.c\" -o \"$$gate_tools/fs-sandbox\"", "manifest=\"$$cargo_home/check-src/compiler/Cargo.toml\"", - "run_cargo /bin/bash -p -c 'for capability_set in CapInh CapPrm CapEff CapBnd CapAmb; do [[ \"$$(/usr/bin/sed -n \"s/^$${capability_set}:[[:space:]]*//p\" /proc/self/status)\" == 0000000000000000 ]] || exit 1; done; [[ $$$$ == 1 && $$PPID == 0 && \"$$(/usr/bin/id -u)\" == \"$$1\" && \"$$(/usr/bin/id -g)\" == \"$$2\" && \"$$(/usr/bin/sed -n \"s/^NoNewPrivs:[[:space:]]*//p\" /proc/self/status)\" == 1 && ! -e /proc/self/fd/8 && ! -e /proc/self/fd/9 && -z \"$$(/usr/bin/sed -n \"2p\" /proc/net/route)\" ]]' gate-isolation \"$$gate_uid\" \"$$gate_gid\"", + "run_cargo /bin/bash -p -c 'for capability_set in CapInh CapPrm CapEff CapBnd CapAmb; do [[ \"$$(/usr/bin/sed -n \"s/^$${capability_set}:[[:space:]]*//p\" /proc/self/status)\" == 0000000000000000 ]] || exit 1; done; for descriptor in /proc/self/fd/*; do [[ ! -e \"$$descriptor\" || \"$${descriptor##*/}\" =~ ^[012]$$ ]] || exit 1; done; ! /usr/bin/head -c 1 -- \"$$3/Makefile\" >/dev/null 2>&1 || exit 1; [[ $$$$ == 1 && $$PPID == 0 && \"$$(/usr/bin/id -u)\" == \"$$1\" && \"$$(/usr/bin/id -g)\" == \"$$2\" && \"$$HOME\" == \"$$4\" && \"$$PATH\" == \"$$5/toolchain/bin:/usr/bin:/bin\" && \"$$(/usr/bin/sed -n \"s/^NoNewPrivs:[[:space:]]*//p\" /proc/self/status)\" == 1 && -z \"$$(/usr/bin/sed -n \"2p\" /proc/net/route)\" ]]' gate-isolation \"$$gate_uid\" \"$$gate_gid\" \"$$repository_root\" \"$$cargo_home/home\" \"$$gate_tools\"", "PYTHONPYCACHEPREFIX=\"$$cargo_home/snapshot-python-cache\"", "run_cargo cargo fmt --manifest-path \"$$manifest\" --all -- --check", "run_cargo cargo clippy --manifest-path \"$$manifest\" --workspace --lib --bins --locked --offline", @@ -60,19 +61,30 @@ "/usr/bin/mktemp -d -- \"$${TMPDIR:-/tmp}/orange-repro-home.XXXXXXXX\"", "trap '/usr/bin/rm -rf -- \"$$cargo_home\" \"$$repro_home_b\"' EXIT;", "repro_home_b=\"$$(CDPATH= cd -- \"$$repro_home_b\" && pwd -P)\"", + "/usr/bin/mktemp -d -- \"$${TMPDIR:-/tmp}/orange-gate-tools.XXXXXXXX\"", + "trap '/usr/bin/rm -rf -- \"$$cargo_home\" \"$$repro_home_b\" \"$$gate_tools\"' EXIT;", + "gate_tools=\"$$(CDPATH= cd -- \"$$gate_tools\" && pwd -P)\"", + "/usr/bin/mkdir -- \"$$cargo_home/home\" \"$$cargo_home/tmp\" \"$$gate_tools/toolchain\"", + "toolchain_root=\"$$(/usr/bin/env -i HOME=\"$$HOME\" LANG=C LC_ALL=C PATH=\"$$PATH\" RUSTUP_TOOLCHAIN=1.96.1 rustc --print sysroot)\"", + "toolchain_root=\"$$(CDPATH= cd -- \"$$toolchain_root\" && pwd -P)\"", + "[[ -f \"$$toolchain_root/bin/cargo\" && ! -L \"$$toolchain_root/bin/cargo\" && -x \"$$toolchain_root/bin/cargo\" && -f \"$$toolchain_root/bin/rustc\" && ! -L \"$$toolchain_root/bin/rustc\" && -x \"$$toolchain_root/bin/rustc\" ]]", "gate_uid=\"$$(/usr/bin/id -u)\"", "gate_gid=\"$$(/usr/bin/id -g)\"", - "namespace_runner=( \\\n\t\t/usr/bin/unshare \\\n\t\t--user \\\n\t\t--map-current-user \\\n\t\t--keep-caps \\\n\t\t--mount \\\n\t\t--pid \\\n\t\t--fork \\\n\t\t--kill-child=KILL \\\n\t\t--mount-proc \\\n\t\t--net \\\n\t\t/usr/bin/setpriv \\\n\t\t--bounding-set=-all \\\n\t\t--inh-caps=-all \\\n\t\t--ambient-caps=-all \\\n\t\t--no-new-privs \\\n\t);", + "namespace_setup='set -euo pipefail; toolchain_root=\"$$1\"; gate_tools=\"$$2\"; shift 2; /usr/bin/mount --bind \"$$toolchain_root\" \"$$gate_tools/toolchain\"; /usr/bin/mount --options remount,bind,ro,nosuid,nodev \"$$gate_tools/toolchain\"; /usr/bin/mount --types tmpfs --options mode=755,nosuid,nodev,noexec tmpfs /home; exec \"$$@\"'", + "namespace_runner=( \\\n\t\t/usr/bin/unshare \\\n\t\t--user \\\n\t\t--map-current-user \\\n\t\t--keep-caps \\\n\t\t--mount \\\n\t\t--pid \\\n\t\t--fork \\\n\t\t--kill-child=KILL \\\n\t\t--mount-proc \\\n\t\t--net \\\n\t\t/bin/bash \\\n\t\t-p \\\n\t\t-c \\\n\t\t\"$$namespace_setup\" \\\n\t\tgate-namespace \\\n\t\t\"$$toolchain_root\" \\\n\t\t\"$$gate_tools\" \\\n\t\t/usr/bin/setpriv \\\n\t\t--bounding-set=-all \\\n\t\t--inh-caps=-all \\\n\t\t--ambient-caps=-all \\\n\t\t--no-new-privs \\\n\t);", "if ! \"$${namespace_runner[@]}\" /bin/true >/dev/null 2>&1; then", - "namespace_runner=( \\\n\t\t\t/usr/bin/sudo \\\n\t\t\t--non-interactive \\\n\t\t\t-- \\\n\t\t\t/usr/bin/unshare \\\n\t\t\t--mount \\\n\t\t\t--pid \\\n\t\t\t--fork \\\n\t\t\t--kill-child=KILL \\\n\t\t\t--mount-proc \\\n\t\t\t--net \\\n\t\t\t/usr/bin/setpriv \\\n\t\t\t--bounding-set=-all \\\n\t\t\t--inh-caps=-all \\\n\t\t\t--ambient-caps=-all \\\n\t\t\t--reuid \"$$gate_uid\" \\\n\t\t\t--regid \"$$gate_gid\" \\\n\t\t\t--clear-groups \\\n\t\t\t--no-new-privs \\\n\t\t);", + "namespace_runner=( \\\n\t\t\t/usr/bin/sudo \\\n\t\t\t--non-interactive \\\n\t\t\t-- \\\n\t\t\t/usr/bin/unshare \\\n\t\t\t--mount \\\n\t\t\t--pid \\\n\t\t\t--fork \\\n\t\t\t--kill-child=KILL \\\n\t\t\t--mount-proc \\\n\t\t\t--net \\\n\t\t\t/bin/bash \\\n\t\t\t-p \\\n\t\t\t-c \\\n\t\t\t\"$$namespace_setup\" \\\n\t\t\tgate-namespace \\\n\t\t\t\"$$toolchain_root\" \\\n\t\t\t\"$$gate_tools\" \\\n\t\t\t/usr/bin/setpriv \\\n\t\t\t--bounding-set=-all \\\n\t\t\t--inh-caps=-all \\\n\t\t\t--ambient-caps=-all \\\n\t\t\t--reuid \"$$gate_uid\" \\\n\t\t\t--regid \"$$gate_gid\" \\\n\t\t\t--clear-groups \\\n\t\t\t--no-new-privs \\\n\t\t);", "\"$${namespace_runner[@]}\" /bin/true; \\\n\tfi;", "exec 8<&- 9<&-;", "cd -- /;", - "\"$${namespace_runner[@]}\" \\\n\t\t\t\t/usr/bin/env -i \\\n\t\t\t\tCARGO_HOME=\"$$cargo_home\"", + "\"$${namespace_runner[@]}\" \\\n\t\t\t\t\"$$gate_tools/fs-sandbox\" \\\n\t\t\t\t--dir / \\\n\t\t\t\t--ro /usr \\\n\t\t\t\t--ro /etc \\\n\t\t\t\t--ro /proc \\\n\t\t\t\t--ro /sys \\\n\t\t\t\t--ro \"$$gate_tools\" \\\n\t\t\t\t--ro \"$$gate_tools/toolchain\" \\\n\t\t\t\t--rw /dev/null \\\n\t\t\t\t--rw /dev/zero \\\n\t\t\t\t--rw /dev/random \\\n\t\t\t\t--rw /dev/urandom \\\n\t\t\t\t--rw \"$$cargo_home\" \\\n\t\t\t\t--rw \"$$repro_home_b\" \\\n\t\t\t\t-- \\\n\t\t\t\t/usr/bin/env -i", "CARGO_HOME=\"$$cargo_home\"", "CARGO_NET_OFFLINE=true", "CARGO_TARGET_DIR=\"$$cargo_home/target\"", - "RUSTUP_TOOLCHAIN=1.96.1", + "HOME=\"$$cargo_home/home\"", + "PATH=\"$$gate_tools/toolchain/bin:/usr/bin:/bin\"", + "RUSTUP_TOOLCHAIN=1.96.1 \\\n\t\t\t\tSOURCE_DATE_EPOCH=0", + "TMPDIR=\"$$cargo_home/tmp\"", "--workspace --all-targets --release --locked --offline", "--workspace --lib --bins --locked --offline -- -D warnings -D clippy::arithmetic_side_effects -D clippy::as_conversions -D clippy::string_slice -D clippy::indexing_slicing -D clippy::unwrap_used -D clippy::expect_used -D clippy::panic", "run_cargo /usr/bin/env CARGO_TARGET_DIR=\"$$cargo_home/target-a\" cargo build --manifest-path \"$$cargo_home/repro-a/compiler/Cargo.toml\" -p orangec --bin orangec --release --locked --offline", @@ -86,7 +98,9 @@ "capture_paths_path=\"$$cargo_home/repro-source.paths\"", "repro_source_archive=/proc/self/fd/9", "repro_source_paths=/proc/self/fd/8", - "run_cargo /bin/bash -p -c 'for capability_set in CapInh CapPrm CapEff CapBnd CapAmb; do [[ \"$$(/usr/bin/sed -n \"s/^$${capability_set}:[[:space:]]*//p\" /proc/self/status)\" == 0000000000000000 ]] || exit 1; done; [[ $$$$ == 1 && $$PPID == 0 && \"$$(/usr/bin/id -u)\" == \"$$1\" && \"$$(/usr/bin/id -g)\" == \"$$2\" && \"$$(/usr/bin/sed -n \"s/^NoNewPrivs:[[:space:]]*//p\" /proc/self/status)\" == 1 && ! -e /proc/self/fd/8 && ! -e /proc/self/fd/9 && -z \"$$(/usr/bin/sed -n \"2p\" /proc/net/route)\" ]]' gate-isolation \"$$gate_uid\" \"$$gate_gid\"", + "/usr/bin/env -i LANG=C LC_ALL=C PATH=/usr/bin:/bin /usr/bin/cc -std=c17 -O2 -D_FORTIFY_SOURCE=3 -fPIE -pie -Wall -Wextra -Werror -pedantic -Wl,-z,relro,-z,now \"$$cargo_home/check-src/tools/fs_sandbox.c\" -o \"$$gate_tools/fs-sandbox\"", + "[[ -f \"$$gate_tools/fs-sandbox\" && ! -L \"$$gate_tools/fs-sandbox\" && -x \"$$gate_tools/fs-sandbox\" ]]", + "run_cargo /bin/bash -p -c 'for capability_set in CapInh CapPrm CapEff CapBnd CapAmb; do [[ \"$$(/usr/bin/sed -n \"s/^$${capability_set}:[[:space:]]*//p\" /proc/self/status)\" == 0000000000000000 ]] || exit 1; done; for descriptor in /proc/self/fd/*; do [[ ! -e \"$$descriptor\" || \"$${descriptor##*/}\" =~ ^[012]$$ ]] || exit 1; done; ! /usr/bin/head -c 1 -- \"$$3/Makefile\" >/dev/null 2>&1 || exit 1; [[ $$$$ == 1 && $$PPID == 0 && \"$$(/usr/bin/id -u)\" == \"$$1\" && \"$$(/usr/bin/id -g)\" == \"$$2\" && \"$$HOME\" == \"$$4\" && \"$$PATH\" == \"$$5/toolchain/bin:/usr/bin:/bin\" && \"$$(/usr/bin/sed -n \"s/^NoNewPrivs:[[:space:]]*//p\" /proc/self/status)\" == 1 && -z \"$$(/usr/bin/sed -n \"2p\" /proc/net/route)\" ]]' gate-isolation \"$$gate_uid\" \"$$gate_gid\" \"$$repository_root\" \"$$cargo_home/home\" \"$$gate_tools\"", "--create --file=\"$$capture_archive_path\"", "--format=gnu --sort=name --mtime=@0 --owner=0 --group=0 --numeric-owner --mode='u+rwX,go+rX,go-w,u-s,g-s,o-t'", "/usr/bin/env -i PATH=/usr/bin:/bin GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 /usr/bin/git -C \"$$repository_root\" ls-files --cached -z > \"$$capture_paths_path\"", diff --git a/tools/fs_sandbox.c b/tools/fs_sandbox.c new file mode 100644 index 0000000..f13162c --- /dev/null +++ b/tools/fs_sandbox.c @@ -0,0 +1,183 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef LANDLOCK_ACCESS_FS_IOCTL_DEV +#define LANDLOCK_ACCESS_FS_IOCTL_DEV (1ULL << 15) +#endif + +static void fail(const char *stage) +{ + const int error_number = errno; + + (void)dprintf(STDERR_FILENO, + "orange filesystem sandbox failed at %s (errno %d)\n", + stage, error_number); + _exit(125); +} + +static uint64_t handled_rights(int abi) +{ + uint64_t rights = 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; + + if (abi >= 2) { + rights |= LANDLOCK_ACCESS_FS_REFER; + } + if (abi >= 3) { + rights |= LANDLOCK_ACCESS_FS_TRUNCATE; + } + if (abi >= 5) { + rights |= LANDLOCK_ACCESS_FS_IOCTL_DEV; + } + return rights; +} + +static uint64_t allowed_rights(uint64_t handled, mode_t mode, int access_mode) +{ + uint64_t rights = 0; + + if (S_ISDIR(mode)) { + rights |= LANDLOCK_ACCESS_FS_READ_DIR; + } + if (access_mode == 0) { + return rights & handled; + } + rights |= LANDLOCK_ACCESS_FS_EXECUTE | LANDLOCK_ACCESS_FS_READ_FILE; + if (access_mode == 1) { + return rights & handled; + } + + rights |= LANDLOCK_ACCESS_FS_WRITE_FILE | LANDLOCK_ACCESS_FS_TRUNCATE; + if (S_ISDIR(mode)) { + rights |= LANDLOCK_ACCESS_FS_REMOVE_DIR | + LANDLOCK_ACCESS_FS_REMOVE_FILE | + LANDLOCK_ACCESS_FS_MAKE_DIR | + LANDLOCK_ACCESS_FS_MAKE_REG | + LANDLOCK_ACCESS_FS_MAKE_SOCK | + LANDLOCK_ACCESS_FS_MAKE_FIFO | + LANDLOCK_ACCESS_FS_MAKE_SYM | + LANDLOCK_ACCESS_FS_REFER; + } + if (S_ISCHR(mode) || S_ISBLK(mode)) { + rights |= LANDLOCK_ACCESS_FS_IOCTL_DEV; + } + return rights & handled; +} + +static void add_path_rule(int ruleset_fd, uint64_t handled, + const char *path, int access_mode) +{ + struct landlock_path_beneath_attr rule = {0}; + struct stat status; + int path_fd; + + if (path[0] != '/') { + errno = EINVAL; + fail("path"); + } + path_fd = open(path, O_PATH | O_CLOEXEC); + if (path_fd < 0) { + fail("open-rule"); + } + if (fstat(path_fd, &status) < 0) { + fail("stat-rule"); + } + rule.parent_fd = path_fd; + rule.allowed_access = allowed_rights(handled, status.st_mode, access_mode); + if (syscall(SYS_landlock_add_rule, ruleset_fd, + LANDLOCK_RULE_PATH_BENEATH, &rule, 0) < 0) { + fail("add-rule"); + } + if (close(path_fd) < 0) { + fail("close-rule"); + } +} + +int main(int argc, char **argv) +{ + struct landlock_ruleset_attr ruleset = {0}; + uint64_t handled; + int abi; + int ruleset_fd; + int index = 1; + int rule_count = 0; + + abi = (int)syscall(SYS_landlock_create_ruleset, NULL, 0, + LANDLOCK_CREATE_RULESET_VERSION); + if (abi < 3) { + errno = abi < 0 ? errno : EOPNOTSUPP; + fail("abi"); + } + handled = handled_rights(abi); + ruleset.handled_access_fs = handled; + ruleset_fd = (int)syscall(SYS_landlock_create_ruleset, &ruleset, + sizeof(ruleset), 0); + if (ruleset_fd < 0) { + fail("create-ruleset"); + } + + while (index < argc && argv[index][0] == '-') { + int access_mode; + + if (strcmp(argv[index], "--") == 0) { + ++index; + break; + } + if (index + 1 >= argc) { + errno = EINVAL; + fail("arguments"); + } + if (strcmp(argv[index], "--dir") == 0) { + access_mode = 0; + } else if (strcmp(argv[index], "--ro") == 0) { + access_mode = 1; + } else if (strcmp(argv[index], "--rw") == 0) { + access_mode = 2; + } else { + errno = EINVAL; + fail("arguments"); + } + add_path_rule(ruleset_fd, handled, argv[index + 1], access_mode); + index += 2; + ++rule_count; + } + if (rule_count == 0 || index >= argc || argv[index][0] != '/') { + errno = EINVAL; + fail("arguments"); + } + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0) { + fail("no-new-privileges"); + } + if (syscall(SYS_landlock_restrict_self, ruleset_fd, 0) < 0) { + fail("restrict-self"); + } + if (close(ruleset_fd) < 0) { + fail("close-ruleset"); + } + if (syscall(SYS_close_range, 3U, ~0U, 0U) < 0) { + fail("close-descriptors"); + } + execv(argv[index], &argv[index]); + fail("execute"); +} diff --git a/tools/tests/test_validate_foundation_hardening.py b/tools/tests/test_validate_foundation_hardening.py index 016e8cd..7a416d9 100644 --- a/tools/tests/test_validate_foundation_hardening.py +++ b/tools/tests/test_validate_foundation_hardening.py @@ -5,7 +5,6 @@ import io import json import os -import pwd import shutil import subprocess import sys @@ -1186,6 +1185,7 @@ def test_repository_launcher_canonicalizes_scope_and_fixes_the_build_epoch(self) self.assertIn(" -i \\\n", launcher) self.assertIn(' HOME="$ACCOUNT_HOME" \\\n', launcher) self.assertIn(' PATH="$SAFE_PATH" \\\n', launcher) + self.assertIn('/usr/bin/getent passwd "$(/usr/bin/id -u)"', launcher) self.assertNotIn("${SOURCE_DATE_EPOCH", launcher) self.assertIn('if [ "$#" -ne 0 ]; then\n', launcher) self.assertIn('/usr/bin/find "$SCRIPT_PATH" -prune -links 1 -print', launcher) @@ -1204,6 +1204,19 @@ def test_repository_launcher_uses_absolute_control_commands(self) -> None: script_directory.mkdir(parents=True) copied_launcher = script_directory / "check-repository" shutil.copy2(launcher, copied_launcher) + account_home = test_root / "account-home" + account_home.mkdir() + launcher_source = copied_launcher.read_text(encoding="utf-8") + account_lookup = 'ACCOUNT_RECORD="$(/usr/bin/getent passwd "$(/usr/bin/id -u)")" || {' + self.assertEqual(launcher_source.count(account_lookup), 1) + fixture_record = ( + f'ACCOUNT_RECORD="sandbox:x:{os.getuid()}:{os.getgid()}:Sandbox:' + f'{account_home}:/bin/sh" || {{' + ) + copied_launcher.write_text( + launcher_source.replace(account_lookup, fixture_record, 1), + encoding="utf-8", + ) hostile_path = test_root / "hostile-path" hostile_path.mkdir() @@ -1264,7 +1277,7 @@ def test_repository_launcher_uses_absolute_control_commands(self) -> None: for line in observed.read_text(encoding="utf-8").splitlines() if "=" in line ) - account_home = Path(pwd.getpwuid(os.getuid()).pw_dir).resolve() + account_home = account_home.resolve() self.assertEqual(environment["HOME"], str(account_home)) self.assertEqual( environment["PATH"], @@ -3084,6 +3097,17 @@ def test_make_check_must_be_policy_first_serialized_and_environment_isolated(sel 'repro_home_b="$$cargo_home"', "make.compiler_environment_contract", ), + ( + 'trap \'/usr/bin/rm -rf -- "$$cargo_home" "$$repro_home_b" ' + '"$$gate_tools"\' EXIT;', + 'trap \'/usr/bin/rm -rf -- "$$cargo_home" "$$repro_home_b"\' EXIT;', + "make.compiler_environment_contract", + ), + ( + 'toolchain_root="$$(CDPATH= cd -- "$$toolchain_root" && pwd -P)"', + 'toolchain_root="$$toolchain_root"', + "make.compiler_environment_contract", + ), ( "exec 8<&- 9<&-;", "/usr/bin/true;", @@ -3119,6 +3143,16 @@ def test_make_check_must_be_policy_first_serialized_and_environment_isolated(sel "", "make.compiler_environment_contract", ), + ( + "remount,bind,ro,nosuid,nodev", + "remount,bind,rw,nosuid,nodev", + "make.compiler_environment_contract", + ), + ( + "mode=755,nosuid,nodev,noexec tmpfs /home", + "mode=755,nosuid,nodev,noexec tmpfs /mnt", + "make.compiler_environment_contract", + ), ( "\t\t\t/usr/bin/sudo \\\n", "\t\t\t/usr/bin/true \\\n", @@ -3150,7 +3184,43 @@ def test_make_check_must_be_policy_first_serialized_and_environment_isolated(sel "make.compiler_environment_contract", ), ( - "run_cargo /bin/bash -p -c 'for capability_set in CapInh CapPrm CapEff CapBnd CapAmb; do [[ \"$$(/usr/bin/sed -n \"s/^$${capability_set}:[[:space:]]*//p\" /proc/self/status)\" == 0000000000000000 ]] || exit 1; done; [[ $$$$ == 1 && $$PPID == 0 && \"$$(/usr/bin/id -u)\" == \"$$1\" && \"$$(/usr/bin/id -g)\" == \"$$2\" && \"$$(/usr/bin/sed -n \"s/^NoNewPrivs:[[:space:]]*//p\" /proc/self/status)\" == 1 && ! -e /proc/self/fd/8 && ! -e /proc/self/fd/9 && -z \"$$(/usr/bin/sed -n \"2p\" /proc/net/route)\" ]]' gate-isolation \"$$gate_uid\" \"$$gate_gid\"", + '"$$gate_tools/fs-sandbox" \\\n', + '/bin/true \\\n', + "make.compiler_environment_contract", + ), + ( + "\t\t\t\t--ro /usr \\\n", + "", + "make.compiler_environment_contract", + ), + ( + "\t\t\t\t--rw \"$$cargo_home\" \\\n", + "", + "make.compiler_environment_contract", + ), + ( + 'HOME="$$cargo_home/home"', + 'HOME="$$HOME"', + "make.compiler_environment_contract", + ), + ( + 'PATH="$$gate_tools/toolchain/bin:/usr/bin:/bin"', + 'PATH="$$PATH"', + "make.compiler_environment_contract", + ), + ( + 'TMPDIR="$$cargo_home/tmp"', + 'TMPDIR="/tmp"', + "make.compiler_environment_contract", + ), + ( + '/usr/bin/cc -std=c17 -O2 -D_FORTIFY_SOURCE=3 -fPIE -pie ' + '-Wall -Wextra -Werror -pedantic -Wl,-z,relro,-z,now', + "/usr/bin/cc -std=c17", + "make.compiler_environment_contract", + ), + ( + "run_cargo /bin/bash -p -c 'for capability_set in CapInh CapPrm CapEff CapBnd CapAmb; do [[ \"$$(/usr/bin/sed -n \"s/^$${capability_set}:[[:space:]]*//p\" /proc/self/status)\" == 0000000000000000 ]] || exit 1; done; for descriptor in /proc/self/fd/*; do [[ ! -e \"$$descriptor\" || \"$${descriptor##*/}\" =~ ^[012]$$ ]] || exit 1; done; ! /usr/bin/head -c 1 -- \"$$3/Makefile\" >/dev/null 2>&1 || exit 1; [[ $$$$ == 1 && $$PPID == 0 && \"$$(/usr/bin/id -u)\" == \"$$1\" && \"$$(/usr/bin/id -g)\" == \"$$2\" && \"$$HOME\" == \"$$4\" && \"$$PATH\" == \"$$5/toolchain/bin:/usr/bin:/bin\" && \"$$(/usr/bin/sed -n \"s/^NoNewPrivs:[[:space:]]*//p\" /proc/self/status)\" == 1 && -z \"$$(/usr/bin/sed -n \"2p\" /proc/net/route)\" ]]' gate-isolation \"$$gate_uid\" \"$$gate_gid\" \"$$repository_root\" \"$$cargo_home/home\" \"$$gate_tools\"", "/usr/bin/true", "make.compiler_environment_contract", ), @@ -3396,6 +3466,85 @@ def test_make_check_must_be_policy_first_serialized_and_environment_isolated(sel self.assertIn(expected_code, codes) self.assertNotIn("make.contract", codes) + def test_filesystem_sandbox_denies_unadmitted_files_and_closes_descriptors(self) -> None: + source_root = Path(__file__).resolve().parents[2] + source = source_root / "tools/fs_sandbox.c" + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + binary = root / "fs-sandbox" + allowed = root / "allowed" + denied = root / "denied" + allowed.mkdir() + denied.mkdir() + (allowed / "input").write_text("allowed\n", encoding="utf-8") + (denied / "secret").write_text("denied\n", encoding="utf-8") + (allowed / "escape").symlink_to(denied / "secret") + subprocess.run( + [ + "/usr/bin/cc", + "-std=c17", + "-O2", + "-D_FORTIFY_SOURCE=3", + "-fPIE", + "-pie", + "-Wall", + "-Wextra", + "-Werror", + "-pedantic", + "-Wl,-z,relro,-z,now", + os.fspath(source), + "-o", + os.fspath(binary), + ], + check=True, + capture_output=True, + text=True, + ) + inherited = os.open(denied / "secret", os.O_RDONLY) + try: + result = subprocess.run( + [ + os.fspath(binary), + "--dir", + "/", + "--ro", + "/usr", + "--ro", + "/etc", + "--ro", + "/proc", + "--rw", + "/dev/null", + "--rw", + os.fspath(allowed), + "--", + "/bin/bash", + "-p", + "-c", + ( + 'set -euo pipefail; [[ "$(cat input)" == allowed ]]; ' + "printf sandboxed > output; " + "! cat ../denied/secret >/dev/null 2>&1; " + "! cat escape >/dev/null 2>&1; " + "! printf escaped > ../denied/output 2>/dev/null; " + '[[ ! -e "/proc/self/fd/$1" ]]' + ), + "sandbox", + str(inherited), + ], + cwd=allowed, + env={"LANG": "C", "LC_ALL": "C", "PATH": "/usr/bin:/bin"}, + pass_fds=(inherited,), + capture_output=True, + text=True, + check=False, + ) + finally: + os.close(inherited) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((allowed / "output").read_text(encoding="utf-8"), "sandboxed") + self.assertFalse((denied / "output").exists()) + def test_make_contract_rejects_an_invalid_root(self) -> None: source_root = Path(__file__).resolve().parents[2] with tempfile.TemporaryDirectory() as directory: diff --git a/tools/validate_foundation.py b/tools/validate_foundation.py index 692d32a..7048a28 100755 --- a/tools/validate_foundation.py +++ b/tools/validate_foundation.py @@ -274,6 +274,7 @@ scripts/ci/check-external-links scripts/ci/install-actionlint scripts/ci/install-lychee +tools/fs_sandbox.c tools/validate_foundation.py tools/tests/test_validate_foundation.py tools/tests/test_validate_foundation_hardening.py @@ -440,7 +441,7 @@ comment_summary_in_pr: never warn_only: false """ -_PHD = "f7b7e1f9f463dd0a01b63c57146ba1685bd47c2c5b68e37e30f234e604d80faa" +_PHD = "8f6eaadda6d185d88698e498eeced235049d11ec8f92bd2ad1bd8389963ff9a4" _CR = ( "run: /usr/bin/env -u BASH_ENV -u ENV -u GNUMAKEFLAGS -u MAKEFLAGS -u MAKEFILES " "-u MAKEOVERRIDES -u MFLAGS /usr/bin/make --no-builtin-rules --no-builtin-variables check-compiler"