use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AllowlistConfig {
pub auto_detect: bool,
pub custom_allow_paths: Vec<String>,
pub custom_allow_processes: Vec<String>,
}
impl Default for AllowlistConfig {
fn default() -> Self {
Self {
auto_detect: true,
custom_allow_paths: Vec::new(),
custom_allow_processes: Vec::new(),
}
}
}
pub struct DeveloperAllowlist {
config: AllowlistConfig,
skip_paths: RwLock<Vec<String>>,
skip_processes: RwLock<HashSet<String>>,
}
impl DeveloperAllowlist {
pub fn new(config: AllowlistConfig) -> Self {
let al = Self {
config: config.clone(),
skip_paths: RwLock::new(Vec::new()),
skip_processes: RwLock::new(HashSet::new()),
};
if config.auto_detect {
al.detect_dev_environments();
}
{
let mut paths = al.skip_paths.write();
for p in &config.custom_allow_paths {
paths.push(p.clone());
}
}
{
let mut procs = al.skip_processes.write();
for p in &config.custom_allow_processes {
procs.insert(p.clone());
}
}
al
}
pub fn detect_dev_environments(&self) {
let mut paths = self.skip_paths.write();
let mut procs = self.skip_processes.write();
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
let home_path = PathBuf::from(&home);
paths.push("target/debug".to_string());
paths.push("target/release".to_string());
if home_path.join(".cargo/bin").exists() || home_path.join(".rustup").exists() {
paths.push(".rustup".to_string());
paths.push(".cargo/registry".to_string());
for name in &["rustc", "cargo", "rust-analyzer", "clippy-driver", "rustfmt", "cargo-clippy", "rustup"] {
procs.insert(name.to_string());
}
}
if home_path.join(".nvm").exists()
|| home_path.join(".npm").exists()
|| Path::new("/usr/bin/node").exists()
|| Path::new("/usr/local/bin/node").exists()
{
paths.push("node_modules".to_string());
paths.push(".npm".to_string());
paths.push(".nvm".to_string());
paths.push(".yarn".to_string());
paths.push(".pnpm-store".to_string());
for name in &["node", "npm", "npx", "yarn", "pnpm", "bun", "deno", "tsx", "ts-node"] {
procs.insert(name.to_string());
}
}
if Path::new("/usr/bin/python3").exists()
|| Path::new("/usr/local/bin/python3").exists()
|| home_path.join(".conda").exists()
{
paths.push("__pycache__".to_string());
paths.push(".venv".to_string());
paths.push("venv".to_string());
paths.push(".conda".to_string());
paths.push(".local/lib/python".to_string());
for name in &["python", "python3", "pip", "pip3", "conda", "jupyter", "ipython", "poetry", "pdm"] {
procs.insert(name.to_string());
}
}
if home_path.join("go").exists() || std::env::var("GOPATH").is_ok() {
paths.push("go/pkg".to_string());
paths.push("go/bin".to_string());
for name in &["go", "gopls", "dlv", "staticcheck"] {
procs.insert(name.to_string());
}
}
if Path::new("/usr/bin/docker").exists() || Path::new("/usr/local/bin/docker").exists() {
paths.push("/var/lib/docker".to_string());
for name in &["docker", "dockerd", "containerd", "containerd-shim", "runc", "docker-compose", "podman", "buildah"] {
procs.insert(name.to_string());
}
}
if Path::new("/usr/bin/javac").exists()
|| Path::new("/usr/local/bin/javac").exists()
|| std::env::var("JAVA_HOME").is_ok()
{
paths.push(".gradle".to_string());
paths.push(".m2/repository".to_string());
for name in &["java", "javac", "gradle", "gradlew", "mvn", "mvnw", "kotlin", "kotlinc", "scala", "sbt"] {
procs.insert(name.to_string());
}
}
for name in &[
"code", "code-server", "codium",
"idea", "idea64", "clion", "goland", "pycharm", "webstorm", "rider", "rustrover",
"vim", "nvim", "emacs", "nano", "helix", "zed",
"sublime_text", "atom",
] {
procs.insert(name.to_string());
}
for name in &[
"gcc", "g++", "cc", "c++", "clang", "clang++",
"make", "cmake", "ninja", "meson",
"gdb", "lldb", "strace", "ltrace", "perf", "valgrind",
"ld", "as", "ar", "nm", "objdump", "strip",
] {
procs.insert(name.to_string());
}
paths.push(".git/objects".to_string());
paths.push(".git/pack".to_string());
paths.push(".git/lfs".to_string());
for name in &["git", "git-lfs", "gh", "hub"] {
procs.insert(name.to_string());
}
paths.push(".cache".to_string());
paths.push(".local/share/Trash".to_string());
paths.push("*.o".to_string());
paths.push("*.a".to_string());
paths.push("*.so".to_string());
paths.push("*.dylib".to_string());
paths.push("*.rlib".to_string());
paths.push("*.rmeta".to_string());
paths.push("*.d".to_string());
paths.push("*.pyc".to_string());
paths.push("*.pyo".to_string());
paths.push("*.class".to_string());
paths.push("*.jar".to_string());
}
pub fn should_skip_path(&self, path: &Path) -> bool {
let path_str = path.to_string_lossy();
let patterns = self.skip_paths.read();
for pattern in patterns.iter() {
if let Some(ext_pattern) = pattern.strip_prefix("*.") {
if let Some(ext) = path.extension() {
if ext.to_string_lossy().eq_ignore_ascii_case(ext_pattern) {
return true;
}
}
continue;
}
if pattern.starts_with('/') {
if path_str.starts_with(pattern.as_str()) {
return true;
}
continue;
}
if path_str.contains(pattern.as_str()) {
return true;
}
}
false
}
pub fn should_skip_process(&self, name: &str) -> bool {
self.skip_processes.read().contains(name)
}
pub fn refresh(&self) {
{
let mut paths = self.skip_paths.write();
paths.clear();
}
{
let mut procs = self.skip_processes.write();
procs.clear();
}
if self.config.auto_detect {
self.detect_dev_environments();
}
let mut paths = self.skip_paths.write();
for p in &self.config.custom_allow_paths {
paths.push(p.clone());
}
let mut procs = self.skip_processes.write();
for p in &self.config.custom_allow_processes {
procs.insert(p.clone());
}
}
pub fn path_pattern_count(&self) -> usize {
self.skip_paths.read().len()
}
pub fn process_count(&self) -> usize {
self.skip_processes.read().len()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_allowlist() -> DeveloperAllowlist {
DeveloperAllowlist::new(AllowlistConfig::default())
}
#[test]
fn node_modules_skipped() {
let al = test_allowlist();
assert!(al.should_skip_path(Path::new("/home/user/project/node_modules/express/index.js")));
assert!(al.should_skip_path(Path::new("/tmp/app/node_modules/.package-lock.json")));
}
#[test]
fn target_debug_skipped() {
let al = test_allowlist();
assert!(al.should_skip_path(Path::new("/home/user/project/target/debug/myapp")));
assert!(al.should_skip_path(Path::new("/opt/project/target/release/libfoo.so")));
}
#[test]
fn git_objects_skipped() {
let al = test_allowlist();
assert!(al.should_skip_path(Path::new("/home/user/repo/.git/objects/ab/cdef1234")));
assert!(al.should_skip_path(Path::new("/home/user/repo/.git/pack/pack-abc.idx")));
}
#[test]
fn object_file_extension_skipped() {
let al = test_allowlist();
assert!(al.should_skip_path(Path::new("/tmp/build/main.o")));
assert!(al.should_skip_path(Path::new("/tmp/lib/libcrypto.a")));
assert!(al.should_skip_path(Path::new("/tmp/lib/libssl.so")));
}
#[test]
fn normal_files_not_skipped() {
let al = test_allowlist();
assert!(!al.should_skip_path(Path::new("/home/user/Downloads/invoice.pdf")));
assert!(!al.should_skip_path(Path::new("/tmp/suspicious.exe")));
assert!(!al.should_skip_path(Path::new("/home/user/document.txt")));
}
#[test]
fn compiler_processes_skipped() {
let al = test_allowlist();
assert!(al.should_skip_process("gcc"));
assert!(al.should_skip_process("clang"));
assert!(al.should_skip_process("make"));
assert!(al.should_skip_process("gdb"));
}
#[test]
fn ide_processes_skipped() {
let al = test_allowlist();
assert!(al.should_skip_process("code"));
assert!(al.should_skip_process("nvim"));
assert!(al.should_skip_process("idea"));
}
#[test]
fn unknown_process_not_skipped() {
let al = test_allowlist();
assert!(!al.should_skip_process("totally-not-malware"));
assert!(!al.should_skip_process("xmrig"));
}
#[test]
fn custom_overrides_work() {
let config = AllowlistConfig {
auto_detect: false,
custom_allow_paths: vec!["my-special-dir".to_string()],
custom_allow_processes: vec!["my-tool".to_string()],
};
let al = DeveloperAllowlist::new(config);
assert!(al.should_skip_path(Path::new("/home/user/my-special-dir/file.bin")));
assert!(al.should_skip_process("my-tool"));
}
#[test]
fn refresh_redetects() {
let al = test_allowlist();
let count_before = al.path_pattern_count();
al.refresh();
let count_after = al.path_pattern_count();
assert_eq!(count_before, count_after);
}
}