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        paths.push(".cache".to_string());
185        paths.push(".local/share/Trash".to_string());
186
187        // Extension-based skips
188        paths.push("*.o".to_string());
189        paths.push("*.a".to_string());
190        paths.push("*.so".to_string());
191        paths.push("*.dylib".to_string());
192        paths.push("*.rlib".to_string());
193        paths.push("*.rmeta".to_string());
194        paths.push("*.d".to_string());
195        paths.push("*.pyc".to_string());
196        paths.push("*.pyo".to_string());
197        paths.push("*.class".to_string());
198        paths.push("*.jar".to_string());
199    }
200
201    /// Check whether a file path should be skipped by scanners.
202    pub fn should_skip_path(&self, path: &Path) -> bool {
203        let path_str = path.to_string_lossy();
204        let patterns = self.skip_paths.read();
205
206        for pattern in patterns.iter() {
207            // Extension match: *.ext
208            if let Some(ext_pattern) = pattern.strip_prefix("*.") {
209                if let Some(ext) = path.extension() {
210                    if ext.to_string_lossy().eq_ignore_ascii_case(ext_pattern) {
211                        return true;
212                    }
213                }
214                continue;
215            }
216
217            // Absolute path prefix match
218            if pattern.starts_with('/') {
219                if path_str.starts_with(pattern.as_str()) {
220                    return true;
221                }
222                continue;
223            }
224
225            // Component match: check if any path component or segment contains the pattern
226            // This handles "node_modules", "target/debug", ".git/objects", etc.
227            if path_str.contains(pattern.as_str()) {
228                return true;
229            }
230        }
231
232        false
233    }
234
235    /// Check whether a process name should be skipped by the process monitor.
236    pub fn should_skip_process(&self, name: &str) -> bool {
237        self.skip_processes.read().contains(name)
238    }
239
240    /// Re-run environment detection (e.g., after installing new tools).
241    pub fn refresh(&self) {
242        {
243            let mut paths = self.skip_paths.write();
244            paths.clear();
245        }
246        {
247            let mut procs = self.skip_processes.write();
248            procs.clear();
249        }
250        if self.config.auto_detect {
251            self.detect_dev_environments();
252        }
253        let mut paths = self.skip_paths.write();
254        for p in &self.config.custom_allow_paths {
255            paths.push(p.clone());
256        }
257        let mut procs = self.skip_processes.write();
258        for p in &self.config.custom_allow_processes {
259            procs.insert(p.clone());
260        }
261    }
262
263    /// Get the number of path patterns in the allowlist.
264    pub fn path_pattern_count(&self) -> usize {
265        self.skip_paths.read().len()
266    }
267
268    /// Get the number of allowed process names.
269    pub fn process_count(&self) -> usize {
270        self.skip_processes.read().len()
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    fn test_allowlist() -> DeveloperAllowlist {
279        DeveloperAllowlist::new(AllowlistConfig::default())
280    }
281
282    #[test]
283    fn node_modules_skipped() {
284        let al = test_allowlist();
285        assert!(al.should_skip_path(Path::new("/home/user/project/node_modules/express/index.js")));
286        assert!(al.should_skip_path(Path::new("/tmp/app/node_modules/.package-lock.json")));
287    }
288
289    #[test]
290    fn target_debug_skipped() {
291        let al = test_allowlist();
292        assert!(al.should_skip_path(Path::new("/home/user/project/target/debug/myapp")));
293        assert!(al.should_skip_path(Path::new("/opt/project/target/release/libfoo.so")));
294    }
295
296    #[test]
297    fn git_objects_skipped() {
298        let al = test_allowlist();
299        assert!(al.should_skip_path(Path::new("/home/user/repo/.git/objects/ab/cdef1234")));
300        assert!(al.should_skip_path(Path::new("/home/user/repo/.git/pack/pack-abc.idx")));
301    }
302
303    #[test]
304    fn object_file_extension_skipped() {
305        let al = test_allowlist();
306        assert!(al.should_skip_path(Path::new("/tmp/build/main.o")));
307        assert!(al.should_skip_path(Path::new("/tmp/lib/libcrypto.a")));
308        assert!(al.should_skip_path(Path::new("/tmp/lib/libssl.so")));
309    }
310
311    #[test]
312    fn normal_files_not_skipped() {
313        let al = test_allowlist();
314        assert!(!al.should_skip_path(Path::new("/home/user/Downloads/invoice.pdf")));
315        assert!(!al.should_skip_path(Path::new("/tmp/suspicious.exe")));
316        assert!(!al.should_skip_path(Path::new("/home/user/document.txt")));
317    }
318
319    #[test]
320    fn compiler_processes_skipped() {
321        let al = test_allowlist();
322        assert!(al.should_skip_process("gcc"));
323        assert!(al.should_skip_process("clang"));
324        assert!(al.should_skip_process("make"));
325        assert!(al.should_skip_process("gdb"));
326    }
327
328    #[test]
329    fn ide_processes_skipped() {
330        let al = test_allowlist();
331        assert!(al.should_skip_process("code"));
332        assert!(al.should_skip_process("nvim"));
333        assert!(al.should_skip_process("idea"));
334    }
335
336    #[test]
337    fn unknown_process_not_skipped() {
338        let al = test_allowlist();
339        assert!(!al.should_skip_process("totally-not-malware"));
340        assert!(!al.should_skip_process("xmrig"));
341    }
342
343    #[test]
344    fn custom_overrides_work() {
345        let config = AllowlistConfig {
346            auto_detect: false,
347            custom_allow_paths: vec!["my-special-dir".to_string()],
348            custom_allow_processes: vec!["my-tool".to_string()],
349        };
350        let al = DeveloperAllowlist::new(config);
351        assert!(al.should_skip_path(Path::new("/home/user/my-special-dir/file.bin")));
352        assert!(al.should_skip_process("my-tool"));
353    }
354
355    #[test]
356    fn refresh_redetects() {
357        let al = test_allowlist();
358        let count_before = al.path_pattern_count();
359        al.refresh();
360        let count_after = al.path_pattern_count();
361        assert_eq!(count_before, count_after);
362    }
363}