mirror of
https://github.com/openai/codex.git
synced 2026-11-12 21:20:34 +00:00
refactor(sandbox): extract Seatbelt denial collector
This commit is contained in:
Generated
-1
@@ -2350,7 +2350,6 @@ dependencies = [
|
||||
"owo-colors",
|
||||
"predicates",
|
||||
"pretty_assertions",
|
||||
"regex-lite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
|
||||
@@ -64,7 +64,6 @@ http = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
os_info = { workspace = true }
|
||||
owo-colors = { workspace = true }
|
||||
regex-lite = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
supports-color = { workspace = true }
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
mod pid_tracker;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod seatbelt;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
|
||||
@@ -23,6 +18,10 @@ use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_permission
|
||||
use codex_sandboxing::seatbelt::CreateSeatbeltCommandArgsParams;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_sandboxing::seatbelt::create_seatbelt_command_args;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_sandboxing::seatbelt_denials::DenialLogger;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_sandboxing::seatbelt_denials::SandboxDenial;
|
||||
use codex_sandboxing::with_managed_mitm_ca_readable_root;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_cli::CliConfigOverrides;
|
||||
@@ -35,9 +34,6 @@ use crate::SeatbeltCommand;
|
||||
use crate::WindowsCommand;
|
||||
use crate::exit_status::handle_exit_status;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use seatbelt::DenialLogger;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub async fn run_command_under_seatbelt(
|
||||
command: SeatbeltCommand,
|
||||
@@ -234,7 +230,11 @@ async fn run_command_under_sandbox(
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut denial_logger = log_denials.then(DenialLogger::new).flatten();
|
||||
let mut denial_logger = if log_denials {
|
||||
DenialLogger::new().await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = log_denials;
|
||||
|
||||
@@ -338,7 +338,7 @@ async fn run_command_under_sandbox(
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(denial_logger) = &mut denial_logger {
|
||||
denial_logger.on_child_spawn(&child);
|
||||
denial_logger.on_child_pid(child.id());
|
||||
}
|
||||
|
||||
let status = child.wait().await?;
|
||||
@@ -350,7 +350,7 @@ async fn run_command_under_sandbox(
|
||||
if denials.is_empty() {
|
||||
eprintln!("None found.");
|
||||
} else {
|
||||
for seatbelt::SandboxDenial { name, capability } in denials {
|
||||
for SandboxDenial { name, capability } in denials {
|
||||
eprintln!("({name}) {capability}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::process::Child;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use super::pid_tracker::PidTracker;
|
||||
|
||||
pub struct SandboxDenial {
|
||||
pub name: String,
|
||||
pub capability: String,
|
||||
}
|
||||
|
||||
pub struct DenialLogger {
|
||||
log_stream: Child,
|
||||
pid_tracker: Option<PidTracker>,
|
||||
log_reader: Option<JoinHandle<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl DenialLogger {
|
||||
pub(crate) fn new() -> Option<Self> {
|
||||
let mut log_stream = start_log_stream()?;
|
||||
let stdout = log_stream.stdout.take()?;
|
||||
let log_reader = tokio::spawn(async move {
|
||||
let mut reader = tokio::io::BufReader::new(stdout);
|
||||
let mut logs = Vec::new();
|
||||
let mut chunk = Vec::new();
|
||||
loop {
|
||||
match reader.read_until(b'\n', &mut chunk).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {
|
||||
logs.extend_from_slice(&chunk);
|
||||
chunk.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
logs
|
||||
});
|
||||
|
||||
Some(Self {
|
||||
log_stream,
|
||||
pid_tracker: None,
|
||||
log_reader: Some(log_reader),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn on_child_spawn(&mut self, child: &Child) {
|
||||
if let Some(root_pid) = child.id() {
|
||||
self.pid_tracker = PidTracker::new(root_pid as i32);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn finish(mut self) -> Vec<SandboxDenial> {
|
||||
let pid_set = match self.pid_tracker {
|
||||
Some(tracker) => tracker.stop().await,
|
||||
None => Default::default(),
|
||||
};
|
||||
|
||||
if pid_set.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let _ = self.log_stream.kill().await;
|
||||
let _ = self.log_stream.wait().await;
|
||||
|
||||
let logs_bytes = match self.log_reader.take() {
|
||||
Some(handle) => handle.await.unwrap_or_default(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let logs = String::from_utf8_lossy(&logs_bytes);
|
||||
|
||||
let mut seen: HashSet<(String, String)> = HashSet::new();
|
||||
let mut denials: Vec<SandboxDenial> = Vec::new();
|
||||
for line in logs.lines() {
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(line)
|
||||
&& let Some(msg) = json.get("eventMessage").and_then(|v| v.as_str())
|
||||
&& let Some((pid, name, capability)) = parse_message(msg)
|
||||
&& pid_set.contains(&pid)
|
||||
&& seen.insert((name.clone(), capability.clone()))
|
||||
{
|
||||
denials.push(SandboxDenial { name, capability });
|
||||
}
|
||||
}
|
||||
denials
|
||||
}
|
||||
}
|
||||
|
||||
fn start_log_stream() -> Option<Child> {
|
||||
use std::process::Stdio;
|
||||
|
||||
const PREDICATE: &str = r#"(((processID == 0) AND (senderImagePath CONTAINS "/Sandbox")) OR (subsystem == "com.apple.sandbox.reporting"))"#;
|
||||
|
||||
tokio::process::Command::new("log")
|
||||
.args(["stream", "--style", "ndjson", "--predicate", PREDICATE])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn parse_message(msg: &str) -> Option<(i32, String, String)> {
|
||||
// Example message:
|
||||
// Sandbox: processname(1234) deny(1) capability-name args...
|
||||
static RE: std::sync::OnceLock<regex_lite::Regex> = std::sync::OnceLock::new();
|
||||
let re = RE.get_or_init(|| {
|
||||
#[expect(clippy::unwrap_used)]
|
||||
regex_lite::Regex::new(r"^Sandbox:\s*(.+?)\((\d+)\)\s+deny\(.*?\)\s*(.+)$").unwrap()
|
||||
});
|
||||
|
||||
let (_, [name, pid_str, capability]) = re.captures(msg)?.extract();
|
||||
let pid = pid_str.trim().parse::<i32>().ok()?;
|
||||
Some((pid, name.to_string(), capability.to_string()))
|
||||
}
|
||||
@@ -21,6 +21,7 @@ libc = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
regex-lite = { workspace = true }
|
||||
tracing = { workspace = true, features = ["log"] }
|
||||
tokio = { workspace = true, features = ["io-util", "process", "rt", "time"] }
|
||||
url = { workspace = true }
|
||||
which = { workspace = true }
|
||||
|
||||
@@ -28,4 +29,4 @@ which = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||
tokio = { workspace = true, features = ["macros"] }
|
||||
|
||||
@@ -5,6 +5,7 @@ mod manager;
|
||||
pub mod policy_transforms;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod seatbelt;
|
||||
pub mod seatbelt_denials;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use bwrap::find_system_bwrap_in_path;
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::collections::HashSet;
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::collections::VecDeque;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tokio::process::Child;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use self::pid_tracker::PidTracker;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
const LOG_STREAM_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
#[cfg(target_os = "macos")]
|
||||
const LOG_STREAM_FLUSH_GRACE_PERIOD: std::time::Duration = std::time::Duration::from_millis(100);
|
||||
#[cfg(target_os = "macos")]
|
||||
const LOG_STREAM_READY_PREFIX: &str = "Filtering the log data using ";
|
||||
#[cfg(target_os = "macos")]
|
||||
const MAX_COLLECTED_LOG_CHARS: usize = 1_000;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod pid_tracker;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
/// A unique macOS Seatbelt sandbox denial emitted by a process.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SandboxDenial {
|
||||
pub name: String,
|
||||
pub capability: String,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct LoggedDenial {
|
||||
pid: i32,
|
||||
denial: SandboxDenial,
|
||||
chars: usize,
|
||||
}
|
||||
|
||||
/// Best-effort collector for macOS Seatbelt denials emitted by a process tree.
|
||||
pub struct DenialLogger {
|
||||
#[cfg(target_os = "macos")]
|
||||
log_stream: Child,
|
||||
#[cfg(target_os = "macos")]
|
||||
pid_tracker: Option<PidTracker>,
|
||||
#[cfg(target_os = "macos")]
|
||||
log_reader: JoinHandle<VecDeque<LoggedDenial>>,
|
||||
#[cfg(target_os = "macos")]
|
||||
tracked_pid_tx: std::sync::mpsc::Sender<i32>,
|
||||
}
|
||||
|
||||
impl DenialLogger {
|
||||
/// Starts collecting Seatbelt denial log messages.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub async fn new() -> Option<Self> {
|
||||
Self::new_with_limit(/*max_chars*/ None).await
|
||||
}
|
||||
|
||||
/// Starts collecting Seatbelt denials while retaining at most 1,000 characters.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub async fn new_bounded() -> Option<Self> {
|
||||
Self::new_with_limit(Some(MAX_COLLECTED_LOG_CHARS)).await
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
async fn new_with_limit(max_chars: Option<usize>) -> Option<Self> {
|
||||
let mut log_stream = start_log_stream()?;
|
||||
let stdout = log_stream.stdout.take()?;
|
||||
let stderr = log_stream.stderr.take()?;
|
||||
let (ready_tx, mut ready_rx) = tokio::sync::mpsc::channel(1);
|
||||
let (tracked_pid_tx, tracked_pid_rx) = std::sync::mpsc::channel();
|
||||
let log_reader = tokio::spawn(async move {
|
||||
let mut stdout = tokio::io::BufReader::new(stdout);
|
||||
let mut stderr = tokio::io::BufReader::new(stderr);
|
||||
let mut logged_denials = VecDeque::new();
|
||||
let mut tracked_pids = HashSet::new();
|
||||
let mut collected_chars = 0;
|
||||
let mut stdout_line = Vec::new();
|
||||
let mut stderr_line = String::new();
|
||||
let mut stdout_open = true;
|
||||
let mut stderr_open = true;
|
||||
while stdout_open || stderr_open {
|
||||
tokio::select! {
|
||||
result = stdout.read_until(b'\n', &mut stdout_line), if stdout_open => {
|
||||
stdout_open = result.is_ok_and(|read| read > 0);
|
||||
if stdout_line.starts_with(LOG_STREAM_READY_PREFIX.as_bytes()) {
|
||||
let _ = ready_tx.try_send(());
|
||||
} else if let Some(denial) = parse_log_line(&stdout_line) {
|
||||
tracked_pids.extend(tracked_pid_rx.try_iter());
|
||||
push_logged_denial(
|
||||
&mut logged_denials,
|
||||
&mut collected_chars,
|
||||
denial,
|
||||
max_chars,
|
||||
&tracked_pids,
|
||||
);
|
||||
}
|
||||
stdout_line.clear();
|
||||
}
|
||||
result = stderr.read_line(&mut stderr_line), if stderr_open => {
|
||||
stderr_open = result.is_ok_and(|read| read > 0);
|
||||
if stderr_line.starts_with(LOG_STREAM_READY_PREFIX) {
|
||||
let _ = ready_tx.try_send(());
|
||||
}
|
||||
stderr_line.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
logged_denials
|
||||
});
|
||||
|
||||
let ready = tokio::time::timeout(LOG_STREAM_READY_TIMEOUT, ready_rx.recv())
|
||||
.await
|
||||
.is_ok_and(|result| result.is_some());
|
||||
if !ready {
|
||||
let _ = log_stream.kill().await;
|
||||
let _ = log_stream.wait().await;
|
||||
log_reader.abort();
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
log_stream,
|
||||
pid_tracker: None,
|
||||
log_reader,
|
||||
tracked_pid_tx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns no logger on platforms without macOS Seatbelt.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub async fn new() -> Option<Self> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns no logger on platforms without macOS Seatbelt.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub async fn new_bounded() -> Option<Self> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Starts tracking the process tree rooted at `child_pid`.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn on_child_pid(&mut self, child_pid: Option<u32>) {
|
||||
if let Some(root_pid) = child_pid {
|
||||
self.pid_tracker = PidTracker::new(root_pid as i32, self.tracked_pid_tx.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Does nothing on platforms without macOS Seatbelt.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn on_child_pid(&mut self, child_pid: Option<u32>) {
|
||||
let _ = child_pid;
|
||||
}
|
||||
|
||||
/// Stops collection and returns unique denials from the tracked process tree.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub async fn finish(mut self) -> Vec<SandboxDenial> {
|
||||
let pid_set = match self.pid_tracker {
|
||||
Some(tracker) => tracker.stop().await,
|
||||
None => Default::default(),
|
||||
};
|
||||
|
||||
if !pid_set.is_empty() {
|
||||
tokio::time::sleep(LOG_STREAM_FLUSH_GRACE_PERIOD).await;
|
||||
}
|
||||
let _ = self.log_stream.kill().await;
|
||||
let _ = self.log_stream.wait().await;
|
||||
let logged_denials = self.log_reader.await.unwrap_or_default();
|
||||
if pid_set.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut seen: HashSet<(String, String)> = HashSet::new();
|
||||
let mut denials: Vec<SandboxDenial> = Vec::new();
|
||||
for LoggedDenial { pid, denial, .. } in logged_denials {
|
||||
if pid_set.contains(&pid)
|
||||
&& seen.insert((denial.name.clone(), denial.capability.clone()))
|
||||
{
|
||||
denials.push(denial);
|
||||
}
|
||||
}
|
||||
denials
|
||||
}
|
||||
|
||||
/// Returns no denials on platforms without macOS Seatbelt.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub async fn finish(self) -> Vec<SandboxDenial> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn push_logged_denial(
|
||||
logged_denials: &mut VecDeque<LoggedDenial>,
|
||||
collected_chars: &mut usize,
|
||||
logged_denial: LoggedDenial,
|
||||
max_chars: Option<usize>,
|
||||
tracked_pids: &HashSet<i32>,
|
||||
) {
|
||||
let denial_chars = logged_denial.chars;
|
||||
if let Some(max_chars) = max_chars {
|
||||
if denial_chars > max_chars {
|
||||
return;
|
||||
}
|
||||
logged_denials.push_back(logged_denial);
|
||||
*collected_chars += denial_chars;
|
||||
|
||||
while *collected_chars > max_chars {
|
||||
let removal_index = logged_denials
|
||||
.iter()
|
||||
.position(|logged_denial| !tracked_pids.contains(&logged_denial.pid))
|
||||
.unwrap_or(0);
|
||||
let Some(removed) = logged_denials.remove(removal_index) else {
|
||||
break;
|
||||
};
|
||||
*collected_chars -= removed.chars;
|
||||
}
|
||||
return;
|
||||
}
|
||||
logged_denials.push_back(logged_denial);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn parse_log_line(line: &[u8]) -> Option<LoggedDenial> {
|
||||
let json = serde_json::from_slice::<serde_json::Value>(line).ok()?;
|
||||
let msg = json.get("eventMessage")?.as_str()?;
|
||||
let (pid, name, capability) = parse_message(msg)?;
|
||||
let chars = name.chars().count() + capability.chars().count();
|
||||
Some(LoggedDenial {
|
||||
pid,
|
||||
denial: SandboxDenial { name, capability },
|
||||
chars,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn start_log_stream() -> Option<Child> {
|
||||
use std::process::Stdio;
|
||||
|
||||
const PREDICATE: &str = r#"(((processID == 0) AND (senderImagePath CONTAINS "/Sandbox")) OR (subsystem == "com.apple.sandbox.reporting"))"#;
|
||||
|
||||
tokio::process::Command::new("log")
|
||||
.args(["stream", "--style", "ndjson", "--predicate", PREDICATE])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn parse_message(msg: &str) -> Option<(i32, String, String)> {
|
||||
// Example message:
|
||||
// Sandbox: processname(1234) deny(1) capability-name args...
|
||||
static RE: std::sync::OnceLock<regex_lite::Regex> = std::sync::OnceLock::new();
|
||||
let re = RE.get_or_init(|| {
|
||||
#[expect(clippy::unwrap_used)]
|
||||
regex_lite::Regex::new(r"^Sandbox:\s*(.+?)\((\d+)\)\s+deny\(.*?\)\s*(.+)$").unwrap()
|
||||
});
|
||||
|
||||
let (_, [name, pid_str, capability]) = re.captures(msg)?.extract();
|
||||
let pid = pid_str.trim().parse::<i32>().ok()?;
|
||||
Some((pid, name.to_string(), capability.to_string()))
|
||||
}
|
||||
|
||||
/// Formats denials for appending to command output.
|
||||
pub fn format_sandbox_denials(denials: &[SandboxDenial]) -> Option<Vec<u8>> {
|
||||
if denials.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut formatted = String::from("\n=== Sandbox denials ===\n");
|
||||
for SandboxDenial { name, capability } in denials {
|
||||
formatted.push_str(&format!("({name}) {capability}\n"));
|
||||
}
|
||||
Some(formatted.into_bytes())
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
#[path = "seatbelt_denials_tests.rs"]
|
||||
mod tests;
|
||||
+43
-44
@@ -1,4 +1,7 @@
|
||||
//! Descendant process tracking for macOS Seatbelt denial collection.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::mpsc::Sender;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::warn;
|
||||
|
||||
@@ -10,13 +13,15 @@ pub(crate) struct PidTracker {
|
||||
}
|
||||
|
||||
impl PidTracker {
|
||||
pub(crate) fn new(root_pid: i32) -> Option<Self> {
|
||||
pub(crate) fn new(root_pid: i32, tracked_pid_tx: Sender<i32>) -> Option<Self> {
|
||||
if root_pid <= 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let _ = tracked_pid_tx.send(root_pid);
|
||||
let kq = unsafe { libc::kqueue() };
|
||||
let handle = tokio::task::spawn_blocking(move || track_descendants(kq, root_pid));
|
||||
let handle =
|
||||
tokio::task::spawn_blocking(move || track_descendants(kq, root_pid, tracked_pid_tx));
|
||||
|
||||
Some(Self { kq, handle })
|
||||
}
|
||||
@@ -112,19 +117,29 @@ fn watch_children(
|
||||
parent: i32,
|
||||
seen: &mut HashSet<i32>,
|
||||
active: &mut HashSet<i32>,
|
||||
tracked_pid_tx: &Sender<i32>,
|
||||
) {
|
||||
for child_pid in list_child_pids(parent) {
|
||||
add_pid_watch(kq, child_pid, seen, active);
|
||||
add_pid_watch(kq, child_pid, seen, active, tracked_pid_tx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Watch `pid` and its children, updating `seen` and `active` sets.
|
||||
fn add_pid_watch(kq: libc::c_int, pid: i32, seen: &mut HashSet<i32>, active: &mut HashSet<i32>) {
|
||||
fn add_pid_watch(
|
||||
kq: libc::c_int,
|
||||
pid: i32,
|
||||
seen: &mut HashSet<i32>,
|
||||
active: &mut HashSet<i32>,
|
||||
tracked_pid_tx: &Sender<i32>,
|
||||
) {
|
||||
if pid <= 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let newly_seen = seen.insert(pid);
|
||||
if newly_seen {
|
||||
let _ = tracked_pid_tx.send(pid);
|
||||
}
|
||||
let mut should_recurse = newly_seen;
|
||||
|
||||
if active.insert(pid) {
|
||||
@@ -145,7 +160,7 @@ fn add_pid_watch(kq: libc::c_int, pid: i32, seen: &mut HashSet<i32>, active: &mu
|
||||
}
|
||||
|
||||
if should_recurse {
|
||||
watch_children(kq, pid, seen, active);
|
||||
watch_children(kq, pid, seen, active, tracked_pid_tx);
|
||||
}
|
||||
}
|
||||
const STOP_IDENT: libc::uintptr_t = 1;
|
||||
@@ -182,7 +197,7 @@ fn trigger_stop_event(kq: libc::c_int) {
|
||||
}
|
||||
|
||||
/// Put all of the above together to track all the descendants of `root_pid`.
|
||||
fn track_descendants(kq: libc::c_int, root_pid: i32) -> HashSet<i32> {
|
||||
fn track_descendants(kq: libc::c_int, root_pid: i32, tracked_pid_tx: Sender<i32>) -> HashSet<i32> {
|
||||
if kq < 0 {
|
||||
let mut seen = HashSet::new();
|
||||
seen.insert(root_pid);
|
||||
@@ -199,7 +214,7 @@ fn track_descendants(kq: libc::c_int, root_pid: i32) -> HashSet<i32> {
|
||||
let mut seen: HashSet<i32> = HashSet::new();
|
||||
let mut active: HashSet<i32> = HashSet::new();
|
||||
|
||||
add_pid_watch(kq, root_pid, &mut seen, &mut active);
|
||||
add_pid_watch(kq, root_pid, &mut seen, &mut active, &tracked_pid_tx);
|
||||
|
||||
const EVENTS_CAP: usize = 32;
|
||||
let mut events: [libc::kevent; EVENTS_CAP] =
|
||||
@@ -211,7 +226,7 @@ fn track_descendants(kq: libc::c_int, root_pid: i32) -> HashSet<i32> {
|
||||
if !pid_is_alive(root_pid) {
|
||||
break;
|
||||
}
|
||||
add_pid_watch(kq, root_pid, &mut seen, &mut active);
|
||||
add_pid_watch(kq, root_pid, &mut seen, &mut active, &tracked_pid_tx);
|
||||
if active.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -256,7 +271,7 @@ fn track_descendants(kq: libc::c_int, root_pid: i32) -> HashSet<i32> {
|
||||
}
|
||||
|
||||
if (ev.fflags & libc::NOTE_FORK) != 0 {
|
||||
watch_children(kq, pid, &mut seen, &mut active);
|
||||
watch_children(kq, pid, &mut seen, &mut active, &tracked_pid_tx);
|
||||
}
|
||||
|
||||
if (ev.fflags & libc::NOTE_EXIT) != 0 {
|
||||
@@ -281,43 +296,12 @@ mod tests {
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn pid_is_alive_detects_current_process() {
|
||||
let pid = std::process::id() as i32;
|
||||
assert!(pid_is_alive(pid));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn list_child_pids_includes_spawned_child() {
|
||||
let mut child = Command::new("/bin/sleep")
|
||||
.arg("5")
|
||||
.stdin(Stdio::null())
|
||||
.spawn()
|
||||
.expect("failed to spawn child process");
|
||||
|
||||
let child_pid = child.id() as i32;
|
||||
let parent_pid = std::process::id() as i32;
|
||||
|
||||
let mut found = false;
|
||||
for _ in 0..100 {
|
||||
if list_child_pids(parent_pid).contains(&child_pid) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
|
||||
assert!(found, "expected to find child pid {child_pid} in list");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn pid_tracker_collects_spawned_children() {
|
||||
let tracker = PidTracker::new(std::process::id() as i32).expect("failed to create tracker");
|
||||
let (tracked_pid_tx, tracked_pid_rx) = std::sync::mpsc::channel();
|
||||
let tracker = PidTracker::new(std::process::id() as i32, tracked_pid_tx)
|
||||
.expect("failed to create tracker");
|
||||
|
||||
let mut child = Command::new("/bin/sleep")
|
||||
.arg("0.1")
|
||||
@@ -328,6 +312,15 @@ mod tests {
|
||||
let child_pid = child.id() as i32;
|
||||
let parent_pid = std::process::id() as i32;
|
||||
|
||||
let mut child_was_published = false;
|
||||
for _ in 0..100 {
|
||||
if tracked_pid_rx.try_iter().any(|pid| pid == child_pid) {
|
||||
child_was_published = true;
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
|
||||
let _ = child.wait();
|
||||
|
||||
let seen = tracker.stop().await;
|
||||
@@ -340,12 +333,18 @@ mod tests {
|
||||
seen.contains(&child_pid),
|
||||
"expected tracker to include child pid {child_pid}"
|
||||
);
|
||||
assert!(
|
||||
child_was_published,
|
||||
"expected tracker to publish child pid {child_pid} before stop"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn pid_tracker_collects_bash_subshell_descendants() {
|
||||
let tracker = PidTracker::new(std::process::id() as i32).expect("failed to create tracker");
|
||||
let (tracked_pid_tx, _tracked_pid_rx) = std::sync::mpsc::channel();
|
||||
let tracker = PidTracker::new(std::process::id() as i32, tracked_pid_tx)
|
||||
.expect("failed to create tracker");
|
||||
|
||||
let child = Command::new("/bin/bash")
|
||||
.arg("-c")
|
||||
@@ -0,0 +1,101 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn logged_denial(pid: i32, name: String) -> LoggedDenial {
|
||||
let chars = name.chars().count();
|
||||
LoggedDenial {
|
||||
pid,
|
||||
denial: SandboxDenial {
|
||||
name,
|
||||
capability: String::new(),
|
||||
},
|
||||
chars,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collected_logs_are_capped_at_one_thousand_characters() {
|
||||
let mut logged_denials = VecDeque::new();
|
||||
let mut collected_chars = 0;
|
||||
let old_denial = logged_denial(/*pid*/ 1, "a".repeat(600));
|
||||
let recent_denial = logged_denial(/*pid*/ 2, "é".repeat(500));
|
||||
let tracked_pids = HashSet::from([1, 2]);
|
||||
|
||||
push_logged_denial(
|
||||
&mut logged_denials,
|
||||
&mut collected_chars,
|
||||
old_denial,
|
||||
Some(MAX_COLLECTED_LOG_CHARS),
|
||||
&tracked_pids,
|
||||
);
|
||||
push_logged_denial(
|
||||
&mut logged_denials,
|
||||
&mut collected_chars,
|
||||
recent_denial.clone(),
|
||||
Some(MAX_COLLECTED_LOG_CHARS),
|
||||
&tracked_pids,
|
||||
);
|
||||
|
||||
assert_eq!(logged_denials, VecDeque::from([recent_denial]));
|
||||
assert_eq!(collected_chars, 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_denials_outlive_unrelated_noise_after_descendant_discovery() {
|
||||
let mut logged_denials = VecDeque::new();
|
||||
let mut collected_chars = 0;
|
||||
let root_denial = logged_denial(/*pid*/ 1, "a".repeat(400));
|
||||
let descendant_denial = logged_denial(/*pid*/ 2, "b".repeat(400));
|
||||
let unrelated_denial = logged_denial(/*pid*/ 3, "c".repeat(300));
|
||||
let mut tracked_pids = HashSet::from([1]);
|
||||
|
||||
push_logged_denial(
|
||||
&mut logged_denials,
|
||||
&mut collected_chars,
|
||||
root_denial.clone(),
|
||||
Some(MAX_COLLECTED_LOG_CHARS),
|
||||
&tracked_pids,
|
||||
);
|
||||
push_logged_denial(
|
||||
&mut logged_denials,
|
||||
&mut collected_chars,
|
||||
descendant_denial.clone(),
|
||||
Some(MAX_COLLECTED_LOG_CHARS),
|
||||
&tracked_pids,
|
||||
);
|
||||
tracked_pids.insert(2);
|
||||
push_logged_denial(
|
||||
&mut logged_denials,
|
||||
&mut collected_chars,
|
||||
unrelated_denial,
|
||||
Some(MAX_COLLECTED_LOG_CHARS),
|
||||
&tracked_pids,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
logged_denials,
|
||||
VecDeque::from([root_denial, descendant_denial])
|
||||
);
|
||||
assert_eq!(collected_chars, 800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_denial_from_large_log_record() {
|
||||
let line = serde_json::to_vec(&serde_json::json!({
|
||||
"eventMessage": "Sandbox: touch(1234) deny(1) file-write-create /private/tmp/nope",
|
||||
"metadata": "x".repeat(2_000),
|
||||
}))
|
||||
.expect("valid log record");
|
||||
|
||||
assert_eq!(
|
||||
parse_log_line(&line),
|
||||
Some(LoggedDenial {
|
||||
pid: 1234,
|
||||
denial: SandboxDenial {
|
||||
name: "touch".to_string(),
|
||||
capability: "file-write-create /private/tmp/nope".to_string(),
|
||||
},
|
||||
chars: "touch".chars().count() + "file-write-create /private/tmp/nope".chars().count(),
|
||||
})
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user