use newt_core::DenialKind;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DangerTier {
Low,
High,
}
const INTERPRETER_EXEC: &[&str] = &[
"sh", "bash", "dash", "zsh", "fish", "ksh", "ash", "tcsh", "csh",
"python", "python2", "python3", "node", "deno", "bun", "perl", "ruby", "php", "lua", "tclsh",
"Rscript", "groovy", "irb", "pwsh",
"awk", "gawk", "mawk",
"env", "xargs", "nohup", "nice", "timeout", "stdbuf", "setsid", "watch",
];
#[derive(Debug, Clone)]
pub struct DangerTable {
interpreters: BTreeSet<String>,
broad_fs_roots: Vec<PathBuf>,
}
impl DangerTable {
#[must_use]
pub fn builtin() -> Self {
Self {
interpreters: INTERPRETER_EXEC.iter().map(|s| (*s).to_string()).collect(),
broad_fs_roots: vec![PathBuf::from("/")],
}
}
#[must_use]
pub fn with_fs_root(mut self, root: impl AsRef<Path>) -> Self {
let root = root.as_ref();
if !root.as_os_str().is_empty() && !self.broad_fs_roots.iter().any(|r| r == root) {
self.broad_fs_roots.push(root.to_path_buf());
}
self
}
#[must_use]
pub fn classify(&self, kind: DenialKind, target: &str) -> DangerTier {
let high = match kind {
DenialKind::Exec => self.is_interpreter(target),
DenialKind::FsRead | DenialKind::FsWrite => self.is_broad_path(target),
DenialKind::Net => false,
};
if high {
DangerTier::High
} else {
DangerTier::Low
}
}
#[must_use]
pub fn blast_radius(&self, kind: DenialKind, target: &str) -> Option<String> {
if self.classify(kind, target) != DangerTier::High {
return None;
}
let t = target.trim();
match kind {
DenialKind::Exec => Some(format!(
"⚠ `{t}` is an interpreter: this grants arbitrary command execution"
)),
DenialKind::FsRead | DenialKind::FsWrite => {
let verb = if kind == DenialKind::FsWrite {
"write"
} else {
"read"
};
if Path::new(t) == Path::new("/") {
Some(format!(
"⚠ `/` is the filesystem root: this grants {verb} access to everything"
))
} else {
Some(format!(
"⚠ `{t}` is a broad filesystem root: this grants {verb} \
access to everything beneath it"
))
}
}
DenialKind::Net => None,
}
}
fn is_interpreter(&self, target: &str) -> bool {
let trimmed = target.trim();
let name = Path::new(trimmed)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(trimmed);
self.interpreters.contains(name)
}
fn is_broad_path(&self, target: &str) -> bool {
let t = target.trim();
if t.is_empty() {
return false;
}
let tp = Path::new(t);
self.broad_fs_roots
.iter()
.any(|root| root.as_path() == tp || root.starts_with(tp))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_flags_interpreters_and_broad_roots_high_and_narrow_low() {
let t = DangerTable::builtin()
.with_fs_root("/home/me")
.with_fs_root("/home/me/project");
for cmd in [
"bash", "sh", "zsh", "fish", "python", "python3", "node", "perl", "ruby",
] {
assert_eq!(
t.classify(DenialKind::Exec, cmd),
DangerTier::High,
"`{cmd}` is an interpreter — high-danger"
);
}
assert_eq!(t.classify(DenialKind::Exec, "env"), DangerTier::High);
assert_eq!(t.classify(DenialKind::Exec, "xargs"), DangerTier::High);
assert_eq!(
t.classify(DenialKind::Exec, "/usr/bin/python3"),
DangerTier::High
);
for cmd in ["ls", "cargo", "git", "rg", "rm"] {
assert_eq!(
t.classify(DenialKind::Exec, cmd),
DangerTier::Low,
"`{cmd}` is a narrow command — low-danger"
);
}
for p in ["/", "/home/me", "/home/me/project", "/home"] {
assert_eq!(
t.classify(DenialKind::FsWrite, p),
DangerTier::High,
"`{p}` is a broad fs root — high-danger"
);
assert_eq!(t.classify(DenialKind::FsRead, p), DangerTier::High);
}
for p in [
"/home/me/project/src",
"/home/me/project/Cargo.toml",
"/tmp/scratch",
] {
assert_eq!(
t.classify(DenialKind::FsWrite, p),
DangerTier::Low,
"`{p}` is a narrow path — low-danger"
);
}
assert_eq!(t.classify(DenialKind::Net, "example.com"), DangerTier::Low);
}
#[test]
fn blast_radius_is_system_computed_for_high_only() {
let t = DangerTable::builtin().with_fs_root("/home/me/project");
let exec = t.blast_radius(DenialKind::Exec, "bash").unwrap();
assert!(exec.contains("interpreter"), "got: {exec}");
assert!(exec.contains("arbitrary command execution"), "got: {exec}");
let root = t.blast_radius(DenialKind::FsWrite, "/").unwrap();
assert!(root.contains("filesystem root"), "got: {root}");
assert!(root.contains("write access to everything"), "got: {root}");
let read_root = t.blast_radius(DenialKind::FsRead, "/").unwrap();
assert!(
read_root.contains("read access to everything"),
"got: {read_root}"
);
let ws = t
.blast_radius(DenialKind::FsWrite, "/home/me/project")
.unwrap();
assert!(ws.contains("broad filesystem root"), "got: {ws}");
assert!(t.blast_radius(DenialKind::Exec, "ls").is_none());
assert!(t
.blast_radius(DenialKind::FsWrite, "/home/me/project/src/main.rs")
.is_none());
assert!(t.blast_radius(DenialKind::Net, "example.com").is_none());
}
#[test]
fn with_fs_root_is_idempotent_on_empties_and_dupes() {
let a = DangerTable::builtin()
.with_fs_root("")
.with_fs_root("/home/me");
let b = a.clone().with_fs_root("/home/me");
assert_eq!(b.broad_fs_roots.len(), a.broad_fs_roots.len());
assert!(b
.broad_fs_roots
.iter()
.any(|r| r.as_path() == Path::new("/")));
}
}