Skip to main content

nexus_shield/endpoint/
allowlist.rs

1// ============================================================================
2// File: endpoint/allowlist.rs
3// Description: Developer-aware allowlist — auto-detects toolchains to eliminate false positives
4// Author: Andrew Jewell Sr. - AutomataNexus
5// Updated: March 24, 2026
6// ============================================================================
7//! Developer Allowlist — knows compilers, build tools, IDEs, and runtimes.
8//!
9//! Auto-detects installed dev environments (Rust, Node, Python, Go, Docker, Java,
10//! C/C++) and builds an allowlist of paths and process names that should never be
11//! flagged as threats. This is what makes NexusShield zero-false-positive on dev machines.
12
13use parking_lot::RwLock;
14use serde::{Deserialize, Serialize};
15use std::collections::HashSet;
16use std::path::{Path, PathBuf};
17
18/// Configuration for the developer allowlist.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct AllowlistConfig {
21    /// Auto-detect installed dev environments on startup.
22    pub auto_detect: bool,
23    /// Additional path patterns to always skip (glob-like).
24    pub custom_allow_paths: Vec<String>,
25    /// Additional process names to always skip.
26    pub custom_allow_processes: Vec<String>,
27}
28
29impl Default for AllowlistConfig {
30    fn default() -> Self {
31        Self {
32            auto_detect: true,
33            custom_allow_paths: Vec::new(),
34            custom_allow_processes: Vec::new(),
35        }
36    }
37}
38
39/// Developer-aware allowlist that auto-detects toolchains.
40pub struct DeveloperAllowlist {
41    config: AllowlistConfig,
42    /// Path patterns to skip (component matches and extension matches).
43    skip_paths: RwLock<Vec<String>>,
44    /// Process names to skip.
45    skip_processes: RwLock<HashSet<String>>,
46}
47
48impl DeveloperAllowlist {
49    pub fn new(config: AllowlistConfig) -> Self {
50        let al = Self {
51            config: config.clone(),
52            skip_paths: RwLock::new(Vec::new()),
53            skip_processes: RwLock::new(HashSet::new()),
54        };
55        if config.auto_detect {
56            al.detect_dev_environments();
57        }
58        // Always add custom overrides
59        {
60            let mut paths = al.skip_paths.write();
61            for p in &config.custom_allow_paths {
62                paths.push(p.clone());
63            }
64        }
65        {
66            let mut procs = al.skip_processes.write();
67            for p in &config.custom_allow_processes {
68                procs.insert(p.clone());
69            }
70        }
71        al
72    }
73
74    /// Scan the filesystem for installed dev environments and populate allowlists.
75    pub fn detect_dev_environments(&self) {
76        let mut paths = self.skip_paths.write();
77        let mut procs = self.skip_processes.write();
78
79        let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
80        let home_path = PathBuf::from(&home);
81
82        // === Build output (always skip — universal across languages) ===
83        paths.push("target/debug".to_string());
84        paths.push("target/release".to_string());
85
86        // === Rust ===
87        if home_path.join(".cargo/bin").exists() || home_path.join(".rustup").exists() {
88            paths.push(".rustup".to_string());
89            paths.push(".cargo/registry".to_string());
90            for name in &["rustc", "cargo", "rust-analyzer", "clippy-driver", "rustfmt", "cargo-clippy", "rustup"] {
91                procs.insert(name.to_string());
92            }
93        }
94
95        // === Node.js ===
96        if home_path.join(".nvm").exists()
97            || home_path.join(".npm").exists()
98            || Path::new("/usr/bin/node").exists()
99            || Path::new("/usr/local/bin/node").exists()
100        {
101            paths.push("node_modules".to_string());
102            paths.push(".npm".to_string());
103            paths.push(".nvm".to_string());
104            paths.push(".yarn".to_string());
105            paths.push(".pnpm-store".to_string());
106            for name in &["node", "npm", "npx", "yarn", "pnpm", "bun", "deno", "tsx", "ts-node"] {
107                procs.insert(name.to_string());
108            }
109        }
110
111        // === Python ===
112        if Path::new("/usr/bin/python3").exists()
113            || Path::new("/usr/local/bin/python3").exists()
114            || home_path.join(".conda").exists()
115        {
116            paths.push("__pycache__".to_string());
117            paths.push(".venv".to_string());
118            paths.push("venv".to_string());
119            paths.push(".conda".to_string());
120            paths.push(".local/lib/python".to_string());
121            for name in &["python", "python3", "pip", "pip3", "conda", "jupyter", "ipython", "poetry", "pdm"] {
122                procs.insert(name.to_string());
123            }
124        }
125
126        // === Go ===
127        if home_path.join("go").exists() || std::env::var("GOPATH").is_ok() {
128            paths.push("go/pkg".to_string());
129            paths.push("go/bin".to_string());
130            for name in &["go", "gopls", "dlv", "staticcheck"] {
131                procs.insert(name.to_string());
132            }
133        }
134
135        // === Docker ===
136        if Path::new("/usr/bin/docker").exists() || Path::new("/usr/local/bin/docker").exists() {
137            paths.push("/var/lib/docker".to_string());
138            for name in &["docker", "dockerd", "containerd", "containerd-shim", "runc", "docker-compose", "podman", "buildah"] {
139                procs.insert(name.to_string());
140            }
141        }
142
143        // === Java / JVM ===
144        if Path::new("/usr/bin/javac").exists()
145            || Path::new("/usr/local/bin/javac").exists()
146            || std::env::var("JAVA_HOME").is_ok()
147        {
148            paths.push(".gradle".to_string());
149            paths.push(".m2/repository".to_string());
150            for name in &["java", "javac", "gradle", "gradlew", "mvn", "mvnw", "kotlin", "kotlinc", "scala", "sbt"] {
151                procs.insert(name.to_string());
152            }
153        }
154
155        // === IDEs (always allow) ===
156        for name in &[
157            "code", "code-server", "codium",
158            "idea", "idea64", "clion", "goland", "pycharm", "webstorm", "rider", "rustrover",
159            "vim", "nvim", "emacs", "nano", "helix", "zed",
160            "sublime_text", "atom",
161        ] {
162            procs.insert(name.to_string());
163        }
164
165        // === Compilers & debuggers (always allow) ===
166        for name in &[
167            "gcc", "g++", "cc", "c++", "clang", "clang++",
168            "make", "cmake", "ninja", "meson",
169            "gdb", "lldb", "strace", "ltrace", "perf", "valgrind",
170            "ld", "as", "ar", "nm", "objdump", "strip",
171        ] {
172            procs.insert(name.to_string());
173        }
174
175        // === Git ===
176        paths.push(".git/objects".to_string());
177        paths.push(".git/pack".to_string());
178        paths.push(".git/lfs".to_string());
179        for name in &["git", "git-lfs", "gh", "hub"] {
180            procs.insert(name.to_string());
181        }
182
183        // === Build artifacts & caches (always skip) ===
184        // Use absolute path to avoid substring matching on random ".cache" in filenames
185        let cache_dir = format!("{}/.cache", home);
186        paths.push(cache_dir);
187        paths.push(".local/share/Trash".to_string());
188
189        // Extension-based skips — ONLY safe build artifacts, NOT executables
190        // NOTE: .so, .a, .dylib are NOT skipped globally — they can contain malware.
191        // They are only skipped when inside known dev paths (target/, .cargo/, node_modules/).
192        paths.push("*.rlib".to_string());  // Rust intermediate (never standalone)
193        paths.push("*.rmeta".to_string()); // Rust metadata (never standalone)
194        paths.push("*.d".to_string());     // Dependency files (text)
195        paths.push("*.pyc".to_string());   // Python bytecode
196        paths.push("*.pyo".to_string());   // Python optimized bytecode
197    }
198
199    /// Check whether a file path should be skipped by scanners.
200    pub fn should_skip_path(&self, path: &Path) -> bool {
201        let path_str = path.to_string_lossy();
202        let patterns = self.skip_paths.read();
203
204        for pattern in patterns.iter() {
205            // Extension match: *.ext
206            if let Some(ext_pattern) = pattern.strip_prefix("*.") {
207                if let Some(ext) = path.extension() {
208                    if ext.to_string_lossy().eq_ignore_ascii_case(ext_pattern) {
209                        return true;
210                    }
211                }
212                continue;
213            }
214
215            // Absolute path prefix match
216            if pattern.starts_with('/') {
217                if path_str.starts_with(pattern.as_str()) {
218                    return true;
219                }
220                continue;
221            }
222
223            // Component match: check if any path component or segment contains the pattern
224            // This handles "node_modules", "target/debug", ".git/objects", etc.
225            if path_str.contains(pattern.as_str()) {
226                return true;
227            }
228        }
229
230        false
231    }
232
233    /// Check whether a process name should be skipped by the process monitor.
234    pub fn should_skip_process(&self, name: &str) -> bool {
235        self.skip_processes.read().contains(name)
236    }
237
238    /// Re-run environment detection (e.g., after installing new tools).
239    pub fn refresh(&self) {
240        {
241            let mut paths = self.skip_paths.write();
242            paths.clear();
243        }
244        {
245            let mut procs = self.skip_processes.write();
246            procs.clear();
247        }
248        if self.config.auto_detect {
249            self.detect_dev_environments();
250        }
251        let mut paths = self.skip_paths.write();
252        for p in &self.config.custom_allow_paths {
253            paths.push(p.clone());
254        }
255        let mut procs = self.skip_processes.write();
256        for p in &self.config.custom_allow_processes {
257            procs.insert(p.clone());
258        }
259    }
260
261    /// Get the number of path patterns in the allowlist.
262    pub fn path_pattern_count(&self) -> usize {
263        self.skip_paths.read().len()
264    }
265
266    /// Get the number of allowed process names.
267    pub fn process_count(&self) -> usize {
268        self.skip_processes.read().len()
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    fn test_allowlist() -> DeveloperAllowlist {
277        DeveloperAllowlist::new(AllowlistConfig::default())
278    }
279
280    #[test]
281    fn node_modules_skipped() {
282        let al = test_allowlist();
283        assert!(al.should_skip_path(Path::new("/home/user/project/node_modules/express/index.js")));
284        assert!(al.should_skip_path(Path::new("/tmp/app/node_modules/.package-lock.json")));
285    }
286
287    #[test]
288    fn target_debug_skipped() {
289        let al = test_allowlist();
290        assert!(al.should_skip_path(Path::new("/home/user/project/target/debug/myapp")));
291        assert!(al.should_skip_path(Path::new("/opt/project/target/release/libfoo.so")));
292    }
293
294    #[test]
295    fn git_objects_skipped() {
296        let al = test_allowlist();
297        assert!(al.should_skip_path(Path::new("/home/user/repo/.git/objects/ab/cdef1234")));
298        assert!(al.should_skip_path(Path::new("/home/user/repo/.git/pack/pack-abc.idx")));
299    }
300
301    #[test]
302    fn build_artifacts_in_dev_paths_skipped() {
303        let al = test_allowlist();
304        // .o, .a, .so are NOT globally skipped anymore — only in dev paths
305        assert!(!al.should_skip_path(Path::new("/tmp/build/main.o")));
306        assert!(!al.should_skip_path(Path::new("/tmp/lib/libcrypto.a")));
307        assert!(!al.should_skip_path(Path::new("/tmp/lib/libssl.so")));
308        // But Rust intermediates are still skipped
309        assert!(al.should_skip_path(Path::new("/tmp/build/dep.rlib")));
310        assert!(al.should_skip_path(Path::new("/tmp/build/dep.rmeta")));
311    }
312
313    #[test]
314    fn normal_files_not_skipped() {
315        let al = test_allowlist();
316        assert!(!al.should_skip_path(Path::new("/home/user/Downloads/invoice.pdf")));
317        assert!(!al.should_skip_path(Path::new("/tmp/suspicious.exe")));
318        assert!(!al.should_skip_path(Path::new("/home/user/document.txt")));
319    }
320
321    #[test]
322    fn compiler_processes_skipped() {
323        let al = test_allowlist();
324        assert!(al.should_skip_process("gcc"));
325        assert!(al.should_skip_process("clang"));
326        assert!(al.should_skip_process("make"));
327        assert!(al.should_skip_process("gdb"));
328    }
329
330    #[test]
331    fn ide_processes_skipped() {
332        let al = test_allowlist();
333        assert!(al.should_skip_process("code"));
334        assert!(al.should_skip_process("nvim"));
335        assert!(al.should_skip_process("idea"));
336    }
337
338    #[test]
339    fn unknown_process_not_skipped() {
340        let al = test_allowlist();
341        assert!(!al.should_skip_process("totally-not-malware"));
342        assert!(!al.should_skip_process("xmrig"));
343    }
344
345    #[test]
346    fn custom_overrides_work() {
347        let config = AllowlistConfig {
348            auto_detect: false,
349            custom_allow_paths: vec!["my-special-dir".to_string()],
350            custom_allow_processes: vec!["my-tool".to_string()],
351        };
352        let al = DeveloperAllowlist::new(config);
353        assert!(al.should_skip_path(Path::new("/home/user/my-special-dir/file.bin")));
354        assert!(al.should_skip_process("my-tool"));
355    }
356
357    #[test]
358    fn refresh_redetects() {
359        let al = test_allowlist();
360        let count_before = al.path_pattern_count();
361        al.refresh();
362        let count_after = al.path_pattern_count();
363        assert_eq!(count_before, count_after);
364    }
365}