diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 0095e68e7c..3890d3a7a1 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -154,6 +154,10 @@ fn qualify_runtime() -> Result<(openshell_sandbox::RuntimeQualification, Qualifi // broker is intentionally nondumpable, so that fallback is inaccessible // even though /proc//mem remains available to mediation. let task_memory_copy = true; + // Check the ABI first: on an older kernel the allow/deny probe would fail + // with an opaque ruleset error instead of naming the ABI it needs. + let landlock_abi = + openshell_sandbox::sandbox::linux::require_landlock_abi().wrap_err("Landlock ABI probe")?; probe_landlock_allow_deny().wrap_err("Landlock allow/deny probe")?; let notification = openshell_sandbox::linux::seccomp_notify::probe_notification_api() .into_diagnostic() @@ -163,14 +167,6 @@ fn qualify_runtime() -> Result<(openshell_sandbox::RuntimeQualification, Qualifi .into_diagnostic() .wrap_err("socket loopback confinement probe")?; probe_dns_relay_bind().wrap_err("DNS relay bind probe")?; - let landlock_abi = openshell_sandbox::linux::landlock::abi_version() - .into_diagnostic() - .wrap_err("Landlock ABI probe")?; - if landlock_abi < 3 { - return Err(miette::miette!( - "sandbox self-protection requires Landlock ABI v3 or newer (including truncation), found v{landlock_abi}" - )); - } let groups = nix::unistd::getgroups() .into_diagnostic()? diff --git a/crates/openshell-sandbox/src/sandbox/linux/landlock.rs b/crates/openshell-sandbox/src/sandbox/linux/landlock.rs index d8dad148af..9931321bd7 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/landlock.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/landlock.rs @@ -54,38 +54,38 @@ impl std::fmt::Display for LandlockAvailability { /// This is safe to call from the parent process and does not create any file /// descriptors or modify process state. pub fn probe_availability() -> LandlockAvailability { - // landlock_create_ruleset syscall number (same on x86_64 and aarch64). - const SYS_LANDLOCK_CREATE_RULESET: libc::c_long = 444; - // Flag: return the highest supported ABI version instead of creating a ruleset. - const LANDLOCK_CREATE_RULESET_VERSION: libc::c_uint = 1 << 0; - - // SAFETY: landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION) - // is a read-only probe that returns the ABI version or an error code. - // It does not allocate file descriptors or modify process state. - #[allow(unsafe_code)] - let ret = unsafe { - libc::syscall( - SYS_LANDLOCK_CREATE_RULESET, - std::ptr::null::(), - 0_usize, - LANDLOCK_CREATE_RULESET_VERSION, - ) - }; - - if ret >= 0 { - #[allow(clippy::cast_possible_truncation)] - LandlockAvailability::Available { abi: ret as i32 } - } else { - let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0); - match errno { + match crate::linux::landlock::abi_version() { + Ok(abi) => LandlockAvailability::Available { + abi: i32::try_from(abi).unwrap_or(i32::MAX), + }, + Err(error) => match error.raw_os_error().unwrap_or(0) { libc::ENOSYS => LandlockAvailability::NotImplemented, libc::EOPNOTSUPP => LandlockAvailability::NotEnabled, libc::EPERM => LandlockAvailability::Blocked, other => LandlockAvailability::Unknown(other), - } + }, } } +/// Lowest Landlock ABI the mandatory baseline accepts (matches [`ABI::V3`]). +/// ABI v3, introduced in Linux 6.2, adds pathname truncation. +const REQUIRED_ABI: i32 = 3; + +fn check_abi(availability: &LandlockAvailability) -> Result { + match availability { + LandlockAvailability::Available { abi } if *abi >= REQUIRED_ABI => Ok(abi.unsigned_abs()), + _ => Err(miette::miette!( + "Landlock ABI v{REQUIRED_ABI} or newer (Linux 6.2+) is required, but Landlock is {availability}" + )), + } +} + +/// Probe the kernel and fail, naming the required and detected ABI, unless it +/// supports the mandatory baseline. Returns the detected ABI. +pub fn require_abi() -> Result { + check_abi(&probe_availability()) +} + /// A prepared Landlock ruleset ready to be enforced via `restrict_self()`. /// /// Path FDs are opened before enforcement. The capability-free launch path @@ -141,6 +141,7 @@ pub fn prepare_capability_free_baseline() -> Result { fn prepare_capability_free_baseline_at(root: &Path) -> Result { // Unlike optional filesystem policy, self-protection must cover pathname // truncation as well as opens. Never silently downgrade this ABI requirement. + require_abi()?; let abi = ABI::V3; let access = AccessFs::from_all(abi); let mut ruleset = Ruleset::default() @@ -922,4 +923,35 @@ mod tests { "probe_availability Debug should produce output" ); } + + #[test] + fn check_abi_accepts_the_required_abi_or_newer() { + for abi in [REQUIRED_ABI, REQUIRED_ABI + 4] { + let detected = check_abi(&LandlockAvailability::Available { abi }).unwrap(); + assert_eq!(detected, abi.unsigned_abs()); + } + } + + #[test] + fn check_abi_names_the_required_and_detected_abi() { + let err = check_abi(&LandlockAvailability::Available { abi: 2 }) + .unwrap_err() + .to_string(); + assert!(err.contains("ABI v3 or newer"), "{err}"); + assert!(err.contains("ABI v2"), "{err}"); + } + + #[test] + fn check_abi_rejects_unavailable_landlock() { + for availability in [ + LandlockAvailability::NotImplemented, + LandlockAvailability::NotEnabled, + LandlockAvailability::Blocked, + LandlockAvailability::Unknown(libc::EIO), + ] { + let err = check_abi(&availability).unwrap_err().to_string(); + assert!(err.contains("ABI v3 or newer"), "{err}"); + assert!(err.contains(&availability.to_string()), "{err}"); + } + } } diff --git a/crates/openshell-sandbox/src/sandbox/linux/mod.rs b/crates/openshell-sandbox/src/sandbox/linux/mod.rs index a6f5817da7..ef68c81f5c 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/mod.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/mod.rs @@ -11,6 +11,8 @@ use openshell_core::policy::SandboxPolicy; use std::path::PathBuf; use std::sync::Once; +pub use landlock::require_abi as require_landlock_abi; + /// Opaque handle to a prepared-but-not-yet-enforced sandbox. /// Holds the Landlock ruleset with `PathFds` opened before child exec. pub struct PreparedSandbox { diff --git a/docs/how-it-works/policies/schema.mdx b/docs/how-it-works/policies/schema.mdx index db85e3a19d..693efb209c 100644 --- a/docs/how-it-works/policies/schema.mdx +++ b/docs/how-it-works/policies/schema.mdx @@ -77,9 +77,10 @@ fails to enforce the policy's rules: | `best_effort` | The sandbox runs without the filesystem rules and logs a high-severity finding. | | `hard_requirement` | The sandbox fails to start. | -In either mode, a sandbox requires a kernel with Landlock ABI v3 or later. The -sandbox log's `Landlock ruleset built` event reports how many paths were applied -and skipped. +In either mode, a sandbox requires a kernel with Landlock ABI v3 or later. On an +older kernel, startup fails with an error that names the required and detected +ABI. The sandbox log's `Landlock ruleset built` event reports how many paths +were applied and skipped. ## Process