diff --git a/CHANGELOG.md b/CHANGELOG.md index f4b20ad..aab87c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ Categories Used: ## [Unreleased](https://github.com/ouch-org/ouch/compare/0.6.1...HEAD) +- Add landlock support for linux filesystem isolation [\#723](https://github.com/ouch-org/ouch/pull/723) ([valoq](https://github.com/valoq)) + ### New Features - Merge folders in decompression [\#798](https://github.com/ouch-org/ouch/pull/798) ([tommady](https://github.com/tommady)) diff --git a/Cargo.lock b/Cargo.lock index 93dce30..f254ff2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -284,7 +284,7 @@ dependencies = [ "byteorder", "bytesize", "libbzip3-sys", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -570,6 +570,26 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "errno" version = "0.3.10" @@ -728,7 +748,7 @@ dependencies = [ "libz-sys", "num_cpus", "snap", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -886,6 +906,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "landlock" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3d2ef408b88e913bfc6594f5e693d57676f6463ded7d8bf994175364320c706" +dependencies = [ + "enumflags2", + "libc", + "thiserror 2.0.12", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1123,6 +1154,7 @@ dependencies = [ "insta", "is_executable", "itertools", + "landlock", "libc", "liblzma", "linked-hash-map", @@ -1142,6 +1174,7 @@ dependencies = [ "tar", "tempfile", "test-strategy", + "thiserror 2.0.12", "time", "unrar", "zip", @@ -1725,7 +1758,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl 2.0.12", ] [[package]] @@ -1739,6 +1781,17 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "time" version = "0.3.41" diff --git a/Cargo.toml b/Cargo.toml index 1fe15af..9a57c50 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 105f004..c70eb15 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 0e6233b..8f9a76f 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 f5dc712..0f045fe 100644 --- a/src/commands/decompress.rs +++ b/src/commands/decompress.rs @@ -5,6 +5,7 @@ use std::{ }; use fs_err as fs; +//use crate::utils::landlock; #[cfg(not(feature = "bzip3"))] use crate::archive; @@ -18,7 +19,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 +40,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 +81,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 +179,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 +215,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 +249,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 +293,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 +330,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 +407,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 a2e7915..adfc5b4 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -4,13 +4,14 @@ use std::{ }; use fs_err as fs; +//use crate::utils::landlock; use crate::{ archive, 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 +23,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 @@ -107,7 +115,6 @@ pub fn list_archive_contents( #[cfg(feature = "unrar")] Rar => { if formats.len() > 1 { - let mut temp_file = tempfile::NamedTempFile::new()?; io::copy(&mut reader, &mut temp_file)?; Box::new(crate::archive::rar::list_archive(temp_file.path(), password)?) } else { diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 4a81d85..629e0f0 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/main.rs b/src/main.rs index 3f1f7dd..927e309 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,5 +43,25 @@ fn main() { fn run() -> Result<()> { let (args, skip_questions_positively, file_visibility_policy) = CliArgs::parse_and_validate_args()?; + + // Get the output dir if specified, else use current dir + //let working_dir = args.output_dir + // .clone() + // .unwrap_or_else(|| env::current_dir().unwrap_or_default()); + // restrict filesystem access to working_dir; + // 1. working_dir is either the output_dir specified by the -d option or + // 2. it is the temporary .tmp-ouch-XXXXXX directory that is renamed after decompression + // + //Case 1: Files are directly written to the output_directory, which may be created by ouch + // full landlock permissions granted inside the specified directory + //Case 2: Files are written to the .tmp-ouch directory, requiring make_dir permissions on the + // parent (cwd) for renaming and full permissions within the tmp-ouch directory itself + // + // Since either the specified output directory is created if it did not exist, or the .ouch-tmp + // directory is created in the current working directory, the parent directory of the target + // directory requires LANDLOCK_ACCESS_FS_MAKE_DIR + // expects either the .tmp-ouch-XXXXXX path or the specified output directory (-d option) + //utils::landlock::init_sandbox(&working_dir); + commands::run(args, skip_questions_positively, file_visibility_policy) } diff --git a/src/utils/landlock.rs b/src/utils/landlock.rs new file mode 100644 index 0000000..ffb4400 --- /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 444cf1f..126aa0f 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 0000000..0fa9d76 --- /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 0000000..5c7c357 --- /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 +}