#![cfg_attr(target_os = "linux", allow(dead_code))]
use std::collections::HashMap;
use std::fmt;
use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::SystemTime;
use procage_policy::{DenyReason, HardDenyClass};
pub(crate) const CAP: usize = 10_000;
pub(crate) const RECEIVER_BOUND: usize = 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Violation {
pub pid: u32,
pub process: String,
pub operation: String,
pub target: Option<String>,
pub denied_by: DeniedBy,
pub received: SystemTime,
pub repeats: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum DeniedBy {
NotGranted,
HardDeny(HardDenyClass),
Protect,
Hide,
Layer,
Lifetime,
OlderKernel,
Unknown(String),
}
impl DeniedBy {
fn of(code: Option<&str>) -> DeniedBy {
let Some(code) = code else {
return DeniedBy::NotGranted;
};
match DenyReason::from_code(code) {
Some(reason) => DeniedBy::from_reason(Some(reason)),
None => DeniedBy::Unknown(code.to_string()),
}
}
pub(crate) fn from_reason(reason: Option<DenyReason>) -> DeniedBy {
match reason {
None => DeniedBy::NotGranted,
Some(DenyReason::HardDeny(class)) => DeniedBy::HardDeny(class),
Some(DenyReason::Protect) => DeniedBy::Protect,
Some(DenyReason::Hide) => DeniedBy::Hide,
Some(DenyReason::Layer) => DeniedBy::Layer,
Some(DenyReason::Lifetime) => DeniedBy::Lifetime,
Some(DenyReason::OlderKernel) => DeniedBy::OlderKernel,
Some(other) => DeniedBy::Unknown(other.code()),
}
}
}
impl fmt::Display for DeniedBy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DeniedBy::NotGranted => f.write_str("not granted by the policy"),
DeniedBy::HardDeny(class) => write!(f, "denied by the {class} hard-deny class"),
DeniedBy::Protect => f.write_str("the path is protected (read-only)"),
DeniedBy::Hide => f.write_str("the path is hidden"),
DeniedBy::Layer => f.write_str("denied by a rule of the policy's raw layer"),
DeniedBy::Lifetime => f.write_str(
"refused so that descendants stay in the run (Lifetime::Run; Lifetime::Tree allows it)",
),
DeniedBy::OlderKernel => f.write_str(
"refused because this kernel's Landlock cannot confine truncation (below ABI 3)",
),
DeniedBy::Unknown(code) => write!(f, "denied ({code})"),
}
}
}
impl fmt::Display for Violation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} (pid {}) was denied {}",
self.process, self.pid, self.operation
)?;
if let Some(target) = &self.target {
write!(f, " {target}")?;
}
write!(f, ": {}", self.denied_by)?;
if self.repeats > 1 {
write!(f, " (×{})", self.repeats)?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Coverage {
Off,
Observed,
SystemCalls,
Unavailable(String),
NotObserved,
}
impl fmt::Display for Coverage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Coverage::Off => f.write_str("reporting is off for this cage"),
Coverage::Observed => f.write_str("denials are reported"),
Coverage::SystemCalls => {
f.write_str("refused system calls are reported; file and network denials are not")
}
Coverage::Unavailable(reason) => write!(f, "reporting is unavailable: {reason}"),
Coverage::NotObserved => f.write_str("this platform does not report denials"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Violations {
list: Vec<Violation>,
complete: bool,
coverage: Coverage,
dropped: u64,
}
impl Violations {
pub(crate) fn empty(coverage: Coverage) -> Violations {
Violations {
list: Vec::new(),
complete: false,
coverage,
dropped: 0,
}
}
pub fn iter(&self) -> std::slice::Iter<'_, Violation> {
self.list.iter()
}
pub fn len(&self) -> usize {
self.list.len()
}
pub fn is_empty(&self) -> bool {
self.list.is_empty()
}
pub fn complete(&self) -> bool {
self.complete
}
pub fn coverage(&self) -> &Coverage {
&self.coverage
}
pub fn dropped(&self) -> u64 {
self.dropped
}
}
impl IntoIterator for Violations {
type Item = Violation;
type IntoIter = std::vec::IntoIter<Violation>;
fn into_iter(self) -> Self::IntoIter {
self.list.into_iter()
}
}
impl<'a> IntoIterator for &'a Violations {
type Item = &'a Violation;
type IntoIter = std::slice::Iter<'a, Violation>;
fn into_iter(self) -> Self::IntoIter {
self.list.iter()
}
}
#[derive(Debug, PartialEq, Eq, Hash)]
struct Key {
pid: u32,
process: String,
operation: String,
target: Option<String>,
denied_by: DeniedBy,
}
impl Key {
fn of(v: &Violation) -> Key {
Key {
pid: v.pid,
process: v.process.clone(),
operation: v.operation.clone(),
target: v.target.clone(),
denied_by: v.denied_by.clone(),
}
}
}
pub(crate) type Sink = Arc<Mutex<Record>>;
pub(crate) fn lock(sink: &Sink) -> MutexGuard<'_, Record> {
sink.lock().unwrap_or_else(|e| e.into_inner())
}
#[derive(Debug)]
pub(crate) struct Record {
violations: Violations,
index: HashMap<Key, usize>,
cap: usize,
live: Option<SyncSender<Violation>>,
receiver: Option<Receiver<Violation>>,
}
impl Record {
pub(crate) fn new(coverage: Coverage) -> Record {
Record::with_cap(coverage, CAP)
}
fn with_cap(coverage: Coverage, cap: usize) -> Record {
let (live, receiver) = sync_channel(RECEIVER_BOUND);
Record {
violations: Violations::empty(coverage),
index: HashMap::new(),
cap,
live: Some(live),
receiver: Some(receiver),
}
}
pub(crate) fn push(&mut self, v: Violation) {
let key = Key::of(&v);
if let Some(&i) = self.index.get(&key) {
let kept = &mut self.violations.list[i];
kept.repeats = kept.repeats.saturating_add(v.repeats);
return;
}
if let Some(live) = &self.live {
match live.try_send(v.clone()) {
Ok(()) | Err(TrySendError::Full(_)) => {}
Err(TrySendError::Disconnected(_)) => self.live = None,
}
}
if self.violations.list.len() < self.cap {
self.index.insert(key, self.violations.list.len());
self.violations.list.push(v);
} else {
self.violations.dropped += 1;
}
}
pub(crate) fn finish(&mut self) {
if matches!(
self.violations.coverage,
Coverage::Observed | Coverage::SystemCalls
) {
self.violations.complete = true;
}
}
pub(crate) fn interrupt(&mut self, reason: &str) {
if !self.violations.complete {
self.violations.coverage = Coverage::Unavailable(reason.to_string());
}
self.live = None;
}
pub(crate) fn is_complete(&self) -> bool {
self.violations.complete
}
pub(crate) fn take_receiver(&mut self) -> Option<Receiver<Violation>> {
self.receiver.take()
}
pub(crate) fn snapshot(&self) -> Violations {
self.violations.clone()
}
}
pub(crate) fn parse(message: &str, received: SystemTime) -> Option<(&str, Violation)> {
let (head, last) = message.rsplit_once('\n')?;
let (marker, code) = match last.split_once(' ') {
Some((marker, code)) => (marker, Some(code)),
None => (last, None),
};
if marker.is_empty() {
return None;
}
let rest = head.strip_prefix("Sandbox: ")?;
let (name, pid, after) = (0..rest.len())
.filter(|&i| rest.as_bytes()[i] == b'(')
.find_map(|open| {
let tail = &rest[open + 1..];
let close = tail.find(')')?;
let pid: u32 = tail[..close].parse().ok()?;
let after = tail[close..].strip_prefix(") deny(")?;
let n_end = after.find(") ")?;
after[..n_end].parse::<u32>().ok()?;
Some((&rest[..open], pid, &after[n_end + 2..]))
})?;
let (operation, target) = match after.split_once(' ') {
Some((op, target)) if !target.is_empty() => (op, Some(target.to_string())),
Some((op, _)) => (op, None),
None => (after, None),
};
if operation.is_empty() {
return None;
}
Some((
marker,
Violation {
pid,
process: name.to_string(),
operation: operation.to_string(),
target,
denied_by: DeniedBy::of(code),
received,
repeats: 1,
},
))
}
#[cfg(test)]
mod tests {
use super::*;
const M: &str = "org.procage.run.1f.0123456789abcdef.3";
const OTHER: &str = "org.procage.run.1f.0123456789abcdef.4";
fn now() -> SystemTime {
SystemTime::UNIX_EPOCH
}
#[test]
fn a_default_denial_is_not_granted() {
let msg = format!("Sandbox: cat(812) deny(1) file-read-data /Users/a/.zshrc\n{M}");
let (marker, v) = parse(&msg, now()).unwrap();
assert_eq!(marker, M);
assert_eq!(v.pid, 812);
assert_eq!(v.process, "cat");
assert_eq!(v.operation, "file-read-data");
assert_eq!(v.target.as_deref(), Some("/Users/a/.zshrc"));
assert_eq!(v.denied_by, DeniedBy::NotGranted);
assert_eq!(
v.to_string(),
"cat (pid 812) was denied file-read-data /Users/a/.zshrc: not granted by the policy"
);
}
#[test]
fn rule_codes_name_the_kind() {
let cases = [
("hide", DeniedBy::Hide),
("protect", DeniedBy::Protect),
("layer", DeniedBy::Layer),
("hd:secrets", DeniedBy::HardDeny(HardDenyClass::Secrets)),
("hd:privilege", DeniedBy::HardDeny(HardDenyClass::Privilege)),
("future-kind", DeniedBy::Unknown("future-kind".into())),
];
for (code, want) in cases {
let msg = format!("Sandbox: sh(9) deny(1) file-write-create /w/x\n{M} {code}");
assert_eq!(parse(&msg, now()).unwrap().1.denied_by, want, "{code}");
}
}
#[test]
fn names_with_parentheses_and_spaces_parse() {
let msg = format!(
"Sandbox: Code Helper (Plugin)(4678) deny(1) mach-lookup com.apple.diagnosticd\n{M}"
);
let (_, v) = parse(&msg, now()).unwrap();
assert_eq!(v.process, "Code Helper (Plugin)");
assert_eq!(v.pid, 4678);
assert_eq!(v.target.as_deref(), Some("com.apple.diagnosticd"));
}
#[test]
fn a_newline_in_the_target_cannot_move_the_violation() {
let target = format!("/w/x\n{OTHER} hide");
let msg = format!("Sandbox: cat(5) deny(1) file-read-data {target}\n{M}");
let (marker, v) = parse(&msg, now()).unwrap();
assert_eq!(marker, M);
assert_eq!(v.target.as_deref(), Some(target.as_str()));
assert_eq!(v.denied_by, DeniedBy::NotGranted);
}
#[test]
fn other_messages_are_ignored() {
for msg in [
"Sandbox: cat(5) deny(1) file-read-data /x".to_string(),
format!("Sandbox: cat(5) allow file-read-data /x\n{M}"),
format!("1 duplicate report for Sandbox: cat(5) deny(1) file-read-data /x\n{M}"),
format!("cat(5) deny(1) file-read-data /x\n{M}"),
format!("Sandbox: cat(x) deny(1) file-read-data /x\n{M}"),
"Sandbox: cat(5) deny(1) file-read-data /x\n".to_string(),
] {
assert!(parse(&msg, now()).is_none(), "{msg:?}");
}
}
#[test]
fn an_operation_without_target_parses() {
let msg = format!("Sandbox: sh(5) deny(1) file-write-setugid\n{M} hd:privilege");
let (_, v) = parse(&msg, now()).unwrap();
assert_eq!(v.operation, "file-write-setugid");
assert_eq!(v.target, None);
}
#[test]
fn the_record_keeps_the_cap_and_counts_the_rest() {
let mut record = Record::with_cap(Coverage::Observed, 3);
let rx = record.take_receiver().unwrap();
let at = |path: usize| {
let msg = format!("Sandbox: cat(5) deny(1) file-read-data /x{path}\n{M}");
parse(&msg, now()).unwrap().1
};
for path in 0..5 {
record.push(at(path));
}
record.push(at(0));
record.push(at(4));
let snap = record.snapshot();
assert_eq!(snap.len(), 3);
assert_eq!(snap.dropped(), 3);
assert_eq!(snap.iter().next().unwrap().repeats, 2);
assert!(!snap.complete());
assert_eq!(rx.try_iter().count(), 6);
assert!(record.take_receiver().is_none());
record.finish();
assert!(record.snapshot().complete());
}
#[test]
fn the_receiver_is_bounded_and_never_blocks() {
let mut record = Record::with_cap(Coverage::Observed, CAP);
let rx = record.take_receiver().unwrap();
let at = |path: usize| {
let msg = format!("Sandbox: cat(5) deny(1) file-read-data /x{path}\n{M}");
parse(&msg, now()).unwrap().1
};
for path in 0..RECEIVER_BOUND + 10 {
record.push(at(path));
}
assert_eq!(rx.try_iter().count(), RECEIVER_BOUND);
assert_eq!(record.snapshot().len(), RECEIVER_BOUND + 10);
drop(rx);
record.push(at(0));
}
#[test]
fn only_equal_denials_merge() {
let mut record = Record::new(Coverage::Observed);
let base = |msg: &str| parse(msg, now()).unwrap().1;
let v = base(&format!("Sandbox: cat(5) deny(1) file-read-data /x\n{M}"));
let variants = [
base(&format!("Sandbox: cat(6) deny(1) file-read-data /x\n{M}")),
base(&format!("Sandbox: dog(5) deny(1) file-read-data /x\n{M}")),
base(&format!("Sandbox: cat(5) deny(1) file-write-data /x\n{M}")),
base(&format!("Sandbox: cat(5) deny(1) file-read-data /y\n{M}")),
base(&format!(
"Sandbox: cat(5) deny(1) file-read-data /x\n{M} hide"
)),
];
record.push(v.clone());
for other in variants.iter().cloned() {
record.push(other);
}
assert_eq!(record.snapshot().len(), 6);
let mut big = v.clone();
big.repeats = u32::MAX;
record.push(big);
record.push(v);
let first = record.snapshot().iter().next().unwrap().clone();
assert_eq!(first.repeats, u32::MAX);
assert!(first.to_string().ends_with(&format!("(×{})", u32::MAX)));
}
#[test]
fn an_interrupted_record_says_why() {
let mut record = Record::new(Coverage::Observed);
record.interrupt("the log stream ended");
let snap = record.snapshot();
assert!(!snap.complete());
assert_eq!(
snap.coverage(),
&Coverage::Unavailable("the log stream ended".into())
);
}
}