diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 739d36e..8670c22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: jobs: rust: @@ -13,7 +14,11 @@ jobs: strategy: fail-fast: false matrix: - runner: [ubuntu-latest, ubuntu-24.04-arm] + # ubuntu-22.04 (kernel 5.15, Landlock ABI v3) catches a regression + # that ubuntu-latest (24.04, kernel 6.8, ABI v4) cannot — the + # FsTruncate / NetTcp / FsIoctlDev path stays exercised even after + # the runner image rolls forward. + runner: [ubuntu-22.04, ubuntu-latest, ubuntu-24.04-arm] steps: - uses: actions/checkout@v4 @@ -26,11 +31,33 @@ jobs: - name: Build run: cargo build --release + - name: Report Landlock ABI on this runner + # Surface the host's Landlock ABI in the job log so a future + # Landlock-version-sensitive regression is visible at a glance, + # without needing to dig into individual test outputs. + run: ./target/release/sandlock check || true + - name: Run unit tests run: cargo test --release --lib -- --test-threads=1 - name: Run integration tests - run: cargo test --release --test integration -- --test-threads=1 + run: | + # On a runner whose Landlock ABI is below the project floor + # (MIN_ABI = 6), the bulk of the integration suite cannot + # execute: every test driver constructs a default + # `Sandbox::builder()` whose `ProtectionPolicy::strict_all()` + # requires every Protection to resolve to Active. On + # ubuntu-22.04 (Landlock ABI v4 in the current GHA image) + # we therefore restrict the integration cell to + # `test_protection` — the policy/resolution-mechanics tests + # that run against a synthetic ABI and are host-ABI- + # independent. The remaining integration tests stay on the + # v6+ runners where their host assumption holds. + if [ "${{ matrix.runner }}" = "ubuntu-22.04" ]; then + cargo test --release --test integration test_protection -- --test-threads=1 + else + cargo test --release --test integration -- --test-threads=1 + fi python: name: Python tests (${{ matrix.runner }}, py${{ matrix.python-version }}) diff --git a/README.md b/README.md index d491216..998fcca 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,9 @@ Sandlock is implemented in **Rust** for performance and safety: | Landlock TCP port rules | 6.7 (ABI v4) | | Landlock IPC scoping | 6.12 (ABI v6) | +Protections can be selectively waived per-policy when needed — see +[`docs/extension-handlers.md#protection-opt-out`](docs/extension-handlers.md#protection-opt-out). + ## Install ### From source diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index 5d3803b..36bd002 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -110,10 +110,46 @@ struct RunArgs { #[arg(long)] no_supervisor: bool, + /// Allow the named protection to degrade silently if the host kernel ABI lacks support. + /// Repeatable. Accepted values: fs-refer, fs-truncate, net-tcp, fs-ioctl-dev, + /// signal-scope, abstract-unix-scope-socket. + #[arg(long = "allow-degraded", value_name = "PROTECTION")] + allow_degraded: Vec, + + /// Disable the named protection entirely (no rule emitted, no error on missing ABI). + /// Repeatable. Accepts the same values as --allow-degraded. + #[arg(long = "disable", value_name = "PROTECTION")] + disable: Vec, + #[arg(last = true)] cmd: Vec, } +/// Parse a kebab-case protection name into a `Protection` value. +/// +/// The canonical names match the Landlock kernel constants +/// (`LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET` → `abstract-unix-socket-scope`, +/// etc.) and are case-insensitive. Accepted: `fs-refer`, `fs-truncate`, +/// `net-tcp`, `fs-ioctl-dev`, `signal-scope`, +/// `abstract-unix-socket-scope` (alias: `abstract-unix-scope-socket`). +fn parse_protection(s: &str) -> Result { + use sandlock_core::Protection; + match s.to_ascii_lowercase().as_str() { + "fs-refer" => Ok(Protection::FsRefer), + "fs-truncate" => Ok(Protection::FsTruncate), + "net-tcp" => Ok(Protection::NetTcp), + "fs-ioctl-dev" => Ok(Protection::FsIoctlDev), + "signal-scope" => Ok(Protection::SignalScope), + "abstract-unix-socket-scope" | "abstract-unix-scope-socket" => { + Ok(Protection::AbstractUnixSocketScope) + } + other => Err(format!( + "unknown protection: {} (valid: fs-refer, fs-truncate, net-tcp, fs-ioctl-dev, signal-scope, abstract-unix-socket-scope)", + other, + )), + } +} + #[derive(Subcommand)] enum ProfileAction { /// List available profiles @@ -207,6 +243,14 @@ async fn main() -> Result<()> { println!(" Device ioctl: {}", if v >= 5 { "supported (ABI v5+)" } else { "not supported" }); println!(" IPC scoping: {}", if v >= 6 { "supported (ABI v6+)" } else { "not supported" }); println!(" Signal scoping: {}", if v >= 6 { "supported (ABI v6+)" } else { "not supported" }); + + println!(); + println!("Per-protection availability (host Landlock ABI v{}):", v); + for p in sandlock_core::Protection::all() { + let available = v >= p.min_abi(); + let marker = if available { "available" } else { "unavailable" }; + println!(" {:<22} requires v{} — {}", format!("{:?}", p), p.min_abi(), marker); + } } Err(e) => { println!(" Landlock: unavailable ({})", e); @@ -459,6 +503,14 @@ async fn run_command(args: RunArgs) -> Result<()> { builder = builder.no_supervisor(true); } + // CLI overrides — protection policy + for s in &args.allow_degraded { + builder = builder.allow_degraded(parse_protection(s).map_err(|e| anyhow!(e))?); + } + for s in &args.disable { + builder = builder.disable(parse_protection(s).map_err(|e| anyhow!(e))?); + } + let policy = builder.build()?; let cmd_strs: Vec<&str> = if let Some(ref shell_cmd) = args.exec_shell { vec!["/bin/sh", "-c", shell_cmd.as_str()] diff --git a/crates/sandlock-core/src/error.rs b/crates/sandlock-core/src/error.rs index 2158f76..90fd924 100644 --- a/crates/sandlock-core/src/error.rs +++ b/crates/sandlock-core/src/error.rs @@ -66,6 +66,19 @@ pub enum ConfinementError { feature: String, }, + /// A `Protection` in `ProtectionState::Strict` is unavailable + /// because the host kernel's Landlock ABI is below the + /// protection's `min_abi()`. Build (or `confine`) refuses to + /// proceed; the caller can resolve by setting that protection to + /// `Degradable` or `Disabled`, or by running on a kernel that + /// supports it. + #[error("required protection {protection:?} is not available: host Landlock ABI is v{host_abi}, requires v{required_abi}")] + ProtectionUnavailable { + protection: crate::protection::Protection, + required_abi: u32, + host_abi: u32, + }, + #[error("landlock error: {0}")] Landlock(String), diff --git a/crates/sandlock-core/src/landlock.rs b/crates/sandlock-core/src/landlock.rs index d5c5fbb..0b2e06a 100644 --- a/crates/sandlock-core/src/landlock.rs +++ b/crates/sandlock-core/src/landlock.rs @@ -2,6 +2,7 @@ use std::os::fd::OwnedFd; use std::path::Path; use crate::error::{ConfinementError, SandlockError}; +use crate::protection::{Protection, ProtectionPolicy, ProtectionState}; use crate::sandbox::Sandbox; use crate::sys::structs::{ LandlockNetPortAttr, LandlockPathBeneathAttr, LandlockRulesetAttr, @@ -188,11 +189,171 @@ fn add_net_rule(ruleset_fd: &OwnedFd, port: u16, access: u64) -> Result<(), Conf Ok(()) } +// ============================================================ +// Per-protection availability resolution +// ============================================================ + +/// Resolution for a single `Protection` against the host's Landlock +/// ABI and the policy's state for it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[doc(hidden)] +pub enum Resolved { + /// Enforce: protection is available on the host and the policy + /// requires (or allows) it. + Active, + /// Enforce-not: protection is unavailable on the host but the + /// policy named it `Degradable`, so we skip it silently. + Degraded, + /// Off: the policy disabled this protection (regardless of host + /// support). + Disabled, + /// Error: the policy is `Strict` but the host kernel cannot + /// provide this protection. + StrictlyUnavailable, +} + +/// Resolve a single `Protection` against the host ABI and a +/// `ProtectionPolicy` into one of four states. +#[doc(hidden)] +pub fn resolve(p: Protection, host_abi: u32, policy: &ProtectionPolicy) -> Resolved { + let available = host_abi >= p.min_abi(); + match (policy.state(p), available) { + (ProtectionState::Disabled, _) => Resolved::Disabled, + (ProtectionState::Strict, true) => Resolved::Active, + (ProtectionState::Strict, false) => Resolved::StrictlyUnavailable, + (ProtectionState::Degradable, true) => Resolved::Active, + (ProtectionState::Degradable, false) => Resolved::Degraded, + } +} + +/// Compute the `scoped` mask from the per-protection resolutions of +/// the two scope protections. +/// +/// # Precondition +/// +/// The caller must have already rejected any `Protection` whose +/// `resolve()` is `Resolved::StrictlyUnavailable` — otherwise this +/// function silently produces a mask that omits the bit, which is the +/// right answer for `Disabled` / `Degraded` but the *wrong* answer for +/// `StrictlyUnavailable` (where the call should never have reached +/// the mask-compute stage). `confine_inner` enforces this by walking +/// `Protection::all()` and returning `ProtectionUnavailable` for any +/// strict-unavailable protection before this function is called. +/// +/// In test builds a `debug_assert!` pins the invariant so a future +/// caller that forgets the upstream guard fails loudly. +pub(crate) fn compute_scope_mask(abi: u32, pol: &ProtectionPolicy) -> u64 { + debug_assert!( + !matches!( + resolve(Protection::SignalScope, abi, pol), + Resolved::StrictlyUnavailable, + ), + "compute_scope_mask called with SignalScope StrictlyUnavailable; \ + caller must filter via confine_inner's Protection::all() walk first" + ); + debug_assert!( + !matches!( + resolve(Protection::AbstractUnixSocketScope, abi, pol), + Resolved::StrictlyUnavailable, + ), + "compute_scope_mask called with AbstractUnixSocketScope StrictlyUnavailable; \ + caller must filter via confine_inner's Protection::all() walk first" + ); + + let mut mask: u64 = 0; + if resolve(Protection::AbstractUnixSocketScope, abi, pol) == Resolved::Active { + mask |= LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET; + } + if resolve(Protection::SignalScope, abi, pol) == Resolved::Active { + mask |= LANDLOCK_SCOPE_SIGNAL; + } + mask +} + +/// Compute the `handled_access_fs` mask. Starts from the ABI-cumulative +/// base set and masks off bits whose corresponding `Protection` is +/// `Disabled` or `Degraded` in the policy. +/// +/// `Degraded` means a `Degradable` protection on a host that does not +/// provide the underlying kernel hook — declaring the bit anyway would +/// fail `landlock_create_ruleset` with EINVAL and break the silent-skip +/// contract. `base_fs_access(abi)` already gates each extension bit on +/// the host ABI, so on a real host the `Degraded` bit is not in the +/// base mask in the first place. Masking it off here is defence in +/// depth: the contract is uniformly expressed regardless of how +/// `base_fs_access` evolves, and the synthetic-ABI integration tests +/// can exercise this code path directly. +pub fn compute_fs_mask(abi: u32, pol: &ProtectionPolicy) -> u64 { + let mut mask = base_fs_access(abi); + if matches!( + resolve(Protection::FsRefer, abi, pol), + Resolved::Disabled | Resolved::Degraded + ) { + mask &= !LANDLOCK_ACCESS_FS_REFER; + } + if matches!( + resolve(Protection::FsTruncate, abi, pol), + Resolved::Disabled | Resolved::Degraded + ) { + mask &= !LANDLOCK_ACCESS_FS_TRUNCATE; + } + if matches!( + resolve(Protection::FsIoctlDev, abi, pol), + Resolved::Disabled | Resolved::Degraded + ) { + mask &= !LANDLOCK_ACCESS_FS_IOCTL_DEV; + } + mask +} + +/// Compute the `handled_access_net` mask AND the TCP wildcard flag, +/// preserving the wildcard behaviour: when any TCP `--net-allow` rule +/// covers every port we drop `CONNECT_TCP` from the handled set (the +/// on-behalf path is then the sole enforcer). +/// +/// Returns `(0, false)` when `Protection::NetTcp` is not `Active` +/// (either disabled by policy or degraded on a kernel that does not +/// provide TCP network hooks). +/// +/// Returning both the mask and the wildcard flag keeps the rule- +/// installation site in `confine_inner` in sync with the mask: the +/// caller no longer recomputes the wildcard from `sandbox.net_allow`, +/// so divergence between the two derivations is impossible by +/// construction. +pub fn compute_net_mask( + abi: u32, + pol: &ProtectionPolicy, + sandbox: &Sandbox, + handle_net: bool, +) -> (u64, bool) { + if !handle_net { + return (0, false); + } + if resolve(Protection::NetTcp, abi, pol) != Resolved::Active { + return (0, false); + } + use crate::sandbox::Protocol; + let net_wildcard = sandbox + .net_allow + .iter() + .any(|r| r.protocol == Protocol::Tcp && r.all_ports); + let mask = if net_wildcard { + LANDLOCK_ACCESS_NET_BIND_TCP + } else { + LANDLOCK_ACCESS_NET_BIND_TCP | LANDLOCK_ACCESS_NET_CONNECT_TCP + }; + (mask, net_wildcard) +} + // ============================================================ // Main entry point // ============================================================ -/// Minimum Landlock ABI version required by sandlock. +/// Minimum Landlock ABI version required by sandlock when every +/// protection is in the default `ProtectionState::Strict`. The +/// authoritative per-protection floors live in +/// `Protection::min_abi()`; this constant is kept for backward +/// compatibility with downstream code that re-exports it. pub const MIN_ABI: u32 = 6; /// Apply Landlock confinement based on the given `Sandbox`. @@ -209,27 +370,40 @@ pub fn confine_filesystem(policy: &Sandbox) -> Result<(), SandlockError> { } fn confine_inner(policy: &Sandbox, handle_net: bool) -> Result<(), SandlockError> { - // Step 1 -- detect and validate ABI version. + // Step 1 — detect host ABI version. let abi = abi_version().map_err(|e| { SandlockError::Runtime(crate::error::SandboxRuntimeError::Confinement(e)) })?; - if abi < MIN_ABI { - return Err(SandlockError::Runtime( - crate::error::SandboxRuntimeError::Confinement( - ConfinementError::InsufficientAbi { - required: MIN_ABI, - actual: abi, - feature: "full sandlock support".into(), - }, - ), - )); + // Step 2 — per-protection availability resolution. Any protection + // in `ProtectionState::Strict` that the host kernel cannot provide + // is a hard error here; `Degradable` becomes a silent skip and + // `Disabled` is honoured regardless of host support. With the + // default `ProtectionPolicy::strict_all()` on a v6+ host this + // produces `Resolved::Active` for every protection — preserving + // the historical `MIN_ABI = 6` floor exactly. + let pol = &policy.protection_policy; + for protection in Protection::all() { + if resolve(protection, abi, pol) == Resolved::StrictlyUnavailable { + return Err(SandlockError::Runtime( + crate::error::SandboxRuntimeError::Confinement( + ConfinementError::ProtectionUnavailable { + protection, + required_abi: protection.min_abi(), + host_abi: abi, + }, + ), + )); + } } - // Step 2 -- build handled_access_fs / handled_access_net / scoped. - let handled_access_fs = base_fs_access(abi); + // Step 3 — build handled_access_fs / handled_access_net / scoped. + // + // FS: cumulative ABI base set, with `Disabled` protections masked + // off (FsRefer/FsTruncate/FsIoctlDev). + let handled_access_fs = compute_fs_mask(abi, pol); - // Restrict TCP bind/connect via Landlock by default. When any + // Net: TCP bind/connect via Landlock by default. When any // `--net-allow` rule has the all-ports wildcard (`host:*` or // `:*`), Landlock cannot express "every port" without enumerating // 65535 rules, so we drop CONNECT_TCP from the handled set — @@ -243,21 +417,17 @@ fn confine_inner(policy: &Sandbox, handle_net: bool) -> Result<(), SandlockError // on-behalf path), so they're filtered out here — feeding them to // Landlock would either be a no-op (for unhandled protocols) or // wrongly install TCP rules from a UDP wildcard. + // + // `compute_net_mask` is the single source of truth for both the + // handled-net mask and the TCP wildcard flag: the rule-installation + // block below uses the same `net_wildcard` value the mask was + // derived from, so the two cannot diverge. use crate::sandbox::Protocol; - let net_wildcard = policy - .net_allow - .iter() - .any(|r| r.protocol == Protocol::Tcp && r.all_ports); - let handled_access_net = if !handle_net { - 0 - } else if net_wildcard { - LANDLOCK_ACCESS_NET_BIND_TCP - } else { - LANDLOCK_ACCESS_NET_BIND_TCP | LANDLOCK_ACCESS_NET_CONNECT_TCP - }; + let (handled_access_net, net_wildcard) = compute_net_mask(abi, pol, policy, handle_net); - // IPC and signal isolation are always enabled. - let scoped = LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET | LANDLOCK_SCOPE_SIGNAL; + // Scope: IPC + signal isolation, each gated on its protection's + // resolved state. + let scoped = compute_scope_mask(abi, pol); // Step 3 — create ruleset. let attr = LandlockRulesetAttr { @@ -331,8 +501,12 @@ fn confine_inner(policy: &Sandbox, handle_net: bool) -> Result<(), SandlockError } } - // Step 5 -- add network port rules. - if handle_net { + // Step 5 -- add network port rules. Skip entirely when + // `Protection::NetTcp` is not `Active` (either policy-disabled or + // degraded on a host without TCP network hooks) — `handled_access_net` + // is 0 in that case and the kernel would reject any rule with EINVAL. + let net_tcp_active = resolve(Protection::NetTcp, abi, pol) == Resolved::Active; + if handle_net && net_tcp_active { for &port in &policy.net_bind { add_net_rule(&ruleset_fd, port, LANDLOCK_ACCESS_NET_BIND_TCP).map_err(|e| { SandlockError::Runtime(crate::error::SandboxRuntimeError::Confinement(e)) @@ -349,7 +523,7 @@ fn confine_inner(policy: &Sandbox, handle_net: bool) -> Result<(), SandlockError // When `net_wildcard` is set we already excluded CONNECT_TCP from // `handled_access_net`, so adding rules here would fail with EINVAL. // Skip — the on-behalf path is the sole enforcer. - if handle_net && !net_wildcard { + if handle_net && net_tcp_active && !net_wildcard { let mut connect_ports: std::collections::HashSet = std::collections::HashSet::new(); for rule in &policy.net_allow { // TCP-only — see net_wildcard comment above. @@ -379,3 +553,211 @@ fn confine_inner(policy: &Sandbox, handle_net: bool) -> Result<(), SandlockError Ok(()) } + +// ============================================================ +// Security-contract tests for the mask-compute helpers +// ============================================================ +// +// These tests check the *observable* output bits of +// `compute_scope_mask` / `compute_fs_mask` / `compute_net_mask` against +// the Landlock kernel constants, not just that the policy-state +// HashMap was mutated. Each `ProtectionState` combined with each host +// ABI produces a specific bit pattern — these tests are the contract +// pin for the Landlock attrs that exit `confine_inner`. Drift here is +// a security bug, not a refactor cleanup. + +#[cfg(test)] +mod mask_contract_tests { + use super::*; + use crate::Sandbox; + + // ---------- compute_scope_mask ---------- + + #[test] + fn scope_mask_strict_v6_sets_both_scope_bits() { + let pol = ProtectionPolicy::strict_all(); + let mask = compute_scope_mask(6, &pol); + assert_eq!( + mask, + LANDLOCK_SCOPE_SIGNAL | LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET, + "strict_all on v6 host must request both v6 IPC scopes" + ); + } + + #[test] + fn scope_mask_disable_signal_clears_only_signal_bit() { + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::SignalScope, ProtectionState::Disabled); + let mask = compute_scope_mask(6, &pol); + assert_eq!(mask & LANDLOCK_SCOPE_SIGNAL, 0, "SIGNAL must be cleared"); + assert_eq!( + mask & LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET, + LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET, + "ABSTRACT_UNIX_SOCKET must remain set" + ); + } + + #[test] + fn scope_mask_disable_abstract_unix_clears_only_abstract_bit() { + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::AbstractUnixSocketScope, ProtectionState::Disabled); + let mask = compute_scope_mask(6, &pol); + assert_eq!( + mask & LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET, + 0, + "ABSTRACT_UNIX_SOCKET must be cleared", + ); + assert_eq!( + mask & LANDLOCK_SCOPE_SIGNAL, + LANDLOCK_SCOPE_SIGNAL, + "SIGNAL must remain set", + ); + } + + #[test] + fn scope_mask_disable_both_returns_zero() { + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::SignalScope, ProtectionState::Disabled); + pol.set(Protection::AbstractUnixSocketScope, ProtectionState::Disabled); + assert_eq!( + compute_scope_mask(6, &pol), + 0, + "both scopes disabled on a capable host must produce mask=0" + ); + } + + #[test] + fn scope_mask_allow_degraded_on_v5_host_returns_zero() { + // v5 does not provide either v6 scope; Degradable must skip + // silently — observable as both bits absent. + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::SignalScope, ProtectionState::Degradable); + pol.set(Protection::AbstractUnixSocketScope, ProtectionState::Degradable); + assert_eq!( + compute_scope_mask(5, &pol), + 0, + "Degradable scopes on a v5 host must contribute no bits" + ); + } + + // ---------- compute_fs_mask ---------- + + #[test] + fn fs_mask_strict_v6_includes_all_fs_protection_bits() { + let pol = ProtectionPolicy::strict_all(); + let mask = compute_fs_mask(6, &pol); + for (bit, name) in [ + (LANDLOCK_ACCESS_FS_REFER, "REFER"), + (LANDLOCK_ACCESS_FS_TRUNCATE, "TRUNCATE"), + (LANDLOCK_ACCESS_FS_IOCTL_DEV, "IOCTL_DEV"), + ] { + assert_eq!( + mask & bit, + bit, + "{} bit must be set in the strict v6 fs mask", + name, + ); + } + } + + #[test] + fn fs_mask_disable_fs_refer_clears_only_refer_bit() { + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::FsRefer, ProtectionState::Disabled); + let mask = compute_fs_mask(6, &pol); + assert_eq!(mask & LANDLOCK_ACCESS_FS_REFER, 0); + assert_eq!(mask & LANDLOCK_ACCESS_FS_TRUNCATE, LANDLOCK_ACCESS_FS_TRUNCATE); + assert_eq!(mask & LANDLOCK_ACCESS_FS_IOCTL_DEV, LANDLOCK_ACCESS_FS_IOCTL_DEV); + } + + #[test] + fn fs_mask_disable_fs_truncate_clears_only_truncate_bit() { + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::FsTruncate, ProtectionState::Disabled); + let mask = compute_fs_mask(6, &pol); + assert_eq!(mask & LANDLOCK_ACCESS_FS_TRUNCATE, 0); + assert_eq!(mask & LANDLOCK_ACCESS_FS_REFER, LANDLOCK_ACCESS_FS_REFER); + assert_eq!(mask & LANDLOCK_ACCESS_FS_IOCTL_DEV, LANDLOCK_ACCESS_FS_IOCTL_DEV); + } + + #[test] + fn fs_mask_disable_fs_ioctl_dev_clears_only_ioctl_dev_bit() { + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::FsIoctlDev, ProtectionState::Disabled); + let mask = compute_fs_mask(6, &pol); + assert_eq!(mask & LANDLOCK_ACCESS_FS_IOCTL_DEV, 0); + assert_eq!(mask & LANDLOCK_ACCESS_FS_REFER, LANDLOCK_ACCESS_FS_REFER); + assert_eq!(mask & LANDLOCK_ACCESS_FS_TRUNCATE, LANDLOCK_ACCESS_FS_TRUNCATE); + } + + #[test] + fn fs_mask_degraded_protections_get_masked_off_on_low_abi_host() { + // FsIoctlDev requires v5; on a v4 host it is Degraded. The bit + // must NOT appear in the mask — declaring a bit the kernel + // doesn't know would fail landlock_create_ruleset with EINVAL. + // This is the bug class commit bf9490d fixed; pin it here. + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::FsIoctlDev, ProtectionState::Degradable); + let mask = compute_fs_mask(4, &pol); + assert_eq!( + mask & LANDLOCK_ACCESS_FS_IOCTL_DEV, + 0, + "Degraded FsIoctlDev on a v4 host must NOT contribute the IOCTL_DEV bit", + ); + } + + // ---------- compute_net_mask ---------- + + fn empty_sandbox() -> Sandbox { + Sandbox::builder() + .build_unchecked() + .expect("minimal builder must produce a sandbox in unit tests") + } + + #[test] + fn net_mask_handle_net_false_returns_zero_no_wildcard() { + let pol = ProtectionPolicy::strict_all(); + let sb = empty_sandbox(); + let (mask, wildcard) = compute_net_mask(6, &pol, &sb, false); + assert_eq!(mask, 0, "handle_net=false → mask is zero"); + assert!(!wildcard, "handle_net=false → wildcard is false"); + } + + #[test] + fn net_mask_strict_no_wildcard_sets_bind_and_connect_bits() { + let pol = ProtectionPolicy::strict_all(); + let sb = empty_sandbox(); + let (mask, wildcard) = compute_net_mask(6, &pol, &sb, true); + assert_eq!( + mask, + LANDLOCK_ACCESS_NET_BIND_TCP | LANDLOCK_ACCESS_NET_CONNECT_TCP, + "strict NetTcp with no wildcard rule → both BIND_TCP and CONNECT_TCP", + ); + assert!(!wildcard); + } + + #[test] + fn net_mask_disable_net_tcp_returns_zero() { + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::NetTcp, ProtectionState::Disabled); + let sb = empty_sandbox(); + let (mask, wildcard) = compute_net_mask(6, &pol, &sb, true); + assert_eq!( + mask, 0, + "disabled NetTcp must produce mask=0 regardless of handle_net", + ); + assert!(!wildcard); + } + + #[test] + fn net_mask_degraded_net_tcp_on_v3_host_returns_zero() { + // NetTcp requires v4. On a v3 host Degradable resolves to + // Degraded, contributing no bits. + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::NetTcp, ProtectionState::Degradable); + let sb = empty_sandbox(); + let (mask, wildcard) = compute_net_mask(3, &pol, &sb, true); + assert_eq!(mask, 0); + assert!(!wildcard); + } +} diff --git a/crates/sandlock-core/src/lib.rs b/crates/sandlock-core/src/lib.rs index e64f6ed..9051327 100644 --- a/crates/sandlock-core/src/lib.rs +++ b/crates/sandlock-core/src/lib.rs @@ -6,6 +6,7 @@ pub mod result; pub(crate) mod arch; pub(crate) mod sys; pub mod landlock; +pub mod protection; pub mod seccomp; pub(crate) mod resource; pub(crate) mod network; @@ -30,6 +31,7 @@ pub(crate) mod http_acl; pub use error::SandlockError; pub use sys::structs::{SeccompData, SeccompNotif}; pub use checkpoint::Checkpoint; +pub use protection::{Protection, ProtectionState, ProtectionPolicy, ProtectionStatus}; pub use sandbox::{Confinement, ConfinementBuilder, Sandbox, SandboxBuilder}; pub use result::{RunResult, ExitStatus}; pub use pipeline::{Stage, Pipeline, Gather}; diff --git a/crates/sandlock-core/src/protection.rs b/crates/sandlock-core/src/protection.rs new file mode 100644 index 0000000..7d52a26 --- /dev/null +++ b/crates/sandlock-core/src/protection.rs @@ -0,0 +1,210 @@ +//! Per-protection ABI floor for Landlock protections. +//! +//! Sandlock relies on a set of Landlock-provided protections, each +//! introduced in a specific Landlock ABI version. This module names +//! them as `Protection` variants and maps each to the minimum ABI the +//! host kernel must support. +//! +//! The actual policy that decides whether a protection is enforced, +//! degraded, or disabled lives in the higher-level +//! `ProtectionPolicy` (also in this module). The decision-vs-availability +//! resolution happens in `landlock::confine_inner`. + +use std::collections::HashMap; + +/// A single Landlock-provided protection, ABI-gated. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Protection { + /// `LANDLOCK_ACCESS_FS_REFER` — ABI v2+. + FsRefer, + /// `LANDLOCK_ACCESS_FS_TRUNCATE` — ABI v3+. + FsTruncate, + /// `LANDLOCK_ACCESS_NET_BIND_TCP` / `_CONNECT_TCP` — ABI v4+. + NetTcp, + /// `LANDLOCK_ACCESS_FS_IOCTL_DEV` — ABI v5+. + FsIoctlDev, + /// `LANDLOCK_SCOPE_SIGNAL` — ABI v6+. + SignalScope, + /// `LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET` — ABI v6+. + AbstractUnixSocketScope, +} + +impl Protection { + /// Minimum Landlock ABI version the host kernel must support for + /// this protection to be available. + pub const fn min_abi(self) -> u32 { + match self { + Protection::FsRefer => 2, + Protection::FsTruncate => 3, + Protection::NetTcp => 4, + Protection::FsIoctlDev => 5, + Protection::SignalScope => 6, + Protection::AbstractUnixSocketScope => 6, + } + } + + /// Iterator over every known protection. + pub fn all() -> impl Iterator { + [ + Protection::FsRefer, + Protection::FsTruncate, + Protection::NetTcp, + Protection::FsIoctlDev, + Protection::SignalScope, + Protection::AbstractUnixSocketScope, + ] + .into_iter() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn min_abi_matches_kernel_documented_floors() { + // These numbers come from the kernel Landlock documentation + // (https://docs.kernel.org/userspace-api/landlock.html); + // they MUST NOT drift. + assert_eq!(Protection::FsRefer.min_abi(), 2); + assert_eq!(Protection::FsTruncate.min_abi(), 3); + assert_eq!(Protection::NetTcp.min_abi(), 4); + assert_eq!(Protection::FsIoctlDev.min_abi(), 5); + assert_eq!(Protection::SignalScope.min_abi(), 6); + assert_eq!(Protection::AbstractUnixSocketScope.min_abi(), 6); + } + + #[test] + fn all_iterates_every_variant_exactly_once() { + let collected: Vec = Protection::all().collect(); + assert_eq!(collected.len(), 6); + // No duplicates. + for p in &collected { + assert_eq!(collected.iter().filter(|&q| q == p).count(), 1); + } + } +} + +/// What a `ProtectionPolicy` instructs sandlock to do with a given +/// `Protection`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProtectionState { + /// Enforce; if the host kernel cannot provide this protection, + /// `confine_inner` returns an error naming the protection and the + /// kernel's actual ABI version. This is the default for every + /// protection. + Strict, + /// Enforce where the host kernel supports it; skip silently when + /// it does not. The skip is observable via `Sandbox::active_protections()` + /// and `sandlock check`. + Degradable, + /// Never enforce, even on a host kernel that supports the protection. + /// Intended for workloads that genuinely need the capability the + /// protection blocks. + Disabled, +} + +/// Per-`Protection` state collection. The default for any protection +/// not explicitly named is `ProtectionState::Strict` — meaning a +/// freshly-constructed `ProtectionPolicy` produces the same behaviour +/// as the current hard `MIN_ABI = 6` floor. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ProtectionPolicy { + states: HashMap, +} + +impl ProtectionPolicy { + /// A policy with no overrides — every protection defaults to + /// `Strict`. Equivalent to the pre-Protection behaviour. + pub fn strict_all() -> Self { + Self::default() + } + + /// Look up the state for a given protection. Returns `Strict` + /// for protections not explicitly named. + pub fn state(&self, protection: Protection) -> ProtectionState { + self.states.get(&protection).copied().unwrap_or(ProtectionState::Strict) + } + + /// Set the state for a single protection. Internal API — public + /// builder methods (in the polarity-dependent layer landing later) + /// call this. Marked `#[doc(hidden)] pub` so integration tests in + /// the `tests/` directory can drive the resolver directly; not part + /// of the stable public surface. + #[doc(hidden)] + pub fn set(&mut self, protection: Protection, state: ProtectionState) { + self.states.insert(protection, state); + } + + /// Iterator over every protection paired with its resolved state + /// (including the implicit `Strict` for unnamed ones). + pub fn iter(&self) -> impl Iterator + '_ { + Protection::all().map(|p| (p, self.state(p))) + } +} + +/// Per-protection runtime status, resolved against the host's +/// Landlock ABI and the `ProtectionPolicy`. Returned by +/// `Sandbox::active_protections()`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProtectionStatus { + /// Enforced (policy is Strict or Degradable, host supports it). + Active, + /// Policy named the protection as Degradable, host does not + /// support it — silently skipped. + Degraded, + /// Policy explicitly disabled the protection. + Disabled, + /// Policy was Strict and host does not support it — would have + /// caused `build()` to fail. + Unavailable, +} + +impl ProtectionStatus { + pub(crate) fn resolve(p: Protection, host_abi: u32, pol: &ProtectionPolicy) -> Self { + let available = host_abi >= p.min_abi(); + match (pol.state(p), available) { + (ProtectionState::Disabled, _) => ProtectionStatus::Disabled, + (ProtectionState::Strict, true) => ProtectionStatus::Active, + (ProtectionState::Strict, false) => ProtectionStatus::Unavailable, + (ProtectionState::Degradable, true) => ProtectionStatus::Active, + (ProtectionState::Degradable, false) => ProtectionStatus::Degraded, + } + } +} + +#[cfg(test)] +mod policy_tests { + use super::*; + + #[test] + fn strict_all_returns_strict_for_every_protection() { + let pol = ProtectionPolicy::strict_all(); + for p in Protection::all() { + assert_eq!(pol.state(p), ProtectionState::Strict); + } + } + + #[test] + fn unnamed_protections_default_to_strict_even_after_other_overrides() { + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::SignalScope, ProtectionState::Degradable); + assert_eq!(pol.state(Protection::SignalScope), ProtectionState::Degradable); + assert_eq!(pol.state(Protection::FsTruncate), ProtectionState::Strict); + assert_eq!(pol.state(Protection::AbstractUnixSocketScope), ProtectionState::Strict); + } + + #[test] + fn iter_yields_every_protection_with_resolved_state() { + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::FsIoctlDev, ProtectionState::Disabled); + let collected: Vec<_> = pol.iter().collect(); + assert_eq!(collected.len(), 6); + assert!(collected.iter().any(|(p, s)| *p == Protection::FsIoctlDev && *s == ProtectionState::Disabled)); + for (p, s) in &collected { + if *p != Protection::FsIoctlDev { + assert_eq!(*s, ProtectionState::Strict, "{:?} should default to Strict", p); + } + } + } +} diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 0d4ceec..f97505a 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -11,6 +11,7 @@ use crate::context; use crate::error::SandboxError; pub use crate::http::{http_acl_check, normalize_path, prefix_or_exact_match, HttpRule}; pub use crate::network::{NetAllow, Protocol}; +use crate::protection::{Protection, ProtectionPolicy, ProtectionState, ProtectionStatus}; /// A byte size value. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -224,6 +225,18 @@ pub struct Sandbox { pub extra_deny_syscalls: Vec, pub extra_allow_syscalls: Vec, + /// Per-protection enforcement policy. Default + /// (`ProtectionPolicy::strict_all()`) preserves the historical hard + /// `MIN_ABI = 6` behaviour. Builder methods to deviate from + /// strict-all are added in a follow-up. + /// + /// Not serialized — policy-file representation of per-protection + /// state arrives with the public builder API in a later change. + /// Deserialized sandboxes get `ProtectionPolicy::default()`, which + /// is identical to `strict_all()`. + #[serde(skip)] + pub protection_policy: ProtectionPolicy, + // Network /// Outbound endpoint allowlist as a list of `(protocol, host?, ports)` /// rules. Each rule names a protocol (TCP/UDP/ICMP) and either a @@ -365,6 +378,7 @@ impl Clone for Sandbox { fs_denied: self.fs_denied.clone(), extra_deny_syscalls: self.extra_deny_syscalls.clone(), extra_allow_syscalls: self.extra_allow_syscalls.clone(), + protection_policy: self.protection_policy.clone(), net_allow: self.net_allow.clone(), net_bind: self.net_bind.clone(), http_allow: self.http_allow.clone(), @@ -435,6 +449,18 @@ impl Sandbox { Ok(()) } + /// Resolve the per-protection state against the host's current + /// Landlock ABI. Returns one entry per `Protection`. Useful for + /// post-`build()` posture inspection. + pub fn active_protections(&self) -> Result, crate::error::SandlockError> { + let host_abi = crate::landlock::abi_version().map_err(|e| { + crate::error::SandlockError::Runtime(crate::error::SandboxRuntimeError::Confinement(e)) + })?; + Ok(Protection::all() + .map(|p| (p, ProtectionStatus::resolve(p, host_abi, &self.protection_policy))) + .collect()) + } + // ================================================================ // Runtime accessor helpers (private) // ================================================================ @@ -1910,6 +1936,12 @@ pub struct SandboxBuilder { #[cfg_attr(feature = "cli", arg(long = "uid"))] pub uid: Option, + /// Per-protection state overrides. Defaults to `strict_all` — every + /// protection enforced, matching the historical `MIN_ABI = 6` floor. + /// Use the `allow_degraded` / `disable` builder methods to deviate. + #[cfg_attr(feature = "cli", clap(skip))] + pub protection_policy: ProtectionPolicy, + // Internal callback — never a CLI flag. #[cfg_attr(feature = "cli", clap(skip))] pub policy_fn: Option, @@ -1985,6 +2017,7 @@ impl Clone for SandboxBuilder { port_remap: self.port_remap, no_supervisor: self.no_supervisor, uid: self.uid, + protection_policy: self.protection_policy.clone(), policy_fn: self.policy_fn.clone(), name: self.name.clone(), // init_fn (FnOnce) cannot be cloned — drop to None. @@ -1996,6 +2029,29 @@ impl Clone for SandboxBuilder { } impl SandboxBuilder { + /// Permit `protection` to be enforced when the host kernel + /// supports it, and silently skipped when it does not (fallback + /// for kernels below the protection's `min_abi()`). + /// + /// The default policy enforces every protection strictly; calling + /// `allow_degraded` lifts the strictness for the named protection + /// only. `sandlock check` and `Sandbox::active_protections()` + /// continue to report the degraded protection so the posture is + /// observable. + pub fn allow_degraded(mut self, protection: Protection) -> Self { + self.protection_policy.set(protection, ProtectionState::Degradable); + self + } + + /// Never enforce `protection`, even on a host kernel that supports + /// it. Intended for workloads that legitimately need the capability + /// the protection blocks (e.g. signalling a sibling process when + /// `SignalScope` would normally prevent it). + pub fn disable(mut self, protection: Protection) -> Self { + self.protection_policy.set(protection, ProtectionState::Disabled); + self + } + pub fn fs_write(mut self, path: impl Into) -> Self { self.fs_writable.push(path.into()); self @@ -2343,6 +2399,7 @@ impl SandboxBuilder { fs_denied: self.fs_denied, extra_deny_syscalls: self.extra_deny_syscalls, extra_allow_syscalls: self.extra_allow_syscalls, + protection_policy: self.protection_policy, net_allow, net_bind: self.net_bind, http_allow, diff --git a/crates/sandlock-core/tests/integration.rs b/crates/sandlock-core/tests/integration.rs index 5c90f88..707c0be 100644 --- a/crates/sandlock-core/tests/integration.rs +++ b/crates/sandlock-core/tests/integration.rs @@ -57,3 +57,6 @@ mod test_http_acl; #[path = "integration/test_handlers.rs"] mod test_handlers; + +#[path = "integration/test_protection.rs"] +mod test_protection; diff --git a/crates/sandlock-core/tests/integration/test_protection.rs b/crates/sandlock-core/tests/integration/test_protection.rs new file mode 100644 index 0000000..6b621a4 --- /dev/null +++ b/crates/sandlock-core/tests/integration/test_protection.rs @@ -0,0 +1,322 @@ +//! Integration tests for the per-protection availability resolution +//! in `landlock::confine_inner`. +//! +//! These tests exercise the policy-driven resolution path directly via +//! the `pub(crate)` `resolve()` helper with synthetic ABI values, so +//! they are independent of the host kernel's actual Landlock ABI. + +use sandlock_core::landlock::{compute_fs_mask, resolve, Resolved}; +use sandlock_core::{Protection, ProtectionPolicy, ProtectionState, ProtectionStatus}; + +// Landlock FS access constants (kernel ABI, stable). Kept local to the +// test so we don't need to expose `crate::sys::structs`. These are bit +// positions defined by `linux/landlock.h`. +const LANDLOCK_ACCESS_FS_REFER: u64 = 1 << 13; +const LANDLOCK_ACCESS_FS_TRUNCATE: u64 = 1 << 14; +const LANDLOCK_ACCESS_FS_IOCTL_DEV: u64 = 1 << 15; + +// ---------------------------------------------------------------------- +// resolve() — Strict +// ---------------------------------------------------------------------- + +#[test] +fn strict_on_supporting_host_resolves_to_active() { + // SignalScope needs ABI v6; host claims v6. + let pol = ProtectionPolicy::strict_all(); + assert_eq!( + resolve(Protection::SignalScope, 6, &pol), + Resolved::Active, + "Strict + available host must resolve to Active" + ); +} + +#[test] +fn strictly_unavailable_returns_protection_unavailable() { + // SignalScope needs ABI v6; host only has v5. + let pol = ProtectionPolicy::strict_all(); + let r = resolve(Protection::SignalScope, 5, &pol); + assert_eq!( + r, + Resolved::StrictlyUnavailable, + "Strict + unavailable host must resolve to StrictlyUnavailable" + ); +} + +#[test] +fn strict_all_on_v6_host_resolves_every_protection_active() { + // Default strict_all() policy on a kernel meeting the highest floor + // (v6) yields Active for every protection — this is the load-bearing + // invariant that preserves pre-refactor confine_inner behaviour. + let pol = ProtectionPolicy::strict_all(); + for p in Protection::all() { + assert_eq!( + resolve(p, 6, &pol), + Resolved::Active, + "{:?} under strict_all on v6 host must be Active", + p + ); + } +} + +// ---------------------------------------------------------------------- +// resolve() — Degradable +// ---------------------------------------------------------------------- + +#[test] +fn degradable_on_unavailable_host_resolves_to_degraded() { + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::SignalScope, ProtectionState::Degradable); + let r = resolve(Protection::SignalScope, 5, &pol); + assert_eq!( + r, + Resolved::Degraded, + "Degradable + unavailable host must resolve to Degraded (silent skip)" + ); +} + +#[test] +fn degradable_on_supporting_host_resolves_to_active() { + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::FsTruncate, ProtectionState::Degradable); + // FsTruncate needs v3. + assert_eq!( + resolve(Protection::FsTruncate, 6, &pol), + Resolved::Active, + "Degradable + available host must enforce (Active)" + ); +} + +// ---------------------------------------------------------------------- +// resolve() — Disabled +// ---------------------------------------------------------------------- + +#[test] +fn disabled_on_supporting_host_resolves_to_disabled() { + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::SignalScope, ProtectionState::Disabled); + let r = resolve(Protection::SignalScope, 6, &pol); + assert_eq!( + r, + Resolved::Disabled, + "Disabled must resolve to Disabled regardless of host support" + ); +} + +#[test] +fn disabled_on_unavailable_host_resolves_to_disabled() { + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::SignalScope, ProtectionState::Disabled); + let r = resolve(Protection::SignalScope, 5, &pol); + assert_eq!( + r, + Resolved::Disabled, + "Disabled wins over host availability — never StrictlyUnavailable" + ); +} + +// ---------------------------------------------------------------------- +// Per-protection ABI floor matrix +// ---------------------------------------------------------------------- + +#[test] +fn strict_all_on_v4_host_fails_only_for_v5_plus_protections() { + // Host with ABI v4 supports FsRefer (v2), FsTruncate (v3), NetTcp + // (v4); fails on FsIoctlDev (v5), SignalScope (v6), + // AbstractUnixSocketScope (v6). + let pol = ProtectionPolicy::strict_all(); + assert_eq!(resolve(Protection::FsRefer, 4, &pol), Resolved::Active); + assert_eq!(resolve(Protection::FsTruncate, 4, &pol), Resolved::Active); + assert_eq!(resolve(Protection::NetTcp, 4, &pol), Resolved::Active); + assert_eq!( + resolve(Protection::FsIoctlDev, 4, &pol), + Resolved::StrictlyUnavailable + ); + assert_eq!( + resolve(Protection::SignalScope, 4, &pol), + Resolved::StrictlyUnavailable + ); + assert_eq!( + resolve(Protection::AbstractUnixSocketScope, 4, &pol), + Resolved::StrictlyUnavailable + ); +} + +#[test] +fn fully_degradable_policy_never_returns_strictly_unavailable_even_on_v1() { + // A policy that marks every protection Degradable must never + // produce StrictlyUnavailable, even on a host so old it only + // supports the v1 base set (no fs-extension protections). + let mut pol = ProtectionPolicy::strict_all(); + for p in Protection::all() { + pol.set(p, ProtectionState::Degradable); + } + for p in Protection::all() { + let r = resolve(p, 1, &pol); + assert!( + matches!(r, Resolved::Active | Resolved::Degraded), + "{:?} on v1 host with Degradable must not be StrictlyUnavailable, got {:?}", + p, + r + ); + } +} + +// ---------------------------------------------------------------------- +// compute_fs_mask() — Degraded protections must be masked off +// +// Regression guards for the bug where `compute_fs_mask` only masked +// off `Resolved::Disabled` bits, leaving a `Degraded` bit in the +// handled-fs mask. The kernel then rejects `landlock_create_ruleset` +// with EINVAL — breaking the `Degradable` silent-skip contract. +// +// Each test pins one extension protection to `Degradable` and feeds +// `compute_fs_mask` a synthetic host ABI below that protection's +// floor, asserting the bit is NOT in the returned mask. +// ---------------------------------------------------------------------- + +#[test] +fn degradable_fs_truncate_on_v1_host_masks_off_truncate_bit() { + // FsTruncate needs ABI v3; host claims v1. Marking it Degradable + // must drop LANDLOCK_ACCESS_FS_TRUNCATE from the handled-fs mask + // (it shouldn't be there from base_fs_access(1) either, but we + // assert the post-mask invariant directly). + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::FsTruncate, ProtectionState::Degradable); + // Sanity: the protection resolves Degraded on this host. + assert_eq!( + resolve(Protection::FsTruncate, 1, &pol), + Resolved::Degraded + ); + let mask = compute_fs_mask(1, &pol); + assert_eq!( + mask & LANDLOCK_ACCESS_FS_TRUNCATE, + 0, + "Degraded FsTruncate must not leave its bit in handled_access_fs (mask=0x{:x})", + mask + ); +} + +#[test] +fn degradable_fs_refer_on_v1_host_masks_off_refer_bit() { + // FsRefer needs ABI v2; host claims v1. Marking it Degradable + // must drop LANDLOCK_ACCESS_FS_REFER from the handled-fs mask. + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::FsRefer, ProtectionState::Degradable); + assert_eq!(resolve(Protection::FsRefer, 1, &pol), Resolved::Degraded); + let mask = compute_fs_mask(1, &pol); + assert_eq!( + mask & LANDLOCK_ACCESS_FS_REFER, + 0, + "Degraded FsRefer must not leave its bit in handled_access_fs (mask=0x{:x})", + mask + ); +} + +#[test] +fn degradable_fs_ioctl_dev_on_v4_host_masks_off_ioctl_dev_bit() { + // FsIoctlDev needs ABI v5; host claims v4. Marking it Degradable + // must drop LANDLOCK_ACCESS_FS_IOCTL_DEV from the handled-fs mask. + let mut pol = ProtectionPolicy::strict_all(); + pol.set(Protection::FsIoctlDev, ProtectionState::Degradable); + assert_eq!( + resolve(Protection::FsIoctlDev, 4, &pol), + Resolved::Degraded + ); + let mask = compute_fs_mask(4, &pol); + assert_eq!( + mask & LANDLOCK_ACCESS_FS_IOCTL_DEV, + 0, + "Degraded FsIoctlDev must not leave its bit in handled_access_fs (mask=0x{:x})", + mask + ); +} + +// ---------------------------------------------------------------------- +// Sandbox::active_protections() runtime accessor +// ---------------------------------------------------------------------- + +#[test] +fn active_protections_returns_six_entries() { + // Construct a default Sandbox; we don't actually run it — just + // call the accessor. The dev host has Landlock available, so + // abi_version() should succeed. + let sb = sandlock_core::Sandbox::builder().build_unchecked().expect("build"); + let result = sb.active_protections().expect("ABI detect"); + assert_eq!(result.len(), 6); +} + +#[test] +fn active_protections_reports_disabled_for_explicitly_off() { + let mut sb = sandlock_core::Sandbox::builder().build_unchecked().expect("build"); + sb.protection_policy.set(Protection::SignalScope, ProtectionState::Disabled); + let result = sb.active_protections().expect("ABI detect"); + let signal = result.iter().find(|(p, _)| *p == Protection::SignalScope).unwrap(); + assert_eq!(signal.1, ProtectionStatus::Disabled); +} + +// ---------------------------------------------------------------------- +// SandboxBuilder::allow_degraded / ::disable polarity-out methods +// ---------------------------------------------------------------------- + +#[test] +fn builder_allow_degraded_sets_state_to_degradable() { + let sb = sandlock_core::Sandbox::builder() + .allow_degraded(Protection::SignalScope) + .build_unchecked() + .expect("build"); + assert_eq!( + sb.protection_policy.state(Protection::SignalScope), + ProtectionState::Degradable + ); +} + +#[test] +fn builder_disable_sets_state_to_disabled() { + let sb = sandlock_core::Sandbox::builder() + .disable(Protection::AbstractUnixSocketScope) + .build_unchecked() + .expect("build"); + assert_eq!( + sb.protection_policy.state(Protection::AbstractUnixSocketScope), + ProtectionState::Disabled + ); +} + +#[test] +fn builder_methods_are_idempotent_last_wins() { + let sb = sandlock_core::Sandbox::builder() + .allow_degraded(Protection::SignalScope) + .disable(Protection::SignalScope) + .build_unchecked() + .expect("build"); + assert_eq!( + sb.protection_policy.state(Protection::SignalScope), + ProtectionState::Disabled + ); +} + +#[test] +fn builder_methods_fluent_chain() { + let sb = sandlock_core::Sandbox::builder() + .allow_degraded(Protection::SignalScope) + .allow_degraded(Protection::AbstractUnixSocketScope) + .disable(Protection::FsTruncate) + .build_unchecked() + .expect("build"); + assert_eq!( + sb.protection_policy.state(Protection::SignalScope), + ProtectionState::Degradable + ); + assert_eq!( + sb.protection_policy.state(Protection::AbstractUnixSocketScope), + ProtectionState::Degradable + ); + assert_eq!( + sb.protection_policy.state(Protection::FsTruncate), + ProtectionState::Disabled + ); + assert_eq!( + sb.protection_policy.state(Protection::FsRefer), + ProtectionState::Strict + ); +} diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index 0cf06d5..6812db7 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -56,6 +56,55 @@ sandlock_builder_t *sandlock_sandbox_builder_env_var(sandlock_builder_t *b, cons sandlock_builder_t *sandlock_sandbox_builder_no_randomize_memory(sandlock_builder_t *b, bool v); sandlock_builder_t *sandlock_sandbox_builder_no_huge_pages(sandlock_builder_t *b, bool v); +/* Landlock protections */ + +/** Per-protection Landlock feature identifier. + * + * Discriminant values mirror the Rust `Protection` enum in + * `sandlock_core::protection::Protection`. Stable across releases; + * new protections are appended at higher discriminants. Old + * discriminants are never reused. + * + * Functions below take the discriminant as `uint32_t` (not the enum + * type) so an out-of-range value from a C caller is rejected at the + * Rust boundary instead of triggering undefined behaviour. Callers + * should pass one of the constants below; any other value is treated + * as documented for each function. */ +typedef enum { + SANDLOCK_PROTECTION_FS_REFER = 0, + SANDLOCK_PROTECTION_FS_TRUNCATE = 1, + SANDLOCK_PROTECTION_NET_TCP = 2, + SANDLOCK_PROTECTION_FS_IOCTL_DEV = 3, + SANDLOCK_PROTECTION_SIGNAL_SCOPE = 4, + SANDLOCK_PROTECTION_ABSTRACT_UNIX_SOCKET_SCOPE = 5, +} sandlock_protection_t; + +/** Minimum Landlock ABI version the host kernel must support for the + * given protection to be available. + * + * Returns 0 for any `protection` value that is not a known + * discriminant. (0 is below every real `min_abi` — those start at + * 2 — so 0 functions as an unambiguous "unknown protection" sentinel.) */ +uint32_t sandlock_protection_min_abi(uint32_t protection); + +/** Mark `protection` as degradable on the builder: enforced when the + * host kernel supports it, silently skipped otherwise. Returns the + * (possibly relocated) builder pointer; mirrors the move-semantics + * convention of the other builder setters. An unknown `protection` + * value is treated as a no-op (the builder is returned unchanged). */ +sandlock_builder_t *sandlock_sandbox_builder_allow_degraded( + sandlock_builder_t *b, + uint32_t protection); + +/** Mark `protection` as disabled on the builder: never enforced, even + * on a host kernel that supports it. Returns the (possibly + * relocated) builder pointer; mirrors the move-semantics convention + * of the other builder setters. An unknown `protection` value is + * treated as a no-op. */ +sandlock_builder_t *sandlock_sandbox_builder_disable( + sandlock_builder_t *b, + uint32_t protection); + /* Build & free */ /* On failure, *err is set to -1 and *err_msg (if non-null) is set to a * heap-allocated C string with the error description. Caller frees it diff --git a/crates/sandlock-ffi/src/lib.rs b/crates/sandlock-ffi/src/lib.rs index 0ffbc98..52740b7 100644 --- a/crates/sandlock-ffi/src/lib.rs +++ b/crates/sandlock-ffi/src/lib.rs @@ -10,7 +10,7 @@ use std::time::Duration; use sandlock_core::pipeline::Stage; use sandlock_core::sandbox::{BranchAction, ByteSize, FsIsolation, SandboxBuilder}; -use sandlock_core::{Sandbox, RunResult}; +use sandlock_core::{Protection, Sandbox, RunResult}; pub mod handler; pub mod notif_repr; @@ -614,6 +614,111 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_deterministic_dirs( Box::into_raw(Box::new(builder.deterministic_dirs(v))) } +// ---------------------------------------------------------------- +// Sandbox Builder — Landlock protections +// ---------------------------------------------------------------- + +/// C ABI discriminants mirroring [`sandlock_core::Protection`]. +/// +/// The Rust entry-points below accept the discriminant as a `u32` +/// (rather than a `#[repr(C)]` enum) so that an out-of-range value +/// from a C or Python caller is rejected at the boundary instead of +/// reaching a Rust `match` over an enum and producing undefined +/// behaviour. +/// +/// New protections are appended at higher values; old discriminants +/// are never reused. +const PROT_FS_REFER: u32 = 0; +const PROT_FS_TRUNCATE: u32 = 1; +const PROT_NET_TCP: u32 = 2; +const PROT_FS_IOCTL_DEV: u32 = 3; +const PROT_SIGNAL_SCOPE: u32 = 4; +const PROT_ABSTRACT_UNIX_SOCKET_SCOPE: u32 = 5; + +/// Convert a raw discriminant into a `Protection`, returning `None` +/// for values not in the known range. Centralises the validation that +/// guards every C-ABI entry-point. +fn try_protection_from_raw(raw: u32) -> Option { + match raw { + PROT_FS_REFER => Some(Protection::FsRefer), + PROT_FS_TRUNCATE => Some(Protection::FsTruncate), + PROT_NET_TCP => Some(Protection::NetTcp), + PROT_FS_IOCTL_DEV => Some(Protection::FsIoctlDev), + PROT_SIGNAL_SCOPE => Some(Protection::SignalScope), + PROT_ABSTRACT_UNIX_SOCKET_SCOPE => Some(Protection::AbstractUnixSocketScope), + _ => None, + } +} + +/// Per-protection minimum Landlock ABI version required by the host +/// kernel for this protection to be available. +/// +/// Returns `0` for any `protection` value that is not a known +/// discriminant — `0` is below every real `min_abi()` (which start at +/// `2`), so callers can use it as an "unknown protection" sentinel +/// without colliding with a valid version number. +#[no_mangle] +pub extern "C" fn sandlock_protection_min_abi(protection: u32) -> u32 { + match try_protection_from_raw(protection) { + Some(p) => p.min_abi(), + None => 0, + } +} + +/// Mark `protection` as degradable on the builder: enforced when the +/// host kernel supports it, silently skipped otherwise. +/// +/// Returns the (possibly relocated) builder pointer, mirroring the +/// move-semantics convention used by every other +/// `sandlock_sandbox_builder_*` setter. A null `b` is returned +/// unchanged. An unknown `protection` discriminant is treated as a +/// no-op: the builder is returned untouched. +/// +/// # Safety +/// `b` must be a valid builder pointer returned by +/// `sandlock_sandbox_builder_new` (or a previous builder setter) and +/// not freed. +#[no_mangle] +pub unsafe extern "C" fn sandlock_sandbox_builder_allow_degraded( + b: *mut SandboxBuilder, + protection: u32, +) -> *mut SandboxBuilder { + if b.is_null() { return b; } + let p = match try_protection_from_raw(protection) { + Some(p) => p, + None => return b, + }; + let builder = *Box::from_raw(b); + Box::into_raw(Box::new(builder.allow_degraded(p))) +} + +/// Mark `protection` as disabled on the builder: never enforced, even +/// on a host kernel that supports it. +/// +/// Returns the (possibly relocated) builder pointer, mirroring the +/// move-semantics convention used by every other +/// `sandlock_sandbox_builder_*` setter. A null `b` is returned +/// unchanged. An unknown `protection` discriminant is treated as a +/// no-op: the builder is returned untouched. +/// +/// # Safety +/// `b` must be a valid builder pointer returned by +/// `sandlock_sandbox_builder_new` (or a previous builder setter) and +/// not freed. +#[no_mangle] +pub unsafe extern "C" fn sandlock_sandbox_builder_disable( + b: *mut SandboxBuilder, + protection: u32, +) -> *mut SandboxBuilder { + if b.is_null() { return b; } + let p = match try_protection_from_raw(protection) { + Some(p) => p, + None => return b, + }; + let builder = *Box::from_raw(b); + Box::into_raw(Box::new(builder.disable(p))) +} + // ---------------------------------------------------------------- // Sandbox Builder — build & free // ---------------------------------------------------------------- diff --git a/crates/sandlock-ffi/tests/protection.rs b/crates/sandlock-ffi/tests/protection.rs new file mode 100644 index 0000000..669a8d7 --- /dev/null +++ b/crates/sandlock-ffi/tests/protection.rs @@ -0,0 +1,249 @@ +//! Integration tests for the C ABI `Protection` setters. +//! +//! These tests drive the FFI symbols directly (no C compilation step) +//! and read back state through the public Rust `Sandbox` API to verify +//! the setters mutate the underlying `ProtectionPolicy`. +//! +//! Protection discriminants are passed as raw `u32` (matching the C ABI +//! and Python ctypes signatures). The `PROT_*` constants below mirror +//! the values defined in `crates/sandlock-ffi/include/sandlock.h`. + +use sandlock_core::{Protection, ProtectionState, Sandbox}; +use sandlock_ffi::{ + sandlock_protection_min_abi, sandlock_sandbox_builder_allow_degraded, + sandlock_sandbox_builder_disable, sandlock_sandbox_builder_new, +}; + +const PROT_FS_REFER: u32 = 0; +const PROT_FS_TRUNCATE: u32 = 1; +const PROT_NET_TCP: u32 = 2; +const PROT_FS_IOCTL_DEV: u32 = 3; +const PROT_SIGNAL_SCOPE: u32 = 4; +const PROT_ABSTRACT_UNIX_SOCKET_SCOPE: u32 = 5; + +#[test] +fn protection_min_abi_returns_kernel_documented_floors() { + // Discriminants in the C ABI must agree with Landlock's + // documented per-feature ABI floor. Drifting these numbers is a + // contract break with every external binding. + assert_eq!( + sandlock_protection_min_abi(PROT_FS_REFER), + 2, + "FsRefer requires Landlock ABI v2", + ); + assert_eq!( + sandlock_protection_min_abi(PROT_FS_TRUNCATE), + 3, + "FsTruncate requires Landlock ABI v3", + ); + assert_eq!( + sandlock_protection_min_abi(PROT_NET_TCP), + 4, + "NetTcp requires Landlock ABI v4", + ); + assert_eq!( + sandlock_protection_min_abi(PROT_FS_IOCTL_DEV), + 5, + "FsIoctlDev requires Landlock ABI v5", + ); + assert_eq!( + sandlock_protection_min_abi(PROT_SIGNAL_SCOPE), + 6, + "SignalScope requires Landlock ABI v6", + ); + assert_eq!( + sandlock_protection_min_abi(PROT_ABSTRACT_UNIX_SOCKET_SCOPE), + 6, + "AbstractUnixSocketScope requires Landlock ABI v6", + ); +} + +#[test] +fn protection_discriminants_cover_rust_enum_in_order() { + // The C ABI discriminants MUST mirror `Protection::all()` iteration + // order so external callers (Python ctypes, hand-written C) can + // index via the raw integer. + let rust_order: Vec = Protection::all().collect(); + assert_eq!(rust_order.len(), 6, "if a new protection lands, extend the FFI discriminants and the PROT_* constants"); + assert_eq!(rust_order[PROT_FS_REFER as usize], Protection::FsRefer); + assert_eq!(rust_order[PROT_FS_TRUNCATE as usize], Protection::FsTruncate); + assert_eq!(rust_order[PROT_NET_TCP as usize], Protection::NetTcp); + assert_eq!(rust_order[PROT_FS_IOCTL_DEV as usize], Protection::FsIoctlDev); + assert_eq!(rust_order[PROT_SIGNAL_SCOPE as usize], Protection::SignalScope); + assert_eq!( + rust_order[PROT_ABSTRACT_UNIX_SOCKET_SCOPE as usize], + Protection::AbstractUnixSocketScope, + ); +} + +/// Run a build sequence through the FFI: builder_new + the supplied +/// closure (typically chaining `allow_degraded` / `disable` setters) +/// + `build()`. Returns the resulting Sandbox so the caller can +/// inspect `protection_policy`. +fn build_via_ffi(configure: F) -> Sandbox +where + F: FnOnce(*mut sandlock_core::sandbox::SandboxBuilder) -> *mut sandlock_core::sandbox::SandboxBuilder, +{ + let b = sandlock_sandbox_builder_new(); + assert!(!b.is_null(), "builder_new returned null"); + let b = configure(b); + assert!(!b.is_null(), "configure returned null builder"); + // SAFETY: `b` is a valid Box pointer produced by builder_new and + // possibly relocated through builder setters. + let builder = unsafe { *Box::from_raw(b) }; + builder.build().expect("build failed") +} + +#[test] +fn builder_allow_degraded_marks_protection_degradable() { + let sandbox = build_via_ffi(|b| unsafe { + sandlock_sandbox_builder_allow_degraded(b, PROT_SIGNAL_SCOPE) + }); + assert_eq!( + sandbox.protection_policy.state(Protection::SignalScope), + ProtectionState::Degradable, + ); + // Other protections stay strict (default). + assert_eq!( + sandbox.protection_policy.state(Protection::FsRefer), + ProtectionState::Strict, + ); +} + +#[test] +fn builder_disable_marks_protection_disabled() { + let sandbox = build_via_ffi(|b| unsafe { + sandlock_sandbox_builder_disable(b, PROT_ABSTRACT_UNIX_SOCKET_SCOPE) + }); + assert_eq!( + sandbox.protection_policy.state(Protection::AbstractUnixSocketScope), + ProtectionState::Disabled, + ); + assert_eq!( + sandbox.protection_policy.state(Protection::FsRefer), + ProtectionState::Strict, + ); +} + +#[test] +fn builder_setters_chain_and_last_call_wins() { + // disable after allow_degraded must end in Disabled (last writer + // wins, mirroring `ProtectionPolicy::set` semantics). + let sandbox = build_via_ffi(|b| unsafe { + let b = sandlock_sandbox_builder_allow_degraded(b, PROT_SIGNAL_SCOPE); + let b = sandlock_sandbox_builder_disable(b, PROT_SIGNAL_SCOPE); + // And opt-out two more protections in one chain. + let b = sandlock_sandbox_builder_allow_degraded(b, PROT_FS_TRUNCATE); + sandlock_sandbox_builder_disable(b, PROT_NET_TCP) + }); + + assert_eq!( + sandbox.protection_policy.state(Protection::SignalScope), + ProtectionState::Disabled, + "last-writer-wins: disable after allow_degraded", + ); + assert_eq!( + sandbox.protection_policy.state(Protection::FsTruncate), + ProtectionState::Degradable, + ); + assert_eq!( + sandbox.protection_policy.state(Protection::NetTcp), + ProtectionState::Disabled, + ); + // Untouched protection stays Strict. + assert_eq!( + sandbox.protection_policy.state(Protection::FsIoctlDev), + ProtectionState::Strict, + ); +} + +#[test] +fn builder_setters_tolerate_null_builder() { + // Null in, null out — no panic. Matches the convention of every + // other `sandlock_sandbox_builder_*` setter. + let out = unsafe { + sandlock_sandbox_builder_allow_degraded(std::ptr::null_mut(), PROT_SIGNAL_SCOPE) + }; + assert!(out.is_null(), "allow_degraded(null, _) must return null"); + + let out = unsafe { + sandlock_sandbox_builder_disable(std::ptr::null_mut(), PROT_FS_REFER) + }; + assert!(out.is_null(), "disable(null, _) must return null"); +} + +// ---------------------------------------------------------------- +// Out-of-range discriminant guards. The Rust entry-points must reject +// unknown values at the boundary; reaching a Rust `match` over a +// `#[repr(C)]` enum with an out-of-range integer was undefined +// behaviour in the original implementation. These tests pin the new +// validating behaviour: setters become no-ops, `min_abi` returns the +// 0 sentinel. +// ---------------------------------------------------------------- + +const INVALID_DISCRIMINANTS: &[u32] = &[6, 7, 42, 99, 1_000, u32::MAX]; + +#[test] +fn protection_min_abi_returns_zero_sentinel_for_unknown_discriminant() { + for &raw in INVALID_DISCRIMINANTS { + assert_eq!( + sandlock_protection_min_abi(raw), + 0, + "min_abi({}) must return the 0 sentinel for an unknown discriminant", + raw, + ); + } +} + +#[test] +fn allow_degraded_with_unknown_discriminant_is_a_noop() { + // The builder pointer must be returned untouched, and the + // resulting Sandbox must have no `Degradable` state set. + for &raw in INVALID_DISCRIMINANTS { + let sandbox = build_via_ffi(|b| unsafe { + sandlock_sandbox_builder_allow_degraded(b, raw) + }); + for p in Protection::all() { + assert_eq!( + sandbox.protection_policy.state(p), + ProtectionState::Strict, + "raw discriminant {} must leave {:?} at the default Strict state", + raw, p, + ); + } + } +} + +#[test] +fn disable_with_unknown_discriminant_is_a_noop() { + for &raw in INVALID_DISCRIMINANTS { + let sandbox = build_via_ffi(|b| unsafe { + sandlock_sandbox_builder_disable(b, raw) + }); + for p in Protection::all() { + assert_eq!( + sandbox.protection_policy.state(p), + ProtectionState::Strict, + "raw discriminant {} must leave {:?} at the default Strict state", + raw, p, + ); + } + } +} + +#[test] +fn unknown_discriminant_does_not_corrupt_subsequent_valid_calls() { + // A bad call must not poison the builder — a following valid call + // must succeed normally. Catches a class of bug where the bad path + // leaks/double-frees the builder allocation. + let sandbox = build_via_ffi(|b| unsafe { + let b = sandlock_sandbox_builder_allow_degraded(b, 9999); + let b = sandlock_sandbox_builder_disable(b, u32::MAX); + sandlock_sandbox_builder_disable(b, PROT_SIGNAL_SCOPE) + }); + assert_eq!( + sandbox.protection_policy.state(Protection::SignalScope), + ProtectionState::Disabled, + "valid call after two invalid ones must still take effect", + ); +} diff --git a/docs/extension-handlers.md b/docs/extension-handlers.md index d494a03..af32ce7 100644 --- a/docs/extension-handlers.md +++ b/docs/extension-handlers.md @@ -653,6 +653,48 @@ an opaque `void*`; the responsibility is on the C side. See `crates/sandlock-ffi/tests/c/handler_smoke.c` for the canonical end-to-end example. +## Protection opt-out + +By default sandlock enforces every Landlock protection the host kernel +supports and refuses to start when a required protection is +unavailable. Two builder methods on `SandboxBuilder` let callers opt +out of the strict default on a per-protection basis: + +- `allow_degraded(Protection::P)` — enforce `P` where the host kernel + supports it, silently skip it where it does not. Use this when + deploying across a mixed fleet of kernels where some lack the + protection. +- `disable(Protection::P)` — never enforce `P`, even on a kernel that + supports it. Use this when the workload legitimately needs the + capability the protection blocks (for example signalling a sibling + process when `SignalScope` would otherwise prevent it). + +Calling neither method leaves the protection in its default `Strict` +state. The two methods are last-wins per protection: a later call for +the same `Protection` value supersedes the earlier one. + +`sandlock check` reports each protection's availability against the +host's Landlock ABI; `Sandbox::active_protections()` returns the +per-protection resolved status (`Active`, `Degraded`, `Disabled`, or +`Unavailable`) of a constructed `Sandbox`. + +Example: + +```rust +use sandlock_core::{Protection, Sandbox}; + +let sb = Sandbox::builder() + .fs_read("/data") + .fs_write("/tmp") + .allow_degraded(Protection::SignalScope) + .allow_degraded(Protection::AbstractUnixSocketScope) + .build()?; +``` + +The two `allow_degraded` calls let the sandbox build on Linux kernels +below 6.12, where the v6 IPC scopes are unavailable. On a kernel that +does support them, the scopes remain enforced. + ## Python wrapper See [`python-handlers.md`](python-handlers.md) — the dedicated page is the diff --git a/docs/python-handlers.md b/docs/python-handlers.md index c53cdb2..f3bdeac 100644 --- a/docs/python-handlers.md +++ b/docs/python-handlers.md @@ -270,6 +270,27 @@ syscall list rather than against an empty dispatch table. was actually dispatched — the supervisor handles cleanup on all paths. +## Protection opt-out + +The Python wrapper exposes the same opt-out mechanism as the Rust +builder, via two keyword arguments on `Sandbox`: + +```python +from sandlock import Sandbox, Protection + +sb = Sandbox( + fs_readable=["/data"], + fs_writable=["/tmp"], + allow_degraded=[Protection.SIGNAL_SCOPE, Protection.ABSTRACT_UNIX_SOCKET_SCOPE], +) +``` + +`disable=[Protection.X, ...]` is the parallel kwarg for protections +that should never be enforced even on capable kernels. See the +"Protection opt-out" section of +[`extension-handlers.md`](extension-handlers.md#protection-opt-out) for +the full semantics. + ## C ABI The Python wrapper sits on the C ABI declared in diff --git a/python/src/sandlock/__init__.py b/python/src/sandlock/__init__.py index fba0c52..9a73a61 100644 --- a/python/src/sandlock/__init__.py +++ b/python/src/sandlock/__init__.py @@ -9,6 +9,7 @@ from ._sdk import ( Stage, Pipeline, Result, SyscallEvent, PolicyContext, Checkpoint, NamedStage, Gather, GatherPipeline, + Protection, landlock_abi_version, min_landlock_abi, confine, ) from .inputs import inputs @@ -49,6 +50,7 @@ "parse_ports", "Change", "DryRunResult", + "Protection", # Handler ABI "Handler", "NotifAction", diff --git a/python/src/sandlock/_sdk.py b/python/src/sandlock/_sdk.py index 4c58038..7bb0834 100644 --- a/python/src/sandlock/_sdk.py +++ b/python/src/sandlock/_sdk.py @@ -8,6 +8,7 @@ import signal import sys from dataclasses import dataclass, field +from enum import IntEnum from pathlib import Path from typing import Any, Sequence @@ -109,6 +110,56 @@ def _builder_fn(name, *extra_args): _b_deterministic_dirs = _builder_fn("sandlock_sandbox_builder_deterministic_dirs", ctypes.c_bool) _b_cpu_cores = _builder_fn("sandlock_sandbox_builder_cpu_cores", ctypes.POINTER(ctypes.c_uint32), ctypes.c_uint32) +# Protection opt-out — mirror of the C ABI `sandlock_protection_t`. +# Discriminant values must stay in sync with `sandlock_core::Protection` +# and `sandlock_protection_t` in `crates/sandlock-ffi/include/sandlock.h`. +class Protection(IntEnum): + """Per-protection Landlock feature identifier. + + Pass values from this enum to ``Sandbox(allow_degraded=...)`` or + ``Sandbox(disable=...)`` to opt out of strict enforcement for the + named protection. See the C header for kernel ABI requirements. + """ + + FS_REFER = 0 + FS_TRUNCATE = 1 + NET_TCP = 2 + FS_IOCTL_DEV = 3 + SIGNAL_SCOPE = 4 + ABSTRACT_UNIX_SOCKET_SCOPE = 5 + + +_lib.sandlock_protection_min_abi.restype = ctypes.c_uint32 +_lib.sandlock_protection_min_abi.argtypes = [ctypes.c_uint32] + +# Move-semantics setters: each returns the (possibly relocated) builder +# pointer, mirroring the convention of the other `_builder_fn` setters. +# The C ABI accepts the protection as a `uint32_t` so an out-of-range +# value is rejected at the FFI boundary (no `#[repr(C)]` enum cast). +_b_allow_degraded = _builder_fn( + "sandlock_sandbox_builder_allow_degraded", ctypes.c_uint32 +) +_b_disable = _builder_fn( + "sandlock_sandbox_builder_disable", ctypes.c_uint32 +) + + +def _validate_protection(p: int, *, field: str) -> int: + """Coerce a caller-supplied protection value to a known discriminant + or raise :class:`ValueError`. Centralises the range check so the FFI + is never invoked with an unknown integer (the Rust setters silently + no-op on bad input, which is the wrong UX for the Python caller — + we want a loud failure at the SDK boundary instead). + """ + try: + return int(Protection(int(p))) + except (ValueError, TypeError) as e: + valid = ", ".join(f"{m.name}={int(m)}" for m in Protection) + raise ValueError( + f"{field}: {p!r} is not a known Protection discriminant " + f"(valid: {valid})" + ) from e + # Policy callback (policy_fn). # Path strings absent (issue #27 — path-based control belongs in Landlock). # argv is populated for execve only; TOCTOU-safe via sibling freeze. @@ -903,6 +954,8 @@ def __del__(self): "random_seed", "time_start", "clean_env", "env", "extra_deny_syscalls", "extra_allow_syscalls", "max_open_files", "no_randomize_memory", "no_huge_pages", "no_coredump", "deterministic_dirs", + # Landlock protection opt-out (see Protection IntEnum): + "allow_degraded", "disable", # Managed outside _build_from_policy: "notif_policy", # Runtime-only kwargs — not sent to FFI: @@ -1032,6 +1085,17 @@ def _build_from_policy(policy: PolicyDataclass): b = _b_no_coredump(b, True) if policy.deterministic_dirs: b = _b_deterministic_dirs(b, True) + + # Landlock protection opt-out. The C ABI setters use move-semantics + # and return the (possibly relocated) builder pointer — mirror that + # by rebinding `b` on each call. Idempotent / last-wins: if the + # same Protection appears in both lists, the later call wins + # (matching the underlying `ProtectionPolicy::set` semantics). + for p in (policy.allow_degraded or ()): + b = _b_allow_degraded(b, _validate_protection(p, field="allow_degraded")) + for p in (policy.disable or ()): + b = _b_disable(b, _validate_protection(p, field="disable")) + # Guard: warn if any dataclass field was set to a non-default value # but is not in _HANDLED_FIELDS (i.e. silently dropped). import dataclasses as _dc diff --git a/python/src/sandlock/sandbox.py b/python/src/sandlock/sandbox.py index cef1464..5fa3f35 100644 --- a/python/src/sandlock/sandbox.py +++ b/python/src/sandlock/sandbox.py @@ -338,6 +338,24 @@ class Sandbox: on_error: BranchAction = BranchAction.ABORT """Branch action on sandbox error/exception.""" + # Landlock protection opt-out — relax strict enforcement for the + # named protections. See ``sandlock.Protection`` (the IntEnum mirror + # of the C ABI ``sandlock_protection_t``). + allow_degraded: Sequence[int] = field(default_factory=list) + """Protections that may degrade silently on kernels that don't + support them. Each entry is a :class:`sandlock.Protection` value. + On a capable kernel the protection is still enforced strictly; on + an older kernel it is skipped instead of failing the build. + Idempotent / last-wins with :attr:`disable` (the later assignment + for a given protection wins).""" + + disable: Sequence[int] = field(default_factory=list) + """Protections that are never enforced, even on a host kernel that + supports them. Each entry is a :class:`sandlock.Protection` value. + Use this for a deliberate opt-out (e.g. to allow a workload to use + a protection-incompatible feature). Idempotent / last-wins with + :attr:`allow_degraded`.""" + # Runtime kwargs — not part of policy serialization. name: str | None = field(default=None, repr=False, metadata={"runtime": True}) """Sandbox name (also exposed as the virtual hostname inside the sandbox). diff --git a/python/tests/test_protection.py b/python/tests/test_protection.py new file mode 100644 index 0000000..4937d3b --- /dev/null +++ b/python/tests/test_protection.py @@ -0,0 +1,162 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Python wrapper tests for Protection allow_degraded / disable kwargs. + +Covers the Python parity for the C ABI added in sandlock-ffi (see +``crates/sandlock-ffi/include/sandlock.h`` — ``sandlock_protection_t``, +``sandlock_protection_min_abi``, ``sandlock_sandbox_builder_allow_degraded``, +``sandlock_sandbox_builder_disable``). +""" + +import sandlock +from sandlock import Sandbox, Protection + + +def test_protection_intenum_min_abi_via_ffi(): + """`sandlock_protection_min_abi` returns the kernel ABI requirement + for each protection — sanity-check the IntEnum discriminants match + the C ABI by calling through the FFI.""" + from sandlock._sdk import _lib + + # SIGNAL_SCOPE — Landlock ABI v6 + assert _lib.sandlock_protection_min_abi(int(Protection.SIGNAL_SCOPE)) == 6 + # FS_TRUNCATE — Landlock ABI v3 + assert _lib.sandlock_protection_min_abi(int(Protection.FS_TRUNCATE)) == 3 + + +def test_sandbox_allow_degraded_kwarg_default_empty(): + """Both kwargs default to an empty sequence when omitted.""" + sb = Sandbox(fs_readable=["/usr"]) + assert list(sb.allow_degraded) == [] + assert list(sb.disable) == [] + + +def test_sandbox_allow_degraded_kwarg_accepts_protections(): + """`allow_degraded` accepts a sequence of Protection values and + preserves the order/identity on the dataclass.""" + sb = Sandbox( + fs_readable=["/usr"], + allow_degraded=[ + Protection.SIGNAL_SCOPE, + Protection.ABSTRACT_UNIX_SOCKET_SCOPE, + ], + ) + assert list(sb.allow_degraded) == [ + Protection.SIGNAL_SCOPE, + Protection.ABSTRACT_UNIX_SOCKET_SCOPE, + ] + + +def test_sandbox_disable_kwarg_accepts_protections(): + """`disable` accepts a sequence of Protection values and preserves + them on the dataclass.""" + sb = Sandbox(fs_readable=["/usr"], disable=[Protection.SIGNAL_SCOPE]) + assert list(sb.disable) == [Protection.SIGNAL_SCOPE] + + +def test_protection_is_intenum_with_correct_values(): + """Protection discriminants mirror the C ABI ``sandlock_protection_t`` + layout exactly (stable across releases).""" + assert Protection.FS_REFER == 0 + assert Protection.FS_TRUNCATE == 1 + assert Protection.NET_TCP == 2 + assert Protection.FS_IOCTL_DEV == 3 + assert Protection.SIGNAL_SCOPE == 4 + assert Protection.ABSTRACT_UNIX_SOCKET_SCOPE == 5 + # IntEnum: values usable as plain ints (the FFI takes c_int). + assert int(Protection.SIGNAL_SCOPE) == 4 + assert isinstance(Protection.SIGNAL_SCOPE, int) + + +def test_protection_reexported_from_top_level_package(): + """`Protection` is re-exported by the top-level package so callers + can do ``from sandlock import Protection`` without reaching into + ``_sdk``.""" + assert sandlock.Protection is Protection + assert "Protection" in sandlock.__all__ + + +def test_sandbox_build_with_protection_kwargs_does_not_raise(): + """Building the native policy with non-empty Protection kwargs must + succeed: this exercises the ctypes bindings end-to-end (the + move-semantics `b = _b_allow_degraded(b, ...)` rebind in + `_build_from_policy`).""" + from sandlock._sdk import _NativePolicy + + sb = Sandbox( + fs_readable=["/usr"], + allow_degraded=[Protection.SIGNAL_SCOPE], + disable=[Protection.ABSTRACT_UNIX_SOCKET_SCOPE], + ) + # Should not raise — and the resulting native policy owns a live + # pointer that is freed by __del__. + native = _NativePolicy.from_dataclass(sb) + assert native.ptr is not None and native.ptr != 0 + + +def test_sandbox_build_with_idempotent_protection_kwargs(): + """Repeating the same Protection in `allow_degraded` (or across + both kwargs) must not raise — the C-side `ProtectionPolicy::set` + is last-wins, and the Python wrapper just forwards values.""" + from sandlock._sdk import _NativePolicy + + sb = Sandbox( + fs_readable=["/usr"], + allow_degraded=[Protection.SIGNAL_SCOPE, Protection.SIGNAL_SCOPE], + disable=[Protection.SIGNAL_SCOPE], + ) + native = _NativePolicy.from_dataclass(sb) + assert native.ptr is not None and native.ptr != 0 + + +# -------------------------------------------------------------- +# Out-of-range protection int — the SDK must raise `ValueError` +# before reaching the FFI. The Rust setters tolerate unknown +# discriminants as a no-op, but the Python contract is loud failure +# (silent no-op is the wrong UX when the caller typed a wrong int). +# -------------------------------------------------------------- + + +def test_sandbox_build_rejects_out_of_range_protection_int(): + """An integer outside the known `Protection` enum range raises + `ValueError` at build time — before reaching the FFI.""" + import pytest + + from sandlock._sdk import _NativePolicy + + sb = Sandbox(fs_readable=["/usr"], allow_degraded=[99]) + with pytest.raises(ValueError, match="allow_degraded"): + _NativePolicy.from_dataclass(sb) + + +def test_sandbox_build_rejects_out_of_range_in_disable(): + """Same guard applies to the `disable` kwarg.""" + import pytest + + from sandlock._sdk import _NativePolicy + + sb = Sandbox(fs_readable=["/usr"], disable=[100, 200]) + with pytest.raises(ValueError, match="disable"): + _NativePolicy.from_dataclass(sb) + + +def test_sandbox_build_rejects_negative_protection_int(): + """Negative ints are not valid Protection discriminants — must + raise rather than wrap to a large unsigned value at the FFI.""" + import pytest + + from sandlock._sdk import _NativePolicy + + sb = Sandbox(fs_readable=["/usr"], allow_degraded=[-1]) + with pytest.raises(ValueError): + _NativePolicy.from_dataclass(sb) + + +def test_sandbox_build_accepts_plain_int_in_valid_range(): + """Callers using plain `int` (not the `Protection` IntEnum) for + values in the valid range must still succeed — the validator + coerces through `Protection(int)`.""" + from sandlock._sdk import _NativePolicy + + sb = Sandbox(fs_readable=["/usr"], allow_degraded=[4]) # 4 == SIGNAL_SCOPE + native = _NativePolicy.from_dataclass(sb) + assert native.ptr is not None and native.ptr != 0