Skip to main content

nexus_shield/endpoint/
mod.rs

1// ============================================================================
2// File: endpoint/mod.rs
3// Description: Real-time endpoint protection engine — core types and orchestrator
4// Author: Andrew Jewell Sr. - AutomataNexus
5// Updated: March 24, 2026
6// ============================================================================
7//! # Endpoint Protection Engine
8//!
9//! Real-time file, process, network, and memory monitoring with multi-engine
10//! malware detection. Developer-aware allowlisting eliminates false positives
11//! on dev machines.
12
13pub mod allowlist;
14pub mod container_scanner;
15pub mod dns_filter;
16pub mod file_quarantine;
17pub mod fim;
18pub mod heuristics;
19pub mod memory_scanner;
20pub mod network_monitor;
21pub mod process_monitor;
22pub mod rootkit_detector;
23pub mod signatures;
24pub mod supply_chain;
25pub mod threat_intel;
26pub mod usb_monitor;
27pub mod watcher;
28pub mod yara_engine;
29
30use chrono::{DateTime, Utc};
31use parking_lot::RwLock;
32use serde::{Deserialize, Serialize};
33use std::collections::VecDeque;
34use std::path::{Path, PathBuf};
35use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
36use std::sync::Arc;
37
38use crate::audit_chain::{AuditChain, SecurityEventType};
39
40// =============================================================================
41// Severity
42// =============================================================================
43
44/// Detection severity levels, ordered from lowest to highest.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
46pub enum Severity {
47    Info,
48    Low,
49    Medium,
50    High,
51    Critical,
52}
53
54impl std::fmt::Display for Severity {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        match self {
57            Self::Info => write!(f, "info"),
58            Self::Low => write!(f, "low"),
59            Self::Medium => write!(f, "medium"),
60            Self::High => write!(f, "high"),
61            Self::Critical => write!(f, "critical"),
62        }
63    }
64}
65
66// =============================================================================
67// Detection Category
68// =============================================================================
69
70/// Category of a detection, carrying module-specific metadata.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub enum DetectionCategory {
73    MalwareSignature { name: String, family: String },
74    HeuristicAnomaly { rule: String },
75    SuspiciousProcess { pid: u32, name: String },
76    NetworkAnomaly { connection: String },
77    MemoryAnomaly { pid: u32, region: String },
78    RootkitIndicator { technique: String },
79    YaraMatch { rule_name: String, tags: Vec<String> },
80    FilelessMalware { technique: String },
81}
82
83// =============================================================================
84// Recommended Action
85// =============================================================================
86
87/// Action the engine recommends after a detection.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub enum RecommendedAction {
90    LogOnly,
91    Alert,
92    Quarantine { source_path: PathBuf },
93    KillProcess { pid: u32 },
94    BlockConnection { addr: String },
95    Multi(Vec<RecommendedAction>),
96}
97
98impl std::fmt::Display for RecommendedAction {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        match self {
101            Self::LogOnly => write!(f, "log_only"),
102            Self::Alert => write!(f, "alert"),
103            Self::Quarantine { source_path } => {
104                write!(f, "quarantine({})", source_path.display())
105            }
106            Self::KillProcess { pid } => write!(f, "kill({})", pid),
107            Self::BlockConnection { addr } => write!(f, "block({})", addr),
108            Self::Multi(actions) => {
109                let names: Vec<String> = actions.iter().map(|a| a.to_string()).collect();
110                write!(f, "multi[{}]", names.join(", "))
111            }
112        }
113    }
114}
115
116// =============================================================================
117// Scan Result
118// =============================================================================
119
120/// Unified result returned by every scanner engine.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct ScanResult {
123    /// Unique detection ID.
124    pub id: String,
125    /// When the detection occurred.
126    pub timestamp: DateTime<Utc>,
127    /// Which scanner produced this result.
128    pub scanner: String,
129    /// What was scanned (file path, PID, connection, etc.).
130    pub target: String,
131    /// Severity of the detection.
132    pub severity: Severity,
133    /// Category with detection-specific metadata.
134    pub category: DetectionCategory,
135    /// Human-readable description.
136    pub description: String,
137    /// Confidence score 0.0–1.0.
138    pub confidence: f64,
139    /// Recommended action.
140    pub action: RecommendedAction,
141    /// SHA-256 of the scanned artifact (if applicable).
142    pub artifact_hash: Option<String>,
143}
144
145impl ScanResult {
146    /// Create a new ScanResult with auto-generated ID and timestamp.
147    pub fn new(
148        scanner: impl Into<String>,
149        target: impl Into<String>,
150        severity: Severity,
151        category: DetectionCategory,
152        description: impl Into<String>,
153        confidence: f64,
154        action: RecommendedAction,
155    ) -> Self {
156        Self {
157            id: uuid::Uuid::new_v4().to_string(),
158            timestamp: Utc::now(),
159            scanner: scanner.into(),
160            target: target.into(),
161            severity,
162            category,
163            description: description.into(),
164            confidence: confidence.clamp(0.0, 1.0),
165            action,
166            artifact_hash: None,
167        }
168    }
169
170    /// Attach an artifact hash to this result.
171    pub fn with_hash(mut self, hash: String) -> Self {
172        self.artifact_hash = Some(hash);
173        self
174    }
175}
176
177// =============================================================================
178// Scanner Trait
179// =============================================================================
180
181/// Trait that all scanning engines implement.
182#[async_trait::async_trait]
183pub trait Scanner: Send + Sync {
184    /// Human-readable name of this scanner.
185    fn name(&self) -> &str;
186
187    /// Whether this scanner is currently enabled and operational.
188    fn is_active(&self) -> bool;
189
190    /// Scan a file on disk. Returns empty vec if clean.
191    async fn scan_file(&self, path: &Path) -> Vec<ScanResult>;
192
193    /// Scan raw bytes (for in-memory content). Default: no-op.
194    async fn scan_bytes(&self, _data: &[u8], _label: &str) -> Vec<ScanResult> {
195        Vec::new()
196    }
197
198    /// Scan a running process by PID. Default: no-op.
199    async fn scan_process(&self, _pid: u32) -> Vec<ScanResult> {
200        Vec::new()
201    }
202}
203
204// =============================================================================
205// Endpoint Configuration
206// =============================================================================
207
208/// Top-level configuration for the endpoint protection engine.
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct EndpointConfig {
211    pub enabled: bool,
212    pub enable_watcher: bool,
213    pub enable_process_monitor: bool,
214    pub enable_network_monitor: bool,
215    pub enable_memory_scanner: bool,
216    pub enable_rootkit_detector: bool,
217    pub enable_dns_filter: bool,
218    pub enable_usb_monitor: bool,
219    pub enable_fim: bool,
220    pub data_dir: PathBuf,
221    pub watcher: watcher::WatcherConfig,
222    pub process_monitor: process_monitor::ProcessMonitorConfig,
223    pub network_monitor: network_monitor::NetworkMonitorConfig,
224    pub memory_scanner: memory_scanner::MemoryScanConfig,
225    pub rootkit_detector: rootkit_detector::RootkitConfig,
226    pub heuristics: heuristics::HeuristicConfig,
227    pub quarantine: file_quarantine::QuarantineVaultConfig,
228    pub allowlist: allowlist::AllowlistConfig,
229    pub threat_intel: threat_intel::ThreatIntelConfig,
230    pub signatures: signatures::SignatureConfig,
231    pub dns_filter: dns_filter::DnsFilterConfig,
232    pub usb_monitor: usb_monitor::UsbMonitorConfig,
233    pub fim: fim::FimConfig,
234}
235
236impl Default for EndpointConfig {
237    fn default() -> Self {
238        let data_dir = dirs_or_default();
239        Self {
240            enabled: true,
241            enable_watcher: true,
242            enable_process_monitor: true,
243            enable_network_monitor: true,
244            enable_memory_scanner: false, // requires elevated privileges
245            enable_rootkit_detector: false, // requires root
246            enable_dns_filter: false, // opt-in: requires configuring system DNS
247            enable_usb_monitor: true, // on by default: monitors for USB insertions
248            enable_fim: false, // opt-in: baselines system files, alerts on changes
249            data_dir: data_dir.clone(),
250            watcher: watcher::WatcherConfig::default(),
251            process_monitor: process_monitor::ProcessMonitorConfig::default(),
252            network_monitor: network_monitor::NetworkMonitorConfig::default(),
253            memory_scanner: memory_scanner::MemoryScanConfig::default(),
254            rootkit_detector: rootkit_detector::RootkitConfig::new(data_dir.clone()),
255            heuristics: heuristics::HeuristicConfig::default(),
256            quarantine: file_quarantine::QuarantineVaultConfig::new(data_dir.join("quarantine")),
257            allowlist: allowlist::AllowlistConfig::default(),
258            threat_intel: threat_intel::ThreatIntelConfig::new(data_dir.join("threat-intel")),
259            signatures: signatures::SignatureConfig::new(data_dir.join("signatures.ndjson")),
260            dns_filter: dns_filter::DnsFilterConfig::default(),
261            usb_monitor: usb_monitor::UsbMonitorConfig::default(),
262            fim: fim::FimConfig::default(),
263        }
264    }
265}
266
267fn dirs_or_default() -> PathBuf {
268    std::env::var("HOME")
269        .map(|h| PathBuf::from(h).join(".nexus-shield"))
270        .unwrap_or_else(|_| PathBuf::from("/tmp/nexus-shield"))
271}
272
273// =============================================================================
274// Endpoint Stats
275// =============================================================================
276
277/// Runtime statistics for the endpoint protection engine.
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct EndpointStats {
280    pub total_files_scanned: u64,
281    pub total_threats_detected: u64,
282    pub active_monitors: Vec<String>,
283    pub quarantined_files: usize,
284    pub last_scan_time: Option<DateTime<Utc>>,
285    pub scanners_active: Vec<String>,
286}
287
288// =============================================================================
289// Endpoint Engine
290// =============================================================================
291
292/// Orchestrates all endpoint protection subsystems.
293pub struct EndpointEngine {
294    /// All registered scanning engines.
295    scanners: Vec<Arc<dyn Scanner>>,
296    /// Developer-aware allowlist.
297    pub allowlist: Arc<allowlist::DeveloperAllowlist>,
298    /// Threat intelligence database.
299    pub threat_intel: Arc<threat_intel::ThreatIntelDB>,
300    /// File quarantine vault.
301    pub quarantine: Arc<file_quarantine::QuarantineVault>,
302    /// DNS filtering proxy.
303    pub dns_filter: Option<Arc<dns_filter::DnsFilter>>,
304    /// Broadcast channel for real-time scan results.
305    result_tx: tokio::sync::broadcast::Sender<ScanResult>,
306    /// Detection history (ring buffer).
307    history: Arc<RwLock<VecDeque<ScanResult>>>,
308    /// Configuration.
309    config: EndpointConfig,
310    /// Counters.
311    files_scanned: AtomicU64,
312    threats_detected: AtomicU64,
313    /// Whether the engine is running.
314    running: AtomicBool,
315}
316
317impl EndpointEngine {
318    /// Create a new endpoint engine with the given configuration.
319    pub fn new(config: EndpointConfig) -> Self {
320        let (result_tx, _) = tokio::sync::broadcast::channel(1024);
321
322        // Initialize subsystems
323        let allowlist = Arc::new(allowlist::DeveloperAllowlist::new(config.allowlist.clone()));
324        let threat_intel = Arc::new(threat_intel::ThreatIntelDB::new(config.threat_intel.clone()));
325        let quarantine = Arc::new(file_quarantine::QuarantineVault::new(config.quarantine.clone()));
326
327        // DNS filter (if enabled)
328        let dns_filter = if config.enable_dns_filter {
329            Some(Arc::new(dns_filter::DnsFilter::new(
330                config.dns_filter.clone(),
331                Arc::clone(&threat_intel),
332            )))
333        } else {
334            None
335        };
336
337        // Build scanner list
338        let mut scanners: Vec<Arc<dyn Scanner>> = Vec::new();
339
340        // Signature engine
341        let sig_engine = signatures::SignatureEngine::new(config.signatures.clone());
342        scanners.push(Arc::new(sig_engine));
343
344        // Heuristic engine
345        let heur_engine = heuristics::HeuristicEngine::new(config.heuristics.clone());
346        scanners.push(Arc::new(heur_engine));
347
348        // YARA engine
349        let yara = yara_engine::YaraEngine::new(None);
350        scanners.push(Arc::new(yara));
351
352        Self {
353            scanners,
354            allowlist,
355            threat_intel,
356            quarantine,
357            dns_filter,
358            result_tx,
359            history: Arc::new(RwLock::new(VecDeque::with_capacity(10000))),
360            config,
361            files_scanned: AtomicU64::new(0),
362            threats_detected: AtomicU64::new(0),
363            running: AtomicBool::new(false),
364        }
365    }
366
367    /// Start all background monitors. Returns JoinHandles for spawned tasks.
368    pub async fn start(&self, audit: Arc<AuditChain>) -> Vec<tokio::task::JoinHandle<()>> {
369        self.running.store(true, Ordering::SeqCst);
370        let mut handles = Vec::new();
371
372        // Record startup event
373        audit.record(
374            SecurityEventType::EndpointScanStarted,
375            "system",
376            "Endpoint protection engine started",
377            0.0,
378        );
379
380        // Start file watcher
381        if self.config.enable_watcher {
382            let (scan_tx, mut scan_rx) = tokio::sync::mpsc::unbounded_channel::<PathBuf>();
383            let watcher_handle = watcher::FileWatcher::new(
384                self.config.watcher.clone(),
385                scan_tx,
386            );
387
388            let allowlist = Arc::clone(&self.allowlist);
389            let _watcher_task = watcher_handle.start(allowlist);
390
391            // File scan consumer task
392            let scanners = self.scanners.clone();
393            let result_tx = self.result_tx.clone();
394            let history = Arc::clone(&self.history);
395            let quarantine = Arc::clone(&self.quarantine);
396            let audit2 = Arc::clone(&audit);
397            let files_scanned = &self.files_scanned as *const AtomicU64 as usize;
398            let threats_detected = &self.threats_detected as *const AtomicU64 as usize;
399
400            let handle = tokio::spawn(async move {
401                while let Some(path) = scan_rx.recv().await {
402                    // Run all scanners on the file
403                    let mut all_results = Vec::new();
404                    for scanner in &scanners {
405                        if scanner.is_active() {
406                            let results = scanner.scan_file(&path).await;
407                            all_results.extend(results);
408                        }
409                    }
410
411                    // SAFETY: These are effectively &'static since EndpointEngine outlives tasks
412                    unsafe {
413                        (*(files_scanned as *const AtomicU64)).fetch_add(1, Ordering::Relaxed);
414                    }
415
416                    // Process results
417                    for result in all_results {
418                        unsafe {
419                            (*(threats_detected as *const AtomicU64)).fetch_add(1, Ordering::Relaxed);
420                        }
421
422                        // Quarantine if needed
423                        if let RecommendedAction::Quarantine { ref source_path } = result.action {
424                            let _ = quarantine.quarantine_file(
425                                source_path,
426                                &result.description,
427                                &result.scanner,
428                                result.severity,
429                            );
430                        }
431
432                        // Record to audit chain
433                        audit2.record(
434                            SecurityEventType::MalwareDetected,
435                            &result.target,
436                            &result.description,
437                            result.confidence,
438                        );
439
440                        // Broadcast and save to history
441                        let _ = result_tx.send(result.clone());
442                        let mut hist = history.write();
443                        if hist.len() >= 10000 {
444                            hist.pop_front();
445                        }
446                        hist.push_back(result);
447                    }
448                }
449            });
450            handles.push(handle);
451        }
452
453        // Start DNS filter proxy
454        if self.config.enable_dns_filter {
455            if let Some(ref dns) = self.dns_filter {
456                let (dns_tx, mut dns_rx) = tokio::sync::mpsc::unbounded_channel::<ScanResult>();
457                let dns_handle = Arc::clone(dns).start(dns_tx);
458                handles.push(dns_handle);
459
460                // DNS detection consumer
461                let history = Arc::clone(&self.history);
462                let audit3 = Arc::clone(&audit);
463                let result_tx = self.result_tx.clone();
464                let threats_detected = &self.threats_detected as *const AtomicU64 as usize;
465                let dns_consumer = tokio::spawn(async move {
466                    while let Some(result) = dns_rx.recv().await {
467                        unsafe {
468                            (*(threats_detected as *const AtomicU64)).fetch_add(1, Ordering::Relaxed);
469                        }
470                        audit3.record(
471                            SecurityEventType::MalwareDetected,
472                            &result.target,
473                            &result.description,
474                            result.confidence,
475                        );
476                        let _ = result_tx.send(result.clone());
477                        let mut hist = history.write();
478                        if hist.len() >= 10000 {
479                            hist.pop_front();
480                        }
481                        hist.push_back(result);
482                    }
483                });
484                handles.push(dns_consumer);
485            }
486        }
487
488        // Start USB monitor
489        if self.config.enable_usb_monitor {
490            let (usb_tx, mut usb_rx) = tokio::sync::mpsc::unbounded_channel::<ScanResult>();
491            let usb_mon = Arc::new(usb_monitor::UsbMonitor::new(self.config.usb_monitor.clone()));
492            let usb_handle = Arc::clone(&usb_mon).start(usb_tx);
493            handles.push(usb_handle);
494
495            // USB detection consumer
496            let history = Arc::clone(&self.history);
497            let audit4 = Arc::clone(&audit);
498            let result_tx = self.result_tx.clone();
499            let threats_detected = &self.threats_detected as *const AtomicU64 as usize;
500            let usb_consumer = tokio::spawn(async move {
501                while let Some(result) = usb_rx.recv().await {
502                    unsafe {
503                        (*(threats_detected as *const AtomicU64)).fetch_add(1, Ordering::Relaxed);
504                    }
505                    audit4.record(
506                        SecurityEventType::MalwareDetected,
507                        &result.target,
508                        &result.description,
509                        result.confidence,
510                    );
511                    let _ = result_tx.send(result.clone());
512                    let mut hist = history.write();
513                    if hist.len() >= 10000 {
514                        hist.pop_front();
515                    }
516                    hist.push_back(result);
517                }
518            });
519            handles.push(usb_consumer);
520        }
521
522        // Start File Integrity Monitor
523        if self.config.enable_fim {
524            let (fim_tx, mut fim_rx) = tokio::sync::mpsc::unbounded_channel::<ScanResult>();
525            let fim_mon = Arc::new(fim::FimMonitor::new(self.config.fim.clone()));
526            let fim_handle = Arc::clone(&fim_mon).start(fim_tx);
527            handles.push(fim_handle);
528
529            // FIM detection consumer
530            let history = Arc::clone(&self.history);
531            let audit5 = Arc::clone(&audit);
532            let result_tx = self.result_tx.clone();
533            let threats_detected = &self.threats_detected as *const AtomicU64 as usize;
534            let fim_consumer = tokio::spawn(async move {
535                while let Some(result) = fim_rx.recv().await {
536                    unsafe {
537                        (*(threats_detected as *const AtomicU64)).fetch_add(1, Ordering::Relaxed);
538                    }
539                    audit5.record(
540                        SecurityEventType::MalwareDetected,
541                        &result.target,
542                        &result.description,
543                        result.confidence,
544                    );
545                    let _ = result_tx.send(result.clone());
546                    let mut hist = history.write();
547                    if hist.len() >= 10000 {
548                        hist.pop_front();
549                    }
550                    hist.push_back(result);
551                }
552            });
553            handles.push(fim_consumer);
554        }
555
556        handles
557    }
558
559    /// Scan a single file with all engines.
560    pub async fn scan_file(&self, path: &Path) -> Vec<ScanResult> {
561        if self.allowlist.should_skip_path(path) {
562            return Vec::new();
563        }
564
565        self.files_scanned.fetch_add(1, Ordering::Relaxed);
566        let mut results = Vec::new();
567
568        for scanner in &self.scanners {
569            if scanner.is_active() {
570                let mut r = scanner.scan_file(path).await;
571                results.append(&mut r);
572            }
573        }
574
575        if !results.is_empty() {
576            self.threats_detected
577                .fetch_add(results.len() as u64, Ordering::Relaxed);
578            let mut hist = self.history.write();
579            for r in &results {
580                if hist.len() >= 10000 {
581                    hist.pop_front();
582                }
583                hist.push_back(r.clone());
584            }
585        }
586
587        results
588    }
589
590    /// Scan a directory recursively.
591    pub async fn scan_dir(&self, dir: &Path) -> Vec<ScanResult> {
592        let mut results = Vec::new();
593        if let Ok(entries) = std::fs::read_dir(dir) {
594            for entry in entries.flatten() {
595                let path = entry.path();
596                if path.is_dir() {
597                    if !self.allowlist.should_skip_path(&path) {
598                        let mut r = Box::pin(self.scan_dir(&path)).await;
599                        results.append(&mut r);
600                    }
601                } else if path.is_file() {
602                    let mut r = self.scan_file(&path).await;
603                    results.append(&mut r);
604                }
605            }
606        }
607        results
608    }
609
610    /// Subscribe to real-time scan results.
611    pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<ScanResult> {
612        self.result_tx.subscribe()
613    }
614
615    /// Get recent detection history.
616    pub fn recent_detections(&self, count: usize) -> Vec<ScanResult> {
617        let hist = self.history.read();
618        hist.iter().rev().take(count).cloned().collect()
619    }
620
621    /// Get runtime statistics.
622    pub fn stats(&self) -> EndpointStats {
623        let mut active = Vec::new();
624        if self.config.enable_watcher {
625            active.push("file_watcher".to_string());
626        }
627        if self.config.enable_process_monitor {
628            active.push("process_monitor".to_string());
629        }
630        if self.config.enable_network_monitor {
631            active.push("network_monitor".to_string());
632        }
633        if self.config.enable_memory_scanner {
634            active.push("memory_scanner".to_string());
635        }
636        if self.config.enable_rootkit_detector {
637            active.push("rootkit_detector".to_string());
638        }
639        if self.config.enable_dns_filter {
640            active.push("dns_filter".to_string());
641        }
642        if self.config.enable_usb_monitor {
643            active.push("usb_monitor".to_string());
644        }
645        if self.config.enable_fim {
646            active.push("fim".to_string());
647        }
648
649        let scanner_names: Vec<String> = self
650            .scanners
651            .iter()
652            .filter(|s| s.is_active())
653            .map(|s| s.name().to_string())
654            .collect();
655
656        EndpointStats {
657            total_files_scanned: self.files_scanned.load(Ordering::Relaxed),
658            total_threats_detected: self.threats_detected.load(Ordering::Relaxed),
659            active_monitors: active,
660            quarantined_files: self.quarantine.list_entries().len(),
661            last_scan_time: self.history.read().back().map(|r| r.timestamp),
662            scanners_active: scanner_names,
663        }
664    }
665
666    /// Check if the engine is running.
667    pub fn is_running(&self) -> bool {
668        self.running.load(Ordering::SeqCst)
669    }
670}
671
672// =============================================================================
673// Tests
674// =============================================================================
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679
680    #[test]
681    fn test_severity_ordering() {
682        assert!(Severity::Info < Severity::Low);
683        assert!(Severity::Low < Severity::Medium);
684        assert!(Severity::Medium < Severity::High);
685        assert!(Severity::High < Severity::Critical);
686    }
687
688    #[test]
689    fn test_severity_display() {
690        assert_eq!(Severity::Critical.to_string(), "critical");
691        assert_eq!(Severity::Info.to_string(), "info");
692    }
693
694    #[test]
695    fn test_scan_result_creation() {
696        let result = ScanResult::new(
697            "test_scanner",
698            "/tmp/malware.exe",
699            Severity::High,
700            DetectionCategory::MalwareSignature {
701                name: "EICAR".to_string(),
702                family: "Test".to_string(),
703            },
704            "EICAR test file detected",
705            0.99,
706            RecommendedAction::Quarantine {
707                source_path: PathBuf::from("/tmp/malware.exe"),
708            },
709        );
710        assert!(!result.id.is_empty());
711        assert_eq!(result.scanner, "test_scanner");
712        assert_eq!(result.severity, Severity::High);
713        assert_eq!(result.confidence, 0.99);
714    }
715
716    #[test]
717    fn test_scan_result_with_hash() {
718        let result = ScanResult::new(
719            "sig",
720            "/tmp/test",
721            Severity::Low,
722            DetectionCategory::HeuristicAnomaly {
723                rule: "test".to_string(),
724            },
725            "test",
726            0.5,
727            RecommendedAction::LogOnly,
728        )
729        .with_hash("abc123".to_string());
730        assert_eq!(result.artifact_hash, Some("abc123".to_string()));
731    }
732
733    #[test]
734    fn test_confidence_clamping() {
735        let r1 = ScanResult::new(
736            "s", "t", Severity::Low,
737            DetectionCategory::HeuristicAnomaly { rule: "x".into() },
738            "d", 1.5, RecommendedAction::LogOnly,
739        );
740        assert_eq!(r1.confidence, 1.0);
741
742        let r2 = ScanResult::new(
743            "s", "t", Severity::Low,
744            DetectionCategory::HeuristicAnomaly { rule: "x".into() },
745            "d", -0.5, RecommendedAction::LogOnly,
746        );
747        assert_eq!(r2.confidence, 0.0);
748    }
749
750    #[test]
751    fn test_recommended_action_display() {
752        assert_eq!(RecommendedAction::LogOnly.to_string(), "log_only");
753        assert_eq!(RecommendedAction::Alert.to_string(), "alert");
754        assert_eq!(
755            RecommendedAction::KillProcess { pid: 1234 }.to_string(),
756            "kill(1234)"
757        );
758    }
759
760    #[test]
761    fn test_endpoint_config_default() {
762        let config = EndpointConfig::default();
763        assert!(config.enabled);
764        assert!(config.enable_watcher);
765        assert!(config.enable_process_monitor);
766        assert!(!config.enable_memory_scanner); // requires elevated
767        assert!(!config.enable_rootkit_detector); // requires root
768        assert!(!config.enable_dns_filter); // opt-in
769        assert!(config.enable_usb_monitor); // on by default
770        assert!(!config.enable_fim); // opt-in
771    }
772}