From 0652a626f27a1264a1166b08d6b0f44a2e34c328 Mon Sep 17 00:00:00 2001 From: valoq Date: Mon, 21 Jul 2025 12:04:42 +0200 Subject: [PATCH] add landlock support --- CHANGELOG.md | 1 + Cargo.toml | 2 + src/cli/args.rs | 25 +++++++ src/commands/compress.rs | 1 + src/commands/decompress.rs | 24 ++++++- src/commands/list.rs | 9 ++- src/commands/mod.rs | 9 ++- src/utils/landlock.rs | 114 ++++++++++++++++++++++++++++++++ src/utils/mod.rs | 1 + src/utils/src_utils_landlock.rs | 109 ++++++++++++++++++++++++++++++ tests/landlock.rs | 8 +++ 11 files changed, 299 insertions(+), 4 deletions(-) create mode 100644 src/utils/landlock.rs create mode 100644 src/utils/src_utils_landlock.rs create mode 100644 tests/landlock.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f4b20ade4..3fd5be74e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Categories Used: - Provide Nushell completions (packages still need to install them) [\#827](https://github.com/ouch-org/ouch/pull/827) ([FrancescElies](https://github.com/FrancescElies)) - Support `.lz` decompression [\#838](https://github.com/ouch-org/ouch/pull/838) ([zzzsyyy](https://github.com/zzzsyyy)) - Support `.lzma` decompression (and fix `.lzma` being a wrong alias for `.xz`) [\#838](https://github.com/ouch-org/ouch/pull/838) ([zzzsyyy](https://github.com/zzzsyyy)) +- Add landlock support for linux filesystem isolation [\#723](https://github.com/ouch-org/ouch/pull/723) ([valoq](https://github.com/valoq)) ### Improvements diff --git a/Cargo.toml b/Cargo.toml index 1fe15af25..9a57c508b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ gzp = { version = "0.11.3", default-features = false, features = [ "snappy_default", ] } ignore = "0.4.23" +landlock = "0.4.2" libc = "0.2.155" linked-hash-map = "0.5.6" lz4_flex = "0.11.3" @@ -39,6 +40,7 @@ sevenz-rust2 = { version = "0.13.1", features = ["compress", "aes256"] } snap = "1.1.1" tar = "0.4.42" tempfile = "3.10.1" +thiserror = "2.0.12" time = { version = "0.3.36", default-features = false } unrar = { version = "0.5.7", optional = true } liblzma = "0.4" diff --git a/src/cli/args.rs b/src/cli/args.rs index 105f004be..c70eb150d 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -49,6 +49,10 @@ pub struct CliArgs { #[arg(short = 'c', long, global = true)] pub threads: Option, + /// Disable the sandbox feature + #[arg(long, global = true)] + pub disable_sandbox: bool, + // Ouch and claps subcommands #[command(subcommand)] pub cmd: Subcommand, @@ -85,6 +89,10 @@ pub enum Subcommand { /// Archive target files instead of storing symlinks (supported by `tar` and `zip`) #[arg(long, short = 'S')] follow_symlinks: bool, + + /// Mark sandbox as disabled + #[arg(long, global = true)] + disable_sandbox: bool, }, /// Decompresses one or more files, optionally into another folder #[command(visible_alias = "d")] @@ -104,6 +112,10 @@ pub enum Subcommand { /// Disable Smart Unpack #[arg(long)] no_smart_unpack: bool, + + /// Mark sandbox as disabled + #[arg(long, global = true)] + disable_sandbox: bool, }, /// List contents of an archive #[command(visible_aliases = ["l", "ls"])] @@ -115,6 +127,10 @@ pub enum Subcommand { /// Show archive contents as a tree #[arg(short, long)] tree: bool, + + /// Mark sandbox as disabled + #[arg(long, global = true)] + disable_sandbox: bool, }, } @@ -155,12 +171,14 @@ mod tests { // This is usually replaced in assertion tests password: None, threads: None, + disable_sandbox: false, cmd: Subcommand::Decompress { // Put a crazy value here so no test can assert it unintentionally files: vec!["\x00\x11\x22".into()], output_dir: None, remove: false, no_smart_unpack: false, + disable_sandbox: false, }, } } @@ -175,6 +193,7 @@ mod tests { output_dir: None, remove: false, no_smart_unpack: false, + disable_sandbox: false, }, ..mock_cli_args() } @@ -187,6 +206,7 @@ mod tests { output_dir: None, remove: false, no_smart_unpack: false, + disable_sandbox: false, }, ..mock_cli_args() } @@ -199,6 +219,7 @@ mod tests { output_dir: None, remove: false, no_smart_unpack: false, + disable_sandbox: false, }, ..mock_cli_args() } @@ -214,6 +235,7 @@ mod tests { fast: false, slow: false, follow_symlinks: false, + disable_sandbox: false, }, ..mock_cli_args() } @@ -228,6 +250,7 @@ mod tests { fast: false, slow: false, follow_symlinks: false, + disable_sandbox: false, }, ..mock_cli_args() } @@ -242,6 +265,7 @@ mod tests { fast: false, slow: false, follow_symlinks: false, + disable_sandbox: false, }, ..mock_cli_args() } @@ -267,6 +291,7 @@ mod tests { fast: false, slow: false, follow_symlinks: false, + disable_sandbox: false, }, format: Some("tar.gz".into()), ..mock_cli_args() diff --git a/src/commands/compress.rs b/src/commands/compress.rs index 0e6233bdd..8f9a76f49 100644 --- a/src/commands/compress.rs +++ b/src/commands/compress.rs @@ -35,6 +35,7 @@ pub fn compress_files( question_policy: QuestionPolicy, file_visibility_policy: FileVisibilityPolicy, level: Option, + disable_sandbox: bool, ) -> crate::Result { // If the input files contain a directory, then the total size will be underestimated let file_writer = BufWriter::with_capacity(BUFFER_CAPACITY, output_file); diff --git a/src/commands/decompress.rs b/src/commands/decompress.rs index f5dc71256..3e02a9bee 100644 --- a/src/commands/decompress.rs +++ b/src/commands/decompress.rs @@ -18,7 +18,7 @@ use crate::{ utils::{ self, io::lock_and_flush_output_stdio, - is_path_stdin, + is_path_stdin, landlock, logger::{info, info_accessible}, nice_directory_display, user_wants_to_continue, }, @@ -39,6 +39,7 @@ pub struct DecompressOptions<'a> { pub quiet: bool, pub password: Option<&'a [u8]>, pub remove: bool, + pub disable_sandbox: bool, } /// Decompress a file @@ -79,6 +80,7 @@ pub fn decompress_file(options: DecompressOptions) -> crate::Result<()> { options.question_policy, options.is_output_dir_provided, options.is_smart_unpack, + options.disable_sandbox, )? { files } else { @@ -176,6 +178,7 @@ pub fn decompress_file(options: DecompressOptions) -> crate::Result<()> { options.question_policy, options.is_output_dir_provided, options.is_smart_unpack, + options.disable_sandbox, )? { files } else { @@ -211,6 +214,7 @@ pub fn decompress_file(options: DecompressOptions) -> crate::Result<()> { options.question_policy, options.is_output_dir_provided, options.is_smart_unpack, + options.disable_sandbox, )? { files } else { @@ -244,6 +248,7 @@ pub fn decompress_file(options: DecompressOptions) -> crate::Result<()> { options.question_policy, options.is_output_dir_provided, options.is_smart_unpack, + options.disable_sandbox, )? { files } else { @@ -287,6 +292,7 @@ pub fn decompress_file(options: DecompressOptions) -> crate::Result<()> { options.question_policy, options.is_output_dir_provided, options.is_smart_unpack, + options.disable_sandbox, )? { files } else { @@ -323,7 +329,20 @@ fn execute_decompression( question_policy: QuestionPolicy, is_output_dir_provided: bool, is_smart_unpack: bool, + disable_sandbox: bool, ) -> crate::Result> { + // init landlock sandbox to restrict file system write access to output_dir + // The output directory iseither specified with the -d option or the current working directory is used + // TODO: restrict acess to the current working directory to allow only creating new files + // TODO: move to unpack and smart_unpack to cover the differetn dirctories used for + // decompression + //if !input_is_stdin && options.remove { + //permit write access to input_file_path + //} else { + //} + + landlock::init_sandbox(&[output_dir], disable_sandbox); + if is_smart_unpack { return smart_unpack(unpack_fn, output_dir, output_file_path, question_policy); } @@ -387,6 +406,9 @@ fn smart_unpack( nice_directory_display(temp_dir_path) )); + //first attempt to restict to the tmp file and allow only to rename it in the parent + //landlock::init_sandbox(Some(temp_dir_path)); + let files = unpack_fn(temp_dir_path)?; let root_contains_only_one_element = fs::read_dir(temp_dir_path)?.take(2).count() == 1; diff --git a/src/commands/list.rs b/src/commands/list.rs index a2e7915a0..3c959bded 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -10,7 +10,7 @@ use crate::{ commands::warn_user_about_loading_zip_in_memory, extension::CompressionFormat::{self, *}, list::{self, FileInArchive, ListOptions}, - utils::{io::lock_and_flush_output_stdio, user_wants_to_continue}, + utils::{io::lock_and_flush_output_stdio, landlock, user_wants_to_continue}, QuestionAction, QuestionPolicy, BUFFER_CAPACITY, }; @@ -22,7 +22,14 @@ pub fn list_archive_contents( list_options: ListOptions, question_policy: QuestionPolicy, password: Option<&[u8]>, + disable_sandbox: bool, ) -> crate::Result<()> { + //rar uses a temporary file which needs to be defined early to be permitted in landlock + let mut temp_file = tempfile::NamedTempFile::new()?; + + // Initialize landlock sandbox with write access restricted to /tmp/ as required by some formats + landlock::init_sandbox(&[temp_file.path()], disable_sandbox); + let reader = fs::File::open(archive_path)?; // Zip archives are special, because they require io::Seek, so it requires it's logic separated diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 4a81d851d..629e0f0a2 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -69,6 +69,7 @@ pub fn run( fast, slow, follow_symlinks, + disable_sandbox, } => { // After cleaning, if there are no input files left, exit if files.is_empty() { @@ -116,6 +117,7 @@ pub fn run( question_policy, file_visibility_policy, level, + args.disable_sandbox, ); if let Ok(true) = compress_result { @@ -151,6 +153,7 @@ pub fn run( output_dir, remove, no_smart_unpack, + disable_sandbox, } => { let mut output_paths = vec![]; let mut formats = vec![]; @@ -216,10 +219,12 @@ pub fn run( <[u8] as ByteSlice>::from_os_str(str).expect("convert password to bytes failed") }), remove, + disable_sandbox: args.disable_sandbox, }) }) } - Subcommand::List { archives: files, tree } => { + // check again if we need to provide disable_sandbox as argument here + Subcommand::List { archives: files, tree, disable_sandbox} => { let mut formats = vec![]; if let Some(format) = args.format { @@ -257,9 +262,9 @@ pub fn run( args.password .as_deref() .map(|str| <[u8] as ByteSlice>::from_os_str(str).expect("convert password to bytes failed")), + args.disable_sandbox, )?; } - Ok(()) } } diff --git a/src/utils/landlock.rs b/src/utils/landlock.rs new file mode 100644 index 000000000..ffb4400ba --- /dev/null +++ b/src/utils/landlock.rs @@ -0,0 +1,114 @@ +// Landlock support and generic Landlock sandbox implementation. +// https://landlock.io/rust-landlock/landlock/struct.Ruleset.html + +use std::path::Path; + +use landlock::{ + Access, AccessFs, PathBeneath, PathFd, PathFdError, RestrictionStatus, Ruleset, + RulesetAttr, RulesetCreatedAttr, RulesetError, ABI, +}; +use thiserror::Error; + +/// The status code returned from `ouch` on error +pub const EXIT_FAILURE: i32 = libc::EXIT_FAILURE; + +/// Returns true if Landlock is supported by the running kernel (Linux kernel >= 5.19). +#[cfg(target_os = "linux")] +pub fn is_landlock_supported() -> bool { + use std::process::Command; + + if let Ok(output) = Command::new("uname").arg("-r").output() { + if let Ok(version_str) = String::from_utf8(output.stdout) { + // Version string is expected to be in "5.19.0-foo" or similar + let mut parts = version_str.trim().split('.'); + if let (Some(major), Some(minor)) = (parts.next(), parts.next()) { + if let (Ok(major), Ok(minor)) = (major.parse::(), minor.parse::()) { + return (major > 5) || (major == 5 && minor >= 19); + } + } + } + } + false +} + +#[cfg(not(target_os = "linux"))] +pub fn is_landlock_supported() -> bool { + false +} + +#[derive(Debug, Error)] +pub enum MyRestrictError { + #[error(transparent)] + Ruleset(#[from] RulesetError), + #[error(transparent)] + AddRule(#[from] PathFdError), +} + +/// Restricts the process to only access the given hierarchies using Landlock, if supported. +/// +/// The Landlock ABI is set to v2 for compatibility with Linux 5.19+. +/// All hierarchies are given full access, but root ("/") is read-only. +fn restrict_paths(hierarchies: &[&str]) -> Result { + // The Landlock ABI should be incremented (and tested) regularly. + // ABI set to 2 in compatibility with linux 5.19 and higher + let abi = ABI::V2; + let access_all = AccessFs::from_all(abi); + let access_read = AccessFs::from_read(abi); + + let mut ruleset = Ruleset::default() + .handle_access(access_all)? + .create()? + // Read-only access to / (entire filesystem). + .add_rules(landlock::path_beneath_rules(&["/"], access_read))?; + + // Add write permissions to specified directory of provided + if !hierarchies.is_empty() { + ruleset = ruleset.add_rules( + hierarchies + .iter() + .map::, _>(|p| { + Ok(PathBeneath::new(PathFd::new(p)?, access_all)) + }), + )?; + } + + Ok(ruleset.restrict_self()?) +} + +/// Restricts the process to only access the given hierarchies using Landlock, if supported. +/// Accepts multiple allowed directories as &[&Path]. +pub fn init_sandbox(allowed_dirs: &[&Path], disable_sandbox: bool) { + // if std::env::var("CI").is_ok() { + // return; + // } + if disable_sandbox { + println!("Sandbox feature disabled via --no-sandbox flag."); + // warn!("Security Process isolation disabled"); + return; + } + + if is_landlock_supported() { + let paths: Vec<&str> = allowed_dirs + .iter() + .map(|p| p.to_str().expect("Cannot convert path")) + .collect(); + + let status = if !paths.is_empty() { + restrict_paths(&paths) + } else { + restrict_paths(&[]) + }; + + match status { + Ok(_status) => { + //check + } + Err(_e) => { + //log warning + std::process::exit(EXIT_FAILURE); + } + } + } else { + // warn!("Landlock is NOT supported on this platform or kernel (<5.19)."); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 444cf1f13..126aa0fe7 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -8,6 +8,7 @@ mod file_visibility; mod formatting; mod fs; pub mod io; +pub mod landlock; pub mod logger; mod question; diff --git a/src/utils/src_utils_landlock.rs b/src/utils/src_utils_landlock.rs new file mode 100644 index 000000000..0fa9d76e0 --- /dev/null +++ b/src/utils/src_utils_landlock.rs @@ -0,0 +1,109 @@ +// Landlock support and generic Landlock sandbox implementation. +// https://landlock.io/rust-landlock/landlock/struct.Ruleset.html + +use landlock::{ + Access, AccessFs, PathBeneath, PathFd, PathFdError, RestrictionStatus, Ruleset, + RulesetAttr, RulesetCreatedAttr, RulesetError, ABI, +}; +use thiserror::Error; + +use std::path::Path; + +/// The status code returned from `ouch` on error +pub const EXIT_FAILURE: i32 = libc::EXIT_FAILURE; + +/// Returns true if Landlock is supported by the running kernel (Linux kernel >= 5.19). +#[cfg(target_os = "linux")] +pub fn is_landlock_supported() -> bool { + use std::process::Command; + + if let Ok(output) = Command::new("uname").arg("-r").output() { + if let Ok(version_str) = String::from_utf8(output.stdout) { + // Version string is expected to be in "5.19.0-foo" or similar + let mut parts = version_str.trim().split('.'); + if let (Some(major), Some(minor)) = (parts.next(), parts.next()) { + if let (Ok(major), Ok(minor)) = (major.parse::(), minor.parse::()) { + return (major > 5) || (major == 5 && minor >= 19); + } + } + } + } + false +} + +#[cfg(not(target_os = "linux"))] +pub fn is_landlock_supported() -> bool { + false +} + +#[derive(Debug, Error)] +pub enum MyRestrictError { + #[error(transparent)] + Ruleset(#[from] RulesetError), + #[error(transparent)] + AddRule(#[from] PathFdError), +} + +/// Restricts the process to only access the given hierarchies using Landlock, if supported. +/// +/// The Landlock ABI is set to v2 for compatibility with Linux 5.19+. +/// All hierarchies are given full access, but root ("/") is read-only. +fn restrict_paths(hierarchies: &[&str]) -> Result { + // The Landlock ABI should be incremented (and tested) regularly. + // ABI set to 2 in compatibility with linux 5.19 and higher + let abi = ABI::V2; + let access_all = AccessFs::from_all(abi); + let access_read = AccessFs::from_read(abi); + + let mut ruleset = Ruleset::default() + .handle_access(access_all)? + .create()? + // Read-only access to / (entire filesystem). + .add_rules(landlock::path_beneath_rules(&["/"], access_read))?; + + // Add write permissions to specified directory of provided + if !hierarchies.is_empty() { + ruleset = ruleset.add_rules( + hierarchies + .iter() + .map::, _>(|p| { + Ok(PathBeneath::new(PathFd::new(p)?, access_all)) + }), + )?; + } + + Ok(ruleset.restrict_self()?) +} + +/// Restricts the process to only access the given hierarchies using Landlock, if supported. +/// Accepts multiple allowed directories as &[&Path]. +pub fn init_sandbox(allowed_dirs: &[&Path]) { + // if std::env::var("CI").is_ok() { + // return; + // } + + if is_landlock_supported() { + let paths: Vec<&str> = allowed_dirs + .iter() + .map(|p| p.to_str().expect("Cannot convert path")) + .collect(); + + let status = if !paths.is_empty() { + restrict_paths(&paths) + } else { + restrict_paths(&[]) + }; + + match status { + Ok(_status) => { + // check + } + Err(_e) => { + // log warning + std::process::exit(EXIT_FAILURE); + } + } + } else { + // warn!("Landlock is NOT supported on this platform or kernel (<5.19)."); + } +} \ No newline at end of file diff --git a/tests/landlock.rs b/tests/landlock.rs new file mode 100644 index 000000000..5c7c35776 --- /dev/null +++ b/tests/landlock.rs @@ -0,0 +1,8 @@ +#[test] +fn test_landlock_restriction() { + if !cfg!(target_os = "linux") { + eprintln!("Skipping Landlock test: not running on Linux."); + return; + } + // TODO: Add test +}