use crate::{AttackReport, ParsedTarget, Severity};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanReport {
pub timestamp: String,
pub targets: Vec<String>,
pub summary: ScanSummary,
pub findings: Vec<Finding>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanSummary {
pub total_targets: usize,
pub total_vulnerabilities: usize,
pub exploited: usize,
pub by_severity: SeverityBreakdown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SeverityBreakdown {
pub critical: usize,
pub high: usize,
pub medium: usize,
pub low: usize,
pub informational: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Finding {
pub target: String,
pub module_id: String,
pub severity: String,
pub exploited: bool,
pub evidence: Vec<String>,
pub remediation: Vec<String>,
}
pub fn generate_report(results: Vec<(ParsedTarget, Vec<AttackReport>)>) -> ScanReport {
let timestamp = chrono::Utc::now().to_rfc3339();
let mut targets = Vec::new();
let mut findings = Vec::new();
let mut exploited_count = 0;
let mut severity_counts = SeverityBreakdown {
critical: 0,
high: 0,
medium: 0,
low: 0,
informational: 0,
};
for (target, reports) in results {
targets.push(target.url.clone());
for report in reports {
match report.severity {
Severity::Critical => severity_counts.critical += 1,
Severity::High => severity_counts.high += 1,
Severity::Medium => severity_counts.medium += 1,
Severity::Low => severity_counts.low += 1,
Severity::Informational => severity_counts.informational += 1,
}
if report.exploited {
exploited_count += 1;
}
findings.push(Finding {
target: target.url.clone(),
module_id: report.module_id.as_str().to_string(),
severity: format!("{:?}", report.severity).to_lowercase(),
exploited: report.exploited,
evidence: report.evidence,
remediation: report.remediation,
});
}
}
let total_vulnerabilities = findings.len();
let total_targets = targets.len();
ScanReport {
timestamp,
targets,
summary: ScanSummary {
total_targets,
total_vulnerabilities,
exploited: exploited_count,
by_severity: severity_counts,
},
findings,
}
}
pub fn save_json_report(report: &ScanReport, path: impl AsRef<Path>) -> Result<()> {
let json = serde_json::to_string_pretty(report)?;
std::fs::write(path, json)?;
Ok(())
}
pub fn format_terminal_report(report: &ScanReport) -> String {
let mut output = String::new();
output.push_str("\n");
output.push_str("═══════════════════════════════════════════════════════════════\n");
output.push_str(" Scan Report \n");
output.push_str("═══════════════════════════════════════════════════════════════\n");
output.push_str("\n");
output.push_str(&format!("Timestamp: {}\n", report.timestamp));
output.push_str(&format!("Targets Scanned: {} target{}\n",
report.summary.total_targets,
if report.summary.total_targets == 1 { "" } else { "s" }
));
output.push_str("\n");
output.push_str("Vulnerability Summary:\n");
output.push_str(&format!(" Total: {}\n", report.summary.total_vulnerabilities));
output.push_str(&format!(" Exploited: {} ({:.1}%)\n",
report.summary.exploited,
if report.summary.total_vulnerabilities > 0 {
(report.summary.exploited as f64 / report.summary.total_vulnerabilities as f64) * 100.0
} else {
0.0
}
));
output.push_str("\n");
output.push_str("By Severity:\n");
output.push_str(&format!(" 🔴 Critical: {}\n", report.summary.by_severity.critical));
output.push_str(&format!(" 🟠 High: {}\n", report.summary.by_severity.high));
output.push_str(&format!(" 🟡 Medium: {}\n", report.summary.by_severity.medium));
output.push_str(&format!(" 🟢 Low: {}\n", report.summary.by_severity.low));
output.push_str(&format!(" ⚪ Informational: {}\n", report.summary.by_severity.informational));
output.push_str("\n");
output.push_str("═══════════════════════════════════════════════════════════════\n");
output.push_str("⚠️ CONFIGURATION WARNING\n");
output.push_str("═══════════════════════════════════════════════════════════════\n");
output.push_str("\n");
output.push_str("This scan successfully connected to all targets, which means:\n");
output.push_str("• Gateway is NOT bound to 127.0.0.1 (publicly accessible)\n");
output.push_str("• No network firewall blocking port access\n");
output.push_str("• Default security posture is likely insecure\n");
output.push_str("\n");
output.push_str("IMMEDIATE ACTIONS:\n");
output.push_str("• Run: openclaw security audit --deep\n");
output.push_str("• Run: openclaw security audit --fix\n");
output.push_str("• Bind gateway to 127.0.0.1 in production\n");
output.push_str("• Enable --auth flag for authentication\n");
output.push_str("• Disable mDNS: CLAWDBOT_DISABLE_BONJOUR=1\n");
output.push_str("• Change default port 18789 to non-standard port\n");
output.push_str("\n");
output.push_str("═══════════════════════════════════════════════════════════════\n");
output.push_str("\n");
let exploited_findings: Vec<_> = report.findings.iter()
.filter(|f| f.exploited)
.collect();
if !exploited_findings.is_empty() {
output.push_str("⚠️ EXPLOITED VULNERABILITIES:\n");
output.push_str("───────────────────────────────────────────────────────────────\n");
for finding in exploited_findings {
output.push_str(&format!("\n[{}] {} - {}\n",
finding.severity.to_uppercase(),
finding.module_id,
finding.target
));
if !finding.evidence.is_empty() {
output.push_str("Evidence:\n");
for evidence in &finding.evidence {
output.push_str(&format!(" • {}\n", evidence));
}
}
if !finding.remediation.is_empty() {
output.push_str("Remediation:\n");
for remediation in &finding.remediation {
output.push_str(&format!(" ✓ {}\n", remediation));
}
}
}
output.push_str("\n");
}
output.push_str("═══════════════════════════════════════════════════════════════\n");
output
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{AttackModuleId, Severity as SevEnum};
#[test]
fn test_generate_report_structure() {
let target = ParsedTarget {
url: "ws://test.com:18789".to_string(),
host: "test.com".to_string(),
port: 18789,
};
let reports = vec![AttackReport {
module_id: AttackModuleId::Cve202625253,
exploited: true,
severity: SevEnum::Critical,
evidence: vec!["Token captured".to_string()],
remediation: vec!["Upgrade".to_string()],
}];
let scan_report = generate_report(vec![(target, reports)]);
assert_eq!(scan_report.summary.total_targets, 1);
assert_eq!(scan_report.summary.total_vulnerabilities, 1);
assert_eq!(scan_report.summary.exploited, 1);
assert_eq!(scan_report.summary.by_severity.critical, 1);
assert_eq!(scan_report.findings.len(), 1);
}
#[test]
fn test_severity_breakdown() {
let target = ParsedTarget {
url: "ws://test.com:18789".to_string(),
host: "test.com".to_string(),
port: 18789,
};
let reports = vec![
AttackReport {
module_id: AttackModuleId::Cve202625253,
exploited: true,
severity: SevEnum::Critical,
evidence: vec![],
remediation: vec![],
},
AttackReport {
module_id: AttackModuleId::PromptInjection,
exploited: false,
severity: SevEnum::High,
evidence: vec![],
remediation: vec![],
},
];
let scan_report = generate_report(vec![(target, reports)]);
assert_eq!(scan_report.summary.by_severity.critical, 1);
assert_eq!(scan_report.summary.by_severity.high, 1);
}
#[test]
fn test_format_terminal_report() {
let report = ScanReport {
timestamp: "2026-02-05T12:00:00Z".to_string(),
targets: vec!["ws://test.com:18789".to_string()],
summary: ScanSummary {
total_targets: 1,
total_vulnerabilities: 1,
exploited: 1,
by_severity: SeverityBreakdown {
critical: 1,
high: 0,
medium: 0,
low: 0,
informational: 0,
},
},
findings: vec![],
};
let output = format_terminal_report(&report);
assert!(output.contains("Scan Report"));
assert!(output.contains("1 target"));
assert!(output.contains("Exploited: 1"));
assert!(output.contains("CONFIGURATION WARNING"));
assert!(output.contains("publicly accessible"));
}
}