272 lines
8.6 KiB
Rust
272 lines
8.6 KiB
Rust
use anyhow::{Context, Result};
|
|
use tracing::{info, warn};
|
|
|
|
use crate::security::command_guard::SafeCommand;
|
|
use super::manager::{Finding, FindingSeverity, ScanResultStatus};
|
|
|
|
const LYNIS_REPORT_PATH: &str = "/var/log/lynis-report.dat";
|
|
|
|
pub async fn run_scan() -> Result<(ScanResultStatus, Vec<Finding>, String)> {
|
|
info!("Running Lynis security audit");
|
|
|
|
let output = SafeCommand::new("sudo")?
|
|
.arg("lynis")?
|
|
.arg("audit")?
|
|
.arg("system")?
|
|
.arg("--quick")?
|
|
.arg("--no-colors")?
|
|
.execute()
|
|
.context("Failed to run Lynis audit")?;
|
|
|
|
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_lynis_output(&stdout);
|
|
let status = determine_result_status(&findings);
|
|
|
|
Ok((status, findings, raw_output))
|
|
}
|
|
|
|
pub async fn run_full_audit() -> Result<(ScanResultStatus, Vec<Finding>, String)> {
|
|
info!("Running full Lynis security audit");
|
|
|
|
let output = SafeCommand::new("sudo")?
|
|
.arg("lynis")?
|
|
.arg("audit")?
|
|
.arg("system")?
|
|
.arg("--no-colors")?
|
|
.execute()
|
|
.context("Failed to run full Lynis audit")?;
|
|
|
|
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_lynis_output(&stdout);
|
|
let status = determine_result_status(&findings);
|
|
|
|
Ok((status, findings, raw_output))
|
|
}
|
|
|
|
pub fn parse_lynis_output(output: &str) -> Vec<Finding> {
|
|
let mut findings = Vec::new();
|
|
let mut current_category = String::new();
|
|
|
|
for line in output.lines() {
|
|
let trimmed = line.trim();
|
|
|
|
if trimmed.starts_with('[') && trimmed.contains(']') {
|
|
if let Some(category) = extract_category(trimmed) {
|
|
current_category = category;
|
|
}
|
|
}
|
|
|
|
if trimmed.contains("Warning:") || trimmed.contains("WARNING") {
|
|
let finding = Finding {
|
|
id: format!("lynis-warn-{}", findings.len()),
|
|
severity: FindingSeverity::Medium,
|
|
category: current_category.clone(),
|
|
title: "Security Warning".to_string(),
|
|
description: trimmed.replace("Warning:", "").trim().to_string(),
|
|
file_path: None,
|
|
remediation: None,
|
|
};
|
|
findings.push(finding);
|
|
}
|
|
|
|
if trimmed.contains("Suggestion:") || trimmed.contains("SUGGESTION") {
|
|
let finding = Finding {
|
|
id: format!("lynis-sugg-{}", findings.len()),
|
|
severity: FindingSeverity::Low,
|
|
category: current_category.clone(),
|
|
title: "Security Suggestion".to_string(),
|
|
description: trimmed.replace("Suggestion:", "").trim().to_string(),
|
|
file_path: None,
|
|
remediation: extract_remediation(trimmed),
|
|
};
|
|
findings.push(finding);
|
|
}
|
|
|
|
if trimmed.contains("[FOUND]") && trimmed.contains("vulnerable") {
|
|
let finding = Finding {
|
|
id: format!("lynis-vuln-{}", findings.len()),
|
|
severity: FindingSeverity::High,
|
|
category: current_category.clone(),
|
|
title: "Vulnerability Found".to_string(),
|
|
description: trimmed.to_string(),
|
|
file_path: None,
|
|
remediation: None,
|
|
};
|
|
findings.push(finding);
|
|
}
|
|
}
|
|
|
|
findings
|
|
}
|
|
|
|
pub fn parse_report_file() -> Result<LynisReport> {
|
|
let content = std::fs::read_to_string(LYNIS_REPORT_PATH)
|
|
.context("Failed to read Lynis report file")?;
|
|
|
|
let mut report = LynisReport::default();
|
|
|
|
for line in content.lines() {
|
|
if line.starts_with('#') || line.trim().is_empty() {
|
|
continue;
|
|
}
|
|
|
|
if let Some((key, value)) = line.split_once('=') {
|
|
match key {
|
|
"hardening_index" => {
|
|
report.hardening_index = value.parse().unwrap_or(0);
|
|
}
|
|
"warning[]" => {
|
|
report.warnings.push(value.to_string());
|
|
}
|
|
"suggestion[]" => {
|
|
report.suggestions.push(value.to_string());
|
|
}
|
|
"lynis_version" => {
|
|
report.version = value.to_string();
|
|
}
|
|
"test_category[]" => {
|
|
report.categories_tested.push(value.to_string());
|
|
}
|
|
"tests_executed" => {
|
|
report.tests_executed = value.parse().unwrap_or(0);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(report)
|
|
}
|
|
|
|
pub async fn get_hardening_index() -> Result<u32> {
|
|
let report = parse_report_file()?;
|
|
Ok(report.hardening_index)
|
|
}
|
|
|
|
pub async fn apply_suggestion(suggestion_id: &str) -> Result<()> {
|
|
info!("Applying Lynis suggestion: {suggestion_id}");
|
|
|
|
warn!("Auto-remediation for suggestion {suggestion_id} not yet implemented");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn extract_category(line: &str) -> Option<String> {
|
|
let start = line.find('[')?;
|
|
let end = line.find(']')?;
|
|
if start < end {
|
|
Some(line[start + 1..end].trim().to_string())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn extract_remediation(line: &str) -> Option<String> {
|
|
if line.contains("Consider") {
|
|
Some(line.split("Consider").nth(1)?.trim().to_string())
|
|
} else if line.contains("Disable") {
|
|
Some(line.split("Disable").nth(1).map(|s| format!("Disable {}", s.trim()))?)
|
|
} else if line.contains("Enable") {
|
|
Some(line.split("Enable").nth(1).map(|s| format!("Enable {}", s.trim()))?)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
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 || has_high {
|
|
ScanResultStatus::Infected
|
|
} else if has_medium {
|
|
ScanResultStatus::Warnings
|
|
} else {
|
|
ScanResultStatus::Clean
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct LynisReport {
|
|
pub version: String,
|
|
pub hardening_index: u32,
|
|
pub tests_executed: u32,
|
|
pub warnings: Vec<String>,
|
|
pub suggestions: Vec<String>,
|
|
pub categories_tested: Vec<String>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_parse_lynis_output_warnings() {
|
|
let output = r#"
|
|
[+] Boot and services
|
|
- Service Manager [ systemd ]
|
|
Warning: Some warning message here
|
|
[+] Kernel
|
|
Suggestion: Consider enabling some feature
|
|
"#;
|
|
let findings = parse_lynis_output(output);
|
|
assert_eq!(findings.len(), 2);
|
|
assert_eq!(findings[0].severity, FindingSeverity::Medium);
|
|
assert_eq!(findings[1].severity, FindingSeverity::Low);
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_category() {
|
|
assert_eq!(extract_category("[+] Boot and services"), Some("+ Boot and services".to_string()));
|
|
assert_eq!(extract_category("no brackets"), 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_warnings() {
|
|
let findings = vec![Finding {
|
|
id: "test".to_string(),
|
|
severity: FindingSeverity::Medium,
|
|
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_determine_result_status_infected() {
|
|
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::Infected);
|
|
}
|
|
|
|
#[test]
|
|
fn test_lynis_report_default() {
|
|
let report = LynisReport::default();
|
|
assert_eq!(report.hardening_index, 0);
|
|
assert!(report.warnings.is_empty());
|
|
assert!(report.suggestions.is_empty());
|
|
}
|
|
}
|