480 lines
14 KiB
Rust
480 lines
14 KiB
Rust
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<Finding>, 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<String> {
|
|
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<String> {
|
|
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<u64> {
|
|
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<String> {
|
|
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<Vec<QuarantinedFile>> {
|
|
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::<chrono::Utc>::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<Finding> {
|
|
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<String> {
|
|
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<String> {
|
|
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<chrono::DateTime<chrono::Utc>>,
|
|
pub threat_name: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
|
pub struct LMDStats {
|
|
pub signature_count: u64,
|
|
pub quarantined_count: u32,
|
|
pub last_scan: Option<chrono::DateTime<chrono::Utc>>,
|
|
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<Finding> = 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);
|
|
}
|
|
}
|