- Create installer.rs for 'botserver install protection' command - Requires root to install packages and create sudoers config - Sudoers uses exact commands (no wildcards) for security - Update all tool files (lynis, rkhunter, chkrootkit, suricata, lmd) to use sudo - Update manager.rs service management to use sudo - Add 'sudo' and 'visudo' to command_guard.rs whitelist - Update CLI with install/remove/status protection commands Security model: - Installation requires root (sudo botserver install protection) - Runtime uses sudoers NOPASSWD for specific commands only - No wildcards in sudoers - exact command specifications - Tools run on host system, not in containers
320 lines
10 KiB
Rust
320 lines
10 KiB
Rust
use anyhow::{Context, Result};
|
|
use tracing::info;
|
|
|
|
use crate::security::command_guard::SafeCommand;
|
|
use super::manager::{Finding, FindingSeverity, ScanResultStatus};
|
|
|
|
const RKHUNTER_LOG_PATH: &str = "/var/log/rkhunter.log";
|
|
|
|
pub async fn run_scan() -> Result<(ScanResultStatus, Vec<Finding>, String)> {
|
|
info!("Running RKHunter rootkit scan");
|
|
|
|
let output = SafeCommand::new("sudo")?
|
|
.arg("rkhunter")?
|
|
.arg("--check")?
|
|
.arg("--skip-keypress")?
|
|
.arg("--report-warnings-only")?
|
|
.execute()
|
|
.context("Failed to run RKHunter 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_rkhunter_output(&stdout);
|
|
let status = determine_result_status(&findings);
|
|
|
|
Ok((status, findings, raw_output))
|
|
}
|
|
|
|
pub async fn update_database() -> Result<()> {
|
|
info!("Updating RKHunter database");
|
|
|
|
SafeCommand::new("sudo")?
|
|
.arg("rkhunter")?
|
|
.arg("--update")?
|
|
.execute()
|
|
.context("Failed to update RKHunter database")?;
|
|
|
|
info!("RKHunter database updated successfully");
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn update_properties() -> Result<()> {
|
|
info!("Updating RKHunter file properties database");
|
|
|
|
SafeCommand::new("sudo")?
|
|
.arg("rkhunter")?
|
|
.arg("--propupd")?
|
|
.execute()
|
|
.context("Failed to update RKHunter properties")?;
|
|
|
|
info!("RKHunter properties updated successfully");
|
|
Ok(())
|
|
}
|
|
|
|
pub fn parse_rkhunter_output(output: &str) -> Vec<Finding> {
|
|
let mut findings = Vec::new();
|
|
let mut current_section = String::new();
|
|
|
|
for line in output.lines() {
|
|
let trimmed = line.trim();
|
|
|
|
if trimmed.starts_with("Checking") {
|
|
current_section = trimmed.replace("Checking", "").trim().to_string();
|
|
continue;
|
|
}
|
|
|
|
if trimmed.contains("[ Warning ]") || trimmed.contains("[Warning]") {
|
|
let description = extract_warning_description(trimmed);
|
|
let finding = Finding {
|
|
id: format!("rkhunter-warn-{}", findings.len()),
|
|
severity: FindingSeverity::High,
|
|
category: current_section.clone(),
|
|
title: "RKHunter Warning".to_string(),
|
|
description,
|
|
file_path: extract_file_path(trimmed),
|
|
remediation: Some("Investigate the flagged file or configuration".to_string()),
|
|
};
|
|
findings.push(finding);
|
|
}
|
|
|
|
if trimmed.contains("[ Rootkit ]") || trimmed.to_lowercase().contains("rootkit found") {
|
|
let finding = Finding {
|
|
id: format!("rkhunter-rootkit-{}", findings.len()),
|
|
severity: FindingSeverity::Critical,
|
|
category: "Rootkit Detection".to_string(),
|
|
title: "Potential Rootkit Detected".to_string(),
|
|
description: trimmed.to_string(),
|
|
file_path: extract_file_path(trimmed),
|
|
remediation: Some("Immediately investigate and consider system recovery".to_string()),
|
|
};
|
|
findings.push(finding);
|
|
}
|
|
|
|
if trimmed.contains("Suspicious file") || trimmed.contains("suspicious") {
|
|
let finding = Finding {
|
|
id: format!("rkhunter-susp-{}", findings.len()),
|
|
severity: FindingSeverity::High,
|
|
category: current_section.clone(),
|
|
title: "Suspicious File Detected".to_string(),
|
|
description: trimmed.to_string(),
|
|
file_path: extract_file_path(trimmed),
|
|
remediation: Some("Verify the file integrity and source".to_string()),
|
|
};
|
|
findings.push(finding);
|
|
}
|
|
|
|
if trimmed.contains("[ Bad ]") {
|
|
let finding = Finding {
|
|
id: format!("rkhunter-bad-{}", findings.len()),
|
|
severity: FindingSeverity::High,
|
|
category: current_section.clone(),
|
|
title: "Bad Configuration or File".to_string(),
|
|
description: trimmed.to_string(),
|
|
file_path: extract_file_path(trimmed),
|
|
remediation: Some("Review and correct the flagged item".to_string()),
|
|
};
|
|
findings.push(finding);
|
|
}
|
|
}
|
|
|
|
findings
|
|
}
|
|
|
|
pub fn parse_log_file() -> Result<RKHunterReport> {
|
|
let content = std::fs::read_to_string(RKHUNTER_LOG_PATH)
|
|
.context("Failed to read RKHunter log file")?;
|
|
|
|
let mut report = RKHunterReport::default();
|
|
|
|
for line in content.lines() {
|
|
if line.contains("Rootkits checked") {
|
|
if let Some(count) = extract_number_from_line(line) {
|
|
report.rootkits_checked = count;
|
|
}
|
|
}
|
|
|
|
if line.contains("Possible rootkits") {
|
|
if let Some(count) = extract_number_from_line(line) {
|
|
report.possible_rootkits = count;
|
|
}
|
|
}
|
|
|
|
if line.contains("Suspect files") {
|
|
if let Some(count) = extract_number_from_line(line) {
|
|
report.suspect_files = count;
|
|
}
|
|
}
|
|
|
|
if line.contains("Warning:") {
|
|
report.warnings.push(line.replace("Warning:", "").trim().to_string());
|
|
}
|
|
|
|
if line.contains("rkhunter version") {
|
|
report.version = line.split(':').nth(1).unwrap_or("").trim().to_string();
|
|
}
|
|
}
|
|
|
|
Ok(report)
|
|
}
|
|
|
|
pub async fn get_version() -> Result<String> {
|
|
let output = SafeCommand::new("rkhunter")?
|
|
.arg("--version")?
|
|
.execute()
|
|
.context("Failed to get RKHunter version")?;
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let version = stdout
|
|
.lines()
|
|
.find(|l| l.contains("version"))
|
|
.and_then(|l| l.split_whitespace().last())
|
|
.unwrap_or("unknown")
|
|
.to_string();
|
|
|
|
Ok(version)
|
|
}
|
|
|
|
fn extract_warning_description(line: &str) -> String {
|
|
line.replace("[ Warning ]", "")
|
|
.replace("[Warning]", "")
|
|
.trim()
|
|
.to_string()
|
|
}
|
|
|
|
fn extract_file_path(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_number_from_line(line: &str) -> Option<u32> {
|
|
line.split_whitespace()
|
|
.find_map(|word| word.parse::<u32>().ok())
|
|
}
|
|
|
|
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, Default)]
|
|
pub struct RKHunterReport {
|
|
pub version: String,
|
|
pub rootkits_checked: u32,
|
|
pub possible_rootkits: u32,
|
|
pub suspect_files: u32,
|
|
pub warnings: Vec<String>,
|
|
pub scan_time: Option<String>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_parse_rkhunter_output_clean() {
|
|
let output = r#"
|
|
Checking for rootkits...
|
|
Performing check of known rootkit files and directories
|
|
55808 Trojan - Variant A [ Not found ]
|
|
ADM Worm [ Not found ]
|
|
AjaKit Rootkit [ Not found ]
|
|
System checks summary
|
|
=====================
|
|
File properties checks...
|
|
Files checked: 142
|
|
Suspect files: 0
|
|
"#;
|
|
let findings = parse_rkhunter_output(output);
|
|
assert!(findings.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_rkhunter_output_warning() {
|
|
let output = r#"
|
|
Checking for rootkits...
|
|
Checking /dev for suspicious file types [ Warning ]
|
|
Suspicious file found: /dev/.udev/something
|
|
"#;
|
|
let findings = parse_rkhunter_output(output);
|
|
assert!(!findings.is_empty());
|
|
assert_eq!(findings[0].severity, FindingSeverity::High);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_file_path() {
|
|
assert_eq!(
|
|
extract_file_path("Suspicious file found: /etc/passwd"),
|
|
Some("/etc/passwd".to_string())
|
|
);
|
|
assert_eq!(
|
|
extract_file_path("Checking /dev/sda for issues"),
|
|
Some("/dev/sda".to_string())
|
|
);
|
|
assert_eq!(extract_file_path("No path here"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_number_from_line() {
|
|
assert_eq!(extract_number_from_line("Rootkits checked: 42"), Some(42));
|
|
assert_eq!(extract_number_from_line("Found 5 issues"), Some(5));
|
|
assert_eq!(extract_number_from_line("No numbers here"), None);
|
|
}
|
|
|
|
#[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_critical() {
|
|
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_rkhunter_report_default() {
|
|
let report = RKHunterReport::default();
|
|
assert_eq!(report.rootkits_checked, 0);
|
|
assert_eq!(report.possible_rootkits, 0);
|
|
assert!(report.warnings.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_warning_description() {
|
|
assert_eq!(
|
|
extract_warning_description("[ Warning ] Some issue here"),
|
|
"Some issue here"
|
|
);
|
|
assert_eq!(
|
|
extract_warning_description("[Warning] Another issue"),
|
|
"Another issue"
|
|
);
|
|
}
|
|
}
|