clawscan 1.0.0

OpenClaw/Moltbot/Clawdbot vulnerability scanner for prompt injection, supply chain, and RAG poisoning attacks
Documentation
//! Report generation and formatting for vulnerability scan results

use crate::{AttackReport, ParsedTarget, Severity};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::Path;

/// Complete scan report
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanReport {
    pub timestamp: String,
    pub targets: Vec<String>,
    pub summary: ScanSummary,
    pub findings: Vec<Finding>,
}

/// Scan summary statistics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanSummary {
    pub total_targets: usize,
    pub total_vulnerabilities: usize,
    pub exploited: usize,
    pub by_severity: SeverityBreakdown,
}

/// Vulnerability count by severity
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SeverityBreakdown {
    pub critical: usize,
    pub high: usize,
    pub medium: usize,
    pub low: usize,
    pub informational: usize,
}

/// Individual vulnerability finding
#[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>,
}

/// Generate scan report from target results
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 {
            // Count by severity
            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,
            }

            // Count exploited
            if report.exploited {
                exploited_count += 1;
            }

            // Create finding
            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,
    }
}

/// Save report as JSON file
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(())
}

/// Format report as terminal output
pub fn format_terminal_report(report: &ScanReport) -> String {
    let mut output = String::new();

    // Header
    output.push_str("\n");
    output.push_str("═══════════════════════════════════════════════════════════════\n");
    output.push_str("                      Scan Report                              \n");
    output.push_str("═══════════════════════════════════════════════════════════════\n");
    output.push_str("\n");

    // Summary
    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");

    // Vulnerability Summary
    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");

    // Severity Breakdown
    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");

    // Configuration Warning
    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");

    // Exploited Findings
    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"));
    }
}