use anyhow::{Context, Result}; use tracing::info; use crate::security::command_guard::SafeCommand; use super::manager::{Finding, FindingSeverity, ScanResultStatus}; const LMD_QUARANTINE_DIR: &str = "/usr/local/maldetect/quarantine"; pub async fn run_scan(path: Option<&str>) -> Result<(ScanResultStatus, Vec, String)> { info!("Running Linux Malware Detect scan"); let scan_path = path.unwrap_or("/var/www"); let output = SafeCommand::new("sudo")? .arg("maldet")? .arg("-a")? .arg(scan_path)? .execute() .context("Failed to run LMD scan")?; let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let raw_output = format!("{stdout}\n{stderr}"); let findings = parse_lmd_output(&stdout); let status = determine_result_status(&findings); Ok((status, findings, raw_output)) } pub async fn run_background_scan(path: Option<&str>) -> Result { info!("Starting LMD background scan"); let scan_path = path.unwrap_or("/var/www"); let output = SafeCommand::new("sudo")? .arg("maldet")? .arg("-b")? .arg("-a")? .arg(scan_path)? .execute() .context("Failed to start LMD background scan")?; let stdout = String::from_utf8_lossy(&output.stdout); let scan_id = extract_scan_id(&stdout).unwrap_or_else(|| "unknown".to_string()); info!("LMD background scan started with ID: {scan_id}"); Ok(scan_id) } pub async fn update_signatures() -> Result<()> { info!("Updating LMD signatures"); SafeCommand::new("sudo")? .arg("maldet")? .arg("--update-sigs")? .execute() .context("Failed to update LMD signatures")?; info!("LMD signatures updated successfully"); Ok(()) } pub async fn update_version() -> Result<()> { info!("Updating LMD version"); SafeCommand::new("sudo")? .arg("maldet")? .arg("--update-ver")? .execute() .context("Failed to update LMD version")?; info!("LMD version updated successfully"); Ok(()) } pub async fn get_version() -> Result { let output = SafeCommand::new("maldet")? .arg("--version")? .execute() .context("Failed to get LMD version")?; let stdout = String::from_utf8_lossy(&output.stdout); let version = stdout .lines() .find(|l| l.contains("maldet") || l.contains("version")) .and_then(|l| l.split_whitespace().last()) .unwrap_or("unknown") .to_string(); Ok(version) } pub async fn get_signature_count() -> Result { let sig_dir = "/usr/local/maldetect/sigs"; let mut count = 0u64; if let Ok(entries) = std::fs::read_dir(sig_dir) { for entry in entries.flatten() { let path = entry.path(); if path.is_file() { if let Ok(content) = std::fs::read_to_string(&path) { count += content.lines().filter(|l| !l.trim().is_empty()).count() as u64; } } } } Ok(count) } pub async fn quarantine_file(file_path: &str) -> Result<()> { info!("Quarantining file: {file_path}"); SafeCommand::new("sudo")? .arg("maldet")? .arg("-q")? .arg(file_path)? .execute() .context("Failed to quarantine file")?; info!("File quarantined successfully: {file_path}"); Ok(()) } pub async fn restore_file(file_path: &str) -> Result<()> { info!("Restoring file from quarantine: {file_path}"); SafeCommand::new("sudo")? .arg("maldet")? .arg("--restore")? .arg(file_path)? .execute() .context("Failed to restore file from quarantine")?; info!("File restored successfully: {file_path}"); Ok(()) } pub async fn clean_file(file_path: &str) -> Result<()> { info!("Cleaning infected file: {file_path}"); SafeCommand::new("sudo")? .arg("maldet")? .arg("-n")? .arg(file_path)? .execute() .context("Failed to clean file")?; info!("File cleaned successfully: {file_path}"); Ok(()) } pub async fn get_report(scan_id: &str) -> Result { info!("Retrieving LMD report for scan: {scan_id}"); let output = SafeCommand::new("sudo")? .arg("maldet")? .arg("--report")? .arg(scan_id)? .execute() .context("Failed to get LMD report")?; let stdout = String::from_utf8_lossy(&output.stdout).to_string(); Ok(stdout) } pub async fn list_quarantined() -> Result> { let mut files = Vec::new(); if let Ok(entries) = std::fs::read_dir(LMD_QUARANTINE_DIR) { for entry in entries.flatten() { let path = entry.path(); if path.is_file() { let filename = path.file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown") .to_string(); let metadata = std::fs::metadata(&path).ok(); let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0); let quarantined_at = metadata .and_then(|m| m.modified().ok()) .map(|t| chrono::DateTime::::from(t)); files.push(QuarantinedFile { id: filename.clone(), original_path: extract_original_path(&filename), quarantine_path: path.to_string_lossy().to_string(), size, quarantined_at, threat_name: None, }); } } } Ok(files) } pub fn parse_lmd_output(output: &str) -> Vec { let mut findings = Vec::new(); for line in output.lines() { let trimmed = line.trim(); if trimmed.contains("HIT") || trimmed.contains("FOUND") { let parts: Vec<&str> = trimmed.split_whitespace().collect(); let file_path = parts.iter().find(|p| p.starts_with('/')).map(|s| s.to_string()); let threat_name = parts.iter() .find(|p| p.contains("malware") || p.contains("backdoor") || p.contains("trojan")) .map(|s| s.to_string()) .unwrap_or_else(|| "Malware".to_string()); let finding = Finding { id: format!("lmd-hit-{}", findings.len()), severity: FindingSeverity::Critical, category: "Malware Detection".to_string(), title: format!("Malware Detected: {threat_name}"), description: trimmed.to_string(), file_path, remediation: Some("Quarantine or remove the infected file immediately".to_string()), }; findings.push(finding); } if trimmed.contains("suspicious") || trimmed.contains("Suspicious") { let file_path = extract_file_path_from_line(trimmed); let finding = Finding { id: format!("lmd-susp-{}", findings.len()), severity: FindingSeverity::High, category: "Suspicious Activity".to_string(), title: "Suspicious File Detected".to_string(), description: trimmed.to_string(), file_path, remediation: Some("Review the file and consider quarantine if malicious".to_string()), }; findings.push(finding); } if trimmed.contains("warning") || trimmed.contains("Warning") { let finding = Finding { id: format!("lmd-warn-{}", findings.len()), severity: FindingSeverity::Medium, category: "Warning".to_string(), title: "LMD Warning".to_string(), description: trimmed.to_string(), file_path: None, remediation: None, }; findings.push(finding); } } findings } fn extract_scan_id(output: &str) -> Option { for line in output.lines() { if line.contains("scan id:") || line.contains("SCAN ID:") { return line.split(':').nth(1).map(|s| s.trim().to_string()); } if line.contains("report") && line.contains(".") { let parts: Vec<&str> = line.split_whitespace().collect(); for part in parts { if part.contains('.') && part.chars().all(|c| c.is_numeric() || c == '.') { return Some(part.to_string()); } } } } None } fn extract_file_path_from_line(line: &str) -> Option { let words: Vec<&str> = line.split_whitespace().collect(); for word in words { if word.starts_with('/') { return Some(word.trim_matches(|c| c == ':' || c == ',' || c == ';').to_string()); } } None } fn extract_original_path(quarantine_filename: &str) -> String { quarantine_filename .replace(".", "/") .trim_start_matches('/') .to_string() } fn determine_result_status(findings: &[Finding]) -> ScanResultStatus { let has_critical = findings.iter().any(|f| f.severity == FindingSeverity::Critical); let has_high = findings.iter().any(|f| f.severity == FindingSeverity::High); let has_medium = findings.iter().any(|f| f.severity == FindingSeverity::Medium); if has_critical { ScanResultStatus::Infected } else if has_high { ScanResultStatus::Warnings } else if has_medium { ScanResultStatus::Warnings } else { ScanResultStatus::Clean } } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct QuarantinedFile { pub id: String, pub original_path: String, pub quarantine_path: String, pub size: u64, pub quarantined_at: Option>, pub threat_name: Option, } #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub struct LMDStats { pub signature_count: u64, pub quarantined_count: u32, pub last_scan: Option>, pub threats_found: u32, } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_lmd_output_clean() { let output = r#" Linux Malware Detect v1.6.5 Scanning /var/www Total files scanned: 1234 Total hits: 0 Total cleaned: 0 "#; let findings = parse_lmd_output(output); assert!(findings.is_empty()); } #[test] fn test_parse_lmd_output_hit() { let output = r#" Linux Malware Detect v1.6.5 Scanning /var/www {HIT} /var/www/uploads/shell.php : php.cmdshell.unclassed.6 Total hits: 1 "#; let findings = parse_lmd_output(output); assert_eq!(findings.len(), 1); assert_eq!(findings[0].severity, FindingSeverity::Critical); } #[test] fn test_parse_lmd_output_suspicious() { let output = r#" Linux Malware Detect v1.6.5 Scanning /var/www suspicious file found: /var/www/uploads/unknown.php "#; let findings = parse_lmd_output(output); assert_eq!(findings.len(), 1); assert_eq!(findings[0].severity, FindingSeverity::High); } #[test] fn test_extract_scan_id() { assert_eq!( extract_scan_id("scan id: 123456.789"), Some("123456.789".to_string()) ); assert_eq!( extract_scan_id("SCAN ID: abc123"), Some("abc123".to_string()) ); assert_eq!(extract_scan_id("no scan id here"), None); } #[test] fn test_extract_file_path_from_line() { assert_eq!( extract_file_path_from_line("Found malware in /var/www/shell.php"), Some("/var/www/shell.php".to_string()) ); assert_eq!( extract_file_path_from_line("No path here"), None ); } #[test] fn test_extract_original_path() { assert_eq!( extract_original_path("var.www.uploads.shell.php"), "var/www/uploads/shell/php" ); } #[test] fn test_determine_result_status_clean() { let findings: Vec = vec![]; assert_eq!(determine_result_status(&findings), ScanResultStatus::Clean); } #[test] fn test_determine_result_status_infected() { let findings = vec![Finding { id: "test".to_string(), severity: FindingSeverity::Critical, category: "test".to_string(), title: "Test".to_string(), description: "Test".to_string(), file_path: None, remediation: None, }]; assert_eq!(determine_result_status(&findings), ScanResultStatus::Infected); } #[test] fn test_determine_result_status_warnings() { let findings = vec![Finding { id: "test".to_string(), severity: FindingSeverity::High, category: "test".to_string(), title: "Test".to_string(), description: "Test".to_string(), file_path: None, remediation: None, }]; assert_eq!(determine_result_status(&findings), ScanResultStatus::Warnings); } #[test] fn test_quarantined_file_struct() { let file = QuarantinedFile { id: "test".to_string(), original_path: "/var/www/shell.php".to_string(), quarantine_path: "/usr/local/maldetect/quarantine/test".to_string(), size: 1024, quarantined_at: None, threat_name: Some("php.cmdshell".to_string()), }; assert_eq!(file.size, 1024); assert!(file.threat_name.is_some()); } #[test] fn test_lmd_stats_default() { let stats = LMDStats::default(); assert_eq!(stats.signature_count, 0); assert_eq!(stats.quarantined_count, 0); assert!(stats.last_scan.is_none()); } #[test] fn test_parse_lmd_output_warning() { let output = r#" Linux Malware Detect v1.6.5 Warning: signature database may be outdated Scanning /var/www "#; let findings = parse_lmd_output(output); assert_eq!(findings.len(), 1); assert_eq!(findings[0].severity, FindingSeverity::Medium); } #[test] fn test_parse_lmd_output_found() { let output = r#" FOUND: /var/www/malicious.php : malware.backdoor.123 "#; let findings = parse_lmd_output(output); assert_eq!(findings.len(), 1); assert_eq!(findings[0].severity, FindingSeverity::Critical); } }