use crate::events::FileIoEvent;
#[must_use]
pub fn canonicalize_lexical(path: &str) -> String {
let is_absolute = path.starts_with('/');
let had_trailing_slash = path.len() > 1 && path.ends_with('/');
let mut stack: Vec<&str> = Vec::new();
for seg in path.split('/') {
match seg {
"" | "." => continue,
".." => match stack.last() {
Some(&last) if last != ".." => {
stack.pop();
}
_ => {
if !is_absolute {
stack.push("..");
}
}
},
other => stack.push(other),
}
}
let mut out = String::with_capacity(path.len());
if is_absolute {
out.push('/');
}
out.push_str(&stack.join("/"));
if had_trailing_slash && !stack.is_empty() {
out.push('/');
}
out
}
#[derive(Debug, Clone)]
pub struct SensitivePathDetector {
prefixes: Vec<String>,
}
impl SensitivePathDetector {
pub fn new(prefixes: Vec<String>) -> Self {
Self {
prefixes: prefixes.into_iter().map(|p| canonicalize_lexical(&p)).collect(),
}
}
pub fn with_defaults() -> Self {
Self::new(vec![
"/etc/shadow".into(),
"/etc/passwd".into(),
"/etc/sudoers".into(),
"/root/.ssh/".into(),
"/home/".into(),
])
}
pub fn is_sensitive(&self, event: &FileIoEvent) -> bool {
let canonical = canonicalize_lexical(&event.path);
self.prefixes.iter().any(|p| canonical.starts_with(p))
}
pub fn add_prefix(&mut self, prefix: String) {
self.prefixes.push(canonicalize_lexical(&prefix));
}
pub fn prefixes(&self) -> &[String] {
&self.prefixes
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::syscall::SyscallKind;
fn make_event(path: &str) -> FileIoEvent {
FileIoEvent {
pid: 1,
tid: 1,
timestamp_ns: 0,
syscall: SyscallKind::Openat,
path: path.into(),
flags: 0,
return_code: 0,
is_sensitive: false,
duration_ns: 0,
}
}
#[test]
fn detects_sensitive_path() {
let detector = SensitivePathDetector::with_defaults();
assert!(detector.is_sensitive(&make_event("/etc/shadow")));
assert!(detector.is_sensitive(&make_event("/etc/passwd")));
assert!(detector.is_sensitive(&make_event("/root/.ssh/id_rsa")));
}
#[test]
fn allows_normal_path() {
let detector = SensitivePathDetector::with_defaults();
assert!(!detector.is_sensitive(&make_event("/tmp/workfile")));
assert!(!detector.is_sensitive(&make_event("/var/log/syslog")));
}
#[test]
fn custom_prefix() {
let mut detector = SensitivePathDetector::new(vec![]);
detector.add_prefix("/opt/secrets/".into());
assert!(detector.is_sensitive(&make_event("/opt/secrets/key.pem")));
assert!(!detector.is_sensitive(&make_event("/opt/app/config")));
}
#[test]
fn detects_noncanonical_sensitive_path() {
let detector = SensitivePathDetector::with_defaults();
assert!(detector.is_sensitive(&make_event("/etc//shadow")));
assert!(detector.is_sensitive(&make_event("/etc/./shadow")));
assert!(detector.is_sensitive(&make_event("/etc/../etc/shadow")));
assert!(detector.is_sensitive(&make_event("/root/.ssh/../.ssh/id_rsa")));
}
#[test]
fn directory_prefix_boundary_is_respected() {
let detector = SensitivePathDetector::with_defaults();
assert!(detector.is_sensitive(&make_event("/home//user/.bashrc")));
assert!(!detector.is_sensitive(&make_event("/homestead/config")));
}
#[test]
fn collapse_slashes_in_added_prefix() {
let mut detector = SensitivePathDetector::new(vec![]);
detector.add_prefix("/opt//secrets/".into());
assert_eq!(detector.prefixes(), ["/opt/secrets/"]);
assert!(detector.is_sensitive(&make_event("/opt/secrets/key.pem")));
}
#[test]
fn canonicalize_collapses_repeated_slashes() {
assert_eq!(canonicalize_lexical("/etc//shadow"), "/etc/shadow");
assert_eq!(canonicalize_lexical("/etc///foo//bar"), "/etc/foo/bar");
}
#[test]
fn canonicalize_drops_dot_segments() {
assert_eq!(canonicalize_lexical("/etc/./shadow"), "/etc/shadow");
assert_eq!(canonicalize_lexical("/./etc/shadow"), "/etc/shadow");
}
#[test]
fn canonicalize_resolves_dotdot_segments() {
assert_eq!(canonicalize_lexical("/etc/../etc/shadow"), "/etc/shadow");
assert_eq!(canonicalize_lexical("/a/b/../c"), "/a/c");
}
#[test]
fn canonicalize_dotdot_cannot_escape_root() {
assert_eq!(canonicalize_lexical("/etc/../../shadow"), "/shadow");
assert_eq!(canonicalize_lexical("/../../x"), "/x");
}
#[test]
fn canonicalize_preserves_root_and_directory_trailing_slash() {
assert_eq!(canonicalize_lexical("/"), "/");
assert_eq!(canonicalize_lexical("/root/.ssh/"), "/root/.ssh/");
assert_eq!(canonicalize_lexical("/home//"), "/home/");
}
#[test]
fn canonicalize_keeps_relative_leading_dotdot() {
assert_eq!(canonicalize_lexical("../etc/shadow"), "../etc/shadow");
assert_eq!(canonicalize_lexical("a/./b/../c"), "a/c");
assert_eq!(canonicalize_lexical(""), "");
}
}