Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ clap = { version = "4.4", features = ["derive"] }
clap_complete = "4.4"
landlock = "0.4.3"
landlockconfig = { git = "https://github.com/landlock-lsm/landlockconfig", rev = "8b6b59b339181f9fa1ec6f7889564ba154c1a47d" }
lddtree = "0.3.8"
libc = "0.2"
serde = { version = "1.0", features = ["derive"] }
tempfile = "3.8"
thiserror = "2.0"
toml = "0.8"
which = "8.0.0"
62 changes: 62 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ use std::{
};
use thiserror::Error;

#[derive(Debug, Deserialize, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "kebab-case")]
pub enum NoDependency {
Elf,
}

pub const ISLAND_DEFAULT_CONFIG_BASE_NAME: &str = "island-default-base.toml";
pub const ISLAND_DEFAULT_CONFIG_BASE_CONTENT: &str =
include_str!("../assets/landlock/island-default-base.toml");
Expand Down Expand Up @@ -158,6 +164,7 @@ struct ProfileConfig {
#[serde(rename = "context")]
contexts: Option<Vec<TomlContextEntry>>,
env: Option<Vec<Env>>,
no_dependency: Option<Vec<NoDependency>>,
workspace: Option<bool>,
}

Expand All @@ -170,9 +177,21 @@ struct TomlContextEntry {
pub struct Profile {
pub contexts: ContextSet,
pub env_vars: BTreeSet<Env>,
pub no_dependency: BTreeSet<NoDependency>,
pub workspace: bool,
}

pub fn merge_no_dependency<'a, I>(profiles: I) -> BTreeSet<NoDependency>
where
I: IntoIterator<Item = &'a Profile>,
{
let mut merged = BTreeSet::<NoDependency>::new();
for profile in profiles {
merged.extend(profile.no_dependency.iter().copied());
}
merged
}

// Profile names should be trusted, but let's enforce some basic sanity checks
// to avoid usability issues.
pub fn is_profile_name_valid<S>(name: S) -> bool
Expand Down Expand Up @@ -316,6 +335,10 @@ impl IslandConfig {

profile.env_vars.extend(cfg.env.unwrap_or_default());

profile
.no_dependency
.extend(cfg.no_dependency.unwrap_or_default());

profile.workspace = cfg.workspace.unwrap_or(true);

Ok(profile)
Expand Down Expand Up @@ -551,6 +574,45 @@ when_beneath = "/home/user/projects/work1"
create_test_config_with_profiles([("empty", "")]);
}

#[test]
fn test_parse_no_dependency_elf() {
let config = create_test_config_with_profiles([(
"p",
r#"
no_dependency = ["elf"]
"#,
)]);

let profile = config.profiles.get("p").unwrap();
assert!(profile.no_dependency.contains(&NoDependency::Elf));
}

#[test]
fn test_parse_no_dependency_invalid_value() {
let config = IslandConfig::default();
let err = config
.parse_profile_config(
r#"
no_dependency = ["not-a-real-option"]
"#,
"p",
|p| Ok(p.to_path_buf()),
)
.unwrap_err();

assert!(matches!(err, ConfigError::TomlParse(_)));
}

#[test]
fn test_merge_no_dependency_unions_options() {
let mut p1 = Profile::default();
p1.no_dependency.insert(NoDependency::Elf);
let p2 = Profile::default();

let merged = merge_no_dependency([&p1, &p2]);
assert_eq!(merged, BTreeSet::from([NoDependency::Elf]));
}

#[test]
fn test_resolve_profiles_map_error() {
let config = create_test_config();
Expand Down
123 changes: 123 additions & 0 deletions src/elf.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT

use crate::{IslandError, Verbose};
use lddtree::{DependencyAnalyzer, DependencyTree};
use std::path::{Path, PathBuf};

fn lddtree_collect_extra_library_paths(tree: &DependencyTree) -> Vec<PathBuf> {
let mut paths = std::collections::BTreeSet::<PathBuf>::new();

// Binary-level RPATH/RUNPATH.
for p in tree.runpath.iter().chain(tree.rpath.iter()) {
let pb = PathBuf::from(p);
if pb.is_absolute() {
paths.insert(pb);
}
}

for lib in tree.libraries.values() {
// Library-level RPATH/RUNPATH.
for p in lib.runpath.iter().chain(lib.rpath.iter()) {
let pb = PathBuf::from(p);
if pb.is_absolute() {
paths.insert(pb);
}
}

// Also add the directory of resolved libraries.
if let Some(realpath) = lib.realpath.as_ref() {
if let Some(parent) = realpath.parent() {
paths.insert(parent.to_path_buf());
}
} else if lib.path.is_absolute() {
if let Some(parent) = lib.path.parent() {
paths.insert(parent.to_path_buf());
}
}
}

paths.into_iter().collect()
}

fn resolve_dependency_tree(path: &Path) -> Result<DependencyTree, IslandError> {
// lddtree (crate) searches using the binary's RUNPATH/RPATH, but some ecosystems
// (notably Nix) rely heavily on library-level RUNPATH to find second-order deps.
// Work around this by iteratively re-analyzing with additional search paths
// derived from already-resolved libraries.
const MAX_PASSES: usize = 3;

/* Root is set to / to resolve dependencies globally. */
let mut tree = DependencyAnalyzer::new("/".into()).analyze(path)?;
let mut extra_paths: Vec<PathBuf> = Vec::new();

for _ in 0..MAX_PASSES {
if !tree.libraries.values().any(|lib| {
lib.realpath.is_none() && !lib.path.is_absolute() && !lib.path.as_os_str().is_empty()
}) {
break;
}

let newly_discovered = lddtree_collect_extra_library_paths(&tree);
let mut combined = std::collections::BTreeSet::<PathBuf>::new();
combined.extend(extra_paths.into_iter());
combined.extend(newly_discovered.into_iter());
extra_paths = combined.into_iter().collect();

let next_tree = DependencyAnalyzer::new("/".into())
.library_paths(extra_paths.clone())
.analyze(path)?;
// Stop early if we didn't make progress.
if next_tree
.libraries
.values()
.filter(|lib| lib.realpath.is_some())
.count()
<= tree
.libraries
.values()
.filter(|lib| lib.realpath.is_some())
.count()
{
tree = next_tree;
break;
}
tree = next_tree;
}

Ok(tree)
}

pub fn resolve_command_dependency_paths(
command_path: PathBuf,
disable: bool,
verbose: &Verbose,
) -> Result<Vec<PathBuf>, IslandError> {
if disable {
verbose.print(|| {
"Skipping ELF dependency resolution (no_dependency includes \"elf\"); no automatic allow rules will be added".to_string()
});
return Ok(Vec::new());
}

let mut lddtree_paths: Vec<PathBuf> = vec![command_path.clone()];

let dep_tree = resolve_dependency_tree(&command_path)?;
for (library_name, library_object) in &dep_tree.libraries {
// Use realpath if available (canonical resolved path), otherwise fall back to path.
// When a library isn't found, lddtree sets realpath to None and path to just the
// library name (not a full path).
if let Some(realpath) = &library_object.realpath {
lddtree_paths.push(realpath.clone());
} else if library_object.path.is_absolute() {
lddtree_paths.push(library_object.path.clone());
} else {
eprintln!(
"Warning: could not resolve library path for {}: {}",
library_name,
library_object.path.display()
);
}
}

Ok(lddtree_paths)
}
44 changes: 42 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
use clap_complete::generate;
use landlock::RulesetError;
use landlock::{path_beneath_rules, AccessFs, RulesetCreatedAttr, RulesetError};
use landlockconfig::{BuildRulesetError, ParseDirectoryError, ResolveError, ResolvedConfig};
use std::{
collections::BTreeMap,
Expand All @@ -16,10 +16,12 @@ use std::{
use thiserror::Error;

mod config;
use config::{is_profile_name_valid, ConfigError, IslandConfig, ResolvedProfile};
use config::{is_profile_name_valid, merge_no_dependency, ConfigError, IslandConfig, NoDependency, ResolvedProfile};

mod context;

mod elf;

mod lock;

mod workspace;
Expand Down Expand Up @@ -212,6 +214,12 @@ enum IslandError {

#[error(transparent)]
Ruleset(#[from] RulesetError),

#[error(transparent)]
LddTree(#[from] lddtree::Error),

#[error(transparent)]
WhichError(#[from] which::Error),
}

fn run(
Expand Down Expand Up @@ -257,6 +265,27 @@ fn run(
let workspace_manager =
last_profile.workspace_manager(island_config, verbose, |s| env::var(s))?;

let merged_no_dependency = merge_no_dependency(resolved_profiles.iter().map(|p| p.profile));
let disable_elf_dependency_resolution = merged_no_dependency.contains(&NoDependency::Elf);

// Resolve and allow shared library dependencies.
let absolute_command_path = if Path::new(&command_args[0]).is_absolute() {
PathBuf::from(&command_args[0])
} else {
which::which(&command_args[0])?
};
let command_path = try_canonicalize(absolute_command_path)?;
verbose.print(|| format!("Resolved command path: {}", command_path.display()));

let lddtree_paths = elf::resolve_command_dependency_paths(
command_path,
disable_elf_dependency_resolution,
verbose,
)?;
for path in &lddtree_paths {
verbose.print(|| format!("Allowing dependency path: {}", path.display()));
}

// Apply each profile's restrictions in order (broadest scope first).
for resolved_profile in resolved_profiles {
let (mut ruleset, rule_errors) = resolved_profile.config.build_ruleset()?;
Expand All @@ -270,6 +299,17 @@ fn run(
// child rulesets can't grant it either.
ruleset = workspace_manager.update_ruleset(ruleset, verbose)?;

// Add lddtree library paths to allow executing the command and its dependencies.
// If no_dependency disables ELF dependency resolution, this list is empty and we
// intentionally do not add any auto-allow rules.
if !lddtree_paths.is_empty() {
let lddtree_rules = path_beneath_rules(
lddtree_paths.iter().cloned(),
AccessFs::ReadFile | AccessFs::Execute,
);
ruleset = ruleset.add_rules(lddtree_rules)?;
}

// TODO: Do not rely on the kernel to enforce nested sandboxing (limited to 16 layers).
ruleset.restrict_self()?;

Expand Down