use tokio::process::Command;
use crate::utils::LinuxCapability;
#[cfg(not(target_os = "linux"))]
use tracing::warn;
#[derive(Debug, Clone, Default)]
pub struct SecurityConfig {
pub drop_all_caps: bool,
pub keep_caps: Vec<LinuxCapability>,
pub no_new_privs: bool,
pub fail_on_cap_error: bool,
}
impl SecurityConfig {
#[inline]
pub fn is_empty(&self) -> bool {
!self.drop_all_caps && self.keep_caps.is_empty() && !self.no_new_privs
}
}
pub fn attach_security(cmd: &mut Command, config: &SecurityConfig) {
if config.is_empty() {
return;
}
#[cfg(target_os = "linux")]
{
linux_impl::attach(cmd, config);
}
#[cfg(not(target_os = "linux"))]
{
let _ = &cmd;
warn!(
?config,
"security configuration is only enforced on Linux; current OS={}: settings will be ignored",
std::env::consts::OS,
);
}
}
#[cfg(target_os = "linux")]
mod linux_impl {
use super::{KeepMask, SecurityConfig};
use crate::utils::log::{pre_exec_log, pre_exec_log_errno};
use std::io;
use tokio::process::Command;
const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522;
const PR_CAP_AMBIENT: libc::c_int = 47;
const PR_CAP_AMBIENT_RAISE: libc::c_ulong = 2;
const PR_CAP_AMBIENT_CLEAR_ALL: libc::c_ulong = 4;
const PR_SET_NO_NEW_PRIVS: libc::c_int = 38;
const CAP_LAST_CAP: u32 = 63;
pub fn attach(cmd: &mut Command, config: &SecurityConfig) {
let keep_mask = KeepMask::from_caps(&config.keep_caps);
let fail_on_cap_error = config.fail_on_cap_error;
let drop_all_caps = config.drop_all_caps;
let no_new_privs = config.no_new_privs;
unsafe {
cmd.pre_exec(move || {
if drop_all_caps
&& let Err(e) = drop_capabilities_batch(keep_mask)
&& fail_on_cap_error
{
return Err(e);
}
if no_new_privs {
apply_no_new_privs()?;
}
Ok(())
});
}
}
fn drop_capabilities_batch(keep_mask: KeepMask) -> io::Result<()> {
if let Err(e) = clear_ambient_caps() {
pre_exec_log(b"solti-exec: clear_ambient_caps failed: ");
if let Some(code) = e.raw_os_error() {
pre_exec_log_errno(code);
}
return Err(e);
}
let mut header = CapUserHeader {
version: LINUX_CAPABILITY_VERSION_3,
pid: 0,
};
let mut data = [CapUserData::default(); 2];
if unsafe { capget(&mut header, data.as_mut_ptr()) } != 0 {
let e = io::Error::last_os_error();
pre_exec_log(b"solti-exec: capget failed: ");
if let Some(code) = e.raw_os_error() {
pre_exec_log_errno(code);
}
return Err(e);
}
data[0].effective &= keep_mask.bits[0];
data[0].permitted &= keep_mask.bits[0];
data[0].inheritable &= keep_mask.bits[0];
data[1].effective &= keep_mask.bits[1];
data[1].permitted &= keep_mask.bits[1];
data[1].inheritable &= keep_mask.bits[1];
if unsafe { capset(&mut header, data.as_ptr()) } != 0 {
let e = io::Error::last_os_error();
pre_exec_log(b"solti-exec: capset failed: ");
if let Some(code) = e.raw_os_error() {
pre_exec_log_errno(code);
}
return Err(e);
}
for cap_value in 0..=CAP_LAST_CAP {
if keep_mask.is_set(cap_value) {
let _ = raise_ambient_cap(cap_value);
}
}
Ok(())
}
fn clear_ambient_caps() -> io::Result<()> {
let rc = unsafe { libc::prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_CLEAR_ALL, 0, 0, 0) };
if rc != 0 {
let err = io::Error::last_os_error();
if err.raw_os_error() != Some(libc::EINVAL) {
return Err(err);
}
}
Ok(())
}
fn raise_ambient_cap(cap: u32) -> io::Result<()> {
let rc = unsafe { libc::prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_RAISE, cap, 0, 0) };
if rc != 0 {
let err = io::Error::last_os_error();
match err.raw_os_error() {
Some(libc::EINVAL) | Some(libc::EPERM) => return Ok(()),
_ => return Err(err),
}
}
Ok(())
}
fn apply_no_new_privs() -> io::Result<()> {
let rc = unsafe { libc::prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) };
if rc != 0 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
#[repr(C)]
struct CapUserHeader {
version: u32,
pid: libc::c_int,
}
#[repr(C)]
#[derive(Default, Clone, Copy)]
struct CapUserData {
effective: u32,
permitted: u32,
inheritable: u32,
}
unsafe extern "C" {
fn capset(hdrp: *mut CapUserHeader, datap: *const CapUserData) -> libc::c_int;
fn capget(hdrp: *mut CapUserHeader, datap: *mut CapUserData) -> libc::c_int;
}
}
#[derive(Clone, Copy)]
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
struct KeepMask {
bits: [u32; 2],
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
impl KeepMask {
fn from_caps(caps: &[LinuxCapability]) -> Self {
let mut bits = [0u32; 2];
for cap in caps {
let v = cap.to_cap_value();
let idx = (v / 32) as usize;
if idx < 2 {
bits[idx] |= 1u32 << (v % 32);
}
}
Self { bits }
}
fn is_set(self, cap: u32) -> bool {
let idx = (cap / 32) as usize;
if idx >= 2 {
return false;
}
(self.bits[idx] & (1u32 << (cap % 32))) != 0
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::process::Command;
#[test]
fn empty_config_is_noop() {
let cfg = SecurityConfig::default();
assert!(cfg.is_empty());
let mut cmd = Command::new("sh");
attach_security(&mut cmd, &cfg);
}
#[cfg(target_os = "linux")]
#[test]
fn non_empty_config_attaches_pre_exec_hook_on_linux() {
let cfg = SecurityConfig {
drop_all_caps: true,
keep_caps: vec![LinuxCapability::NetAdmin, LinuxCapability::NetBindService],
no_new_privs: true,
..Default::default()
};
assert!(!cfg.is_empty());
let mut cmd = Command::new("sh");
attach_security(&mut cmd, &cfg);
}
#[cfg(not(target_os = "linux"))]
#[test]
fn non_empty_config_is_ignored_on_non_linux() {
let cfg = SecurityConfig {
drop_all_caps: true,
keep_caps: vec![LinuxCapability::NetAdmin],
no_new_privs: true,
..Default::default()
};
assert!(!cfg.is_empty());
let mut cmd = Command::new("sh");
attach_security(&mut cmd, &cfg);
}
#[test]
fn capability_names_are_correct() {
assert_eq!(LinuxCapability::NetAdmin.name(), "NET_ADMIN");
assert_eq!(LinuxCapability::SysAdmin.name(), "SYS_ADMIN");
assert_eq!(LinuxCapability::Chown.name(), "CHOWN");
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn no_new_privs_can_be_set_without_root() {
let cfg = SecurityConfig {
no_new_privs: true,
..Default::default()
};
let mut cmd = Command::new("true");
attach_security(&mut cmd, &cfg);
let result = cmd.status().await;
assert!(result.is_ok(), "no_new_privs should work without root");
assert!(result.unwrap().success());
}
#[test]
fn keep_mask_empty_caps_all_zero() {
let m = KeepMask::from_caps(&[]);
assert_eq!(m.bits, [0, 0]);
for cap in 0..=63 {
assert!(!m.is_set(cap), "cap {cap} should not be set");
}
}
#[test]
fn keep_mask_single_low_cap() {
let m = KeepMask::from_caps(&[LinuxCapability::Chown]);
assert!(m.is_set(0));
assert!(!m.is_set(1));
assert_eq!(m.bits[0], 1);
assert_eq!(m.bits[1], 0);
}
#[test]
fn keep_mask_cap_in_second_word() {
let m = KeepMask::from_caps(&[LinuxCapability::SetFCap, LinuxCapability::SysPtrace]);
assert!(m.is_set(31));
assert!(m.is_set(19));
assert!(!m.is_set(0));
assert_eq!(m.bits[1], 0)
}
#[test]
fn keep_mask_multiple_caps() {
let caps = [
LinuxCapability::Chown, LinuxCapability::NetBindService, LinuxCapability::NetAdmin, LinuxCapability::SysAdmin, ];
let m = KeepMask::from_caps(&caps);
assert!(m.is_set(0));
assert!(m.is_set(10));
assert!(m.is_set(12));
assert!(m.is_set(21));
assert!(!m.is_set(1));
assert!(!m.is_set(11));
assert!(!m.is_set(63));
}
#[test]
fn keep_mask_duplicate_caps_idempotent() {
let m1 = KeepMask::from_caps(&[LinuxCapability::Kill]);
let m2 = KeepMask::from_caps(&[LinuxCapability::Kill, LinuxCapability::Kill]);
assert_eq!(m1.bits, m2.bits);
}
#[test]
fn keep_mask_out_of_range_returns_false() {
let m = KeepMask::from_caps(&[LinuxCapability::Chown]);
assert!(!m.is_set(64));
assert!(!m.is_set(100));
assert!(!m.is_set(u32::MAX));
}
}