use super::database::{CveSummary, Database}; use crate::config::VERSION; /// Export format for security reports. #[derive(Debug, Clone, Copy)] pub enum ReportFormat { Json, Html, Csv, } impl ReportFormat { pub fn from_str(s: &str) -> Option { match s.to_lowercase().as_str() { "json" => Some(Self::Json), "html" => Some(Self::Html), "csv" => Some(Self::Csv), _ => None, } } pub fn extension(&self) -> &'static str { match self { Self::Json => "json", Self::Html => "html", Self::Csv => "csv", } } } /// A single CVE finding in a report. #[derive(Debug, Clone, serde::Serialize)] pub struct ReportCveFinding { pub cve_id: String, pub severity: String, pub cvss_score: Option, pub summary: String, pub library_name: String, pub library_version: String, pub fixed_version: Option, } /// Per-app entry in a report. #[derive(Debug, Clone, serde::Serialize)] pub struct ReportAppEntry { pub name: String, pub version: Option, pub path: String, pub libraries_scanned: usize, pub cve_summary: ReportCveSummaryData, pub findings: Vec, } /// Serializable CVE summary counts. #[derive(Debug, Clone, serde::Serialize)] pub struct ReportCveSummaryData { pub critical: i64, pub high: i64, pub medium: i64, pub low: i64, pub total: i64, } impl From<&CveSummary> for ReportCveSummaryData { fn from(s: &CveSummary) -> Self { Self { critical: s.critical, high: s.high, medium: s.medium, low: s.low, total: s.total(), } } } /// Complete security report. #[derive(Debug, Clone, serde::Serialize)] pub struct SecurityReport { pub generated_at: String, pub driftwood_version: String, pub apps: Vec, pub totals: ReportCveSummaryData, } /// Generate a security report from the database. pub fn build_report(db: &Database, single_app_id: Option) -> SecurityReport { let records = if let Some(id) = single_app_id { db.get_appimage_by_id(id).ok().flatten().into_iter().collect() } else { db.get_all_appimages().unwrap_or_default() }; let mut apps = Vec::new(); let mut total_summary = CveSummary::default(); for record in &records { let libs = db.get_bundled_libraries(record.id).unwrap_or_default(); let cve_matches = db.get_cve_matches(record.id).unwrap_or_default(); let summary = db.get_cve_summary(record.id).unwrap_or_default(); let findings: Vec = cve_matches.iter().map(|m| { ReportCveFinding { cve_id: m.cve_id.clone(), severity: m.severity.clone().unwrap_or_default(), cvss_score: m.cvss_score, summary: m.summary.clone().unwrap_or_default(), library_name: m.library_name.clone().unwrap_or_else(|| m.library_soname.clone()), library_version: m.library_version.clone().unwrap_or_default(), fixed_version: m.fixed_version.clone(), } }).collect(); total_summary.critical += summary.critical; total_summary.high += summary.high; total_summary.medium += summary.medium; total_summary.low += summary.low; apps.push(ReportAppEntry { name: record.app_name.clone().unwrap_or_else(|| record.filename.clone()), version: record.app_version.clone(), path: record.path.clone(), libraries_scanned: libs.len(), cve_summary: ReportCveSummaryData::from(&summary), findings, }); } SecurityReport { generated_at: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(), driftwood_version: VERSION.to_string(), apps, totals: ReportCveSummaryData::from(&total_summary), } } /// Render the report to JSON. pub fn render_json(report: &SecurityReport) -> String { serde_json::to_string_pretty(report).unwrap_or_else(|_| "{}".to_string()) } /// Render the report to CSV. pub fn render_csv(report: &SecurityReport) -> String { let mut out = String::from("App,Version,Path,CVE ID,Severity,CVSS,Library,Library Version,Fixed Version,Summary\n"); for app in &report.apps { if app.findings.is_empty() { out.push_str(&format!( "\"{}\",\"{}\",\"{}\",,,,,,,No CVEs found\n", csv_escape(&app.name), csv_escape(app.version.as_deref().unwrap_or("")), csv_escape(&app.path), )); } else { for f in &app.findings { out.push_str(&format!( "\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",{},\"{}\",\"{}\",\"{}\",\"{}\"\n", csv_escape(&app.name), csv_escape(app.version.as_deref().unwrap_or("")), csv_escape(&app.path), csv_escape(&f.cve_id), csv_escape(&f.severity), f.cvss_score.map(|s| format!("{:.1}", s)).unwrap_or_default(), csv_escape(&f.library_name), csv_escape(&f.library_version), csv_escape(f.fixed_version.as_deref().unwrap_or("")), csv_escape(&f.summary), )); } } } out } fn csv_escape(s: &str) -> String { s.replace('"', "\"\"") } /// Render the report to a standalone HTML document. pub fn render_html(report: &SecurityReport) -> String { let mut html = String::new(); html.push_str("\n\n\n"); html.push_str("\n"); html.push_str("Driftwood Security Report\n"); html.push_str("\n\n\n"); html.push_str("

Driftwood Security Report

\n"); html.push_str(&format!("

Generated: {} | Driftwood v{}

\n", report.generated_at, report.driftwood_version)); // Summary html.push_str("
\n"); html.push_str("

Summary

\n"); html.push_str(&format!("

Apps scanned: {} | Total CVEs: {}

\n", report.apps.len(), report.totals.total)); html.push_str(&format!( "

Critical: {} | High: {} | Medium: {} | Low: {}

\n", report.totals.critical, report.totals.high, report.totals.medium, report.totals.low)); html.push_str("
\n"); // Per-app sections for app in &report.apps { html.push_str(&format!("

{}", html_escape(&app.name))); if let Some(ref ver) = app.version { html.push_str(&format!(" v{}", html_escape(ver))); } html.push_str("

\n"); html.push_str(&format!("

Path: {} | Libraries scanned: {}

\n", html_escape(&app.path), app.libraries_scanned)); if app.findings.is_empty() { html.push_str("

No known vulnerabilities found.

\n"); continue; } html.push_str("\n\n"); for f in &app.findings { let sev_class = f.severity.to_lowercase(); html.push_str(&format!( "\n", html_escape(&f.cve_id), sev_class, html_escape(&f.severity), f.cvss_score.map(|s| format!("{:.1}", s)).unwrap_or_default(), html_escape(&f.library_name), html_escape(&f.library_version), html_escape(f.fixed_version.as_deref().unwrap_or("-")), html_escape(&f.summary), )); } html.push_str("
CVESeverityCVSSLibraryFixed InSummary
{}{}{}{} {}{}{}
\n"); } html.push_str("
\n"); html.push_str("

This report was generated by Driftwood using the OSV.dev vulnerability database. "); html.push_str("Library detection uses heuristics and may not identify all bundled components. "); html.push_str("Results should be treated as advisory, not definitive.

\n"); html.push_str("
\n"); html.push_str("\n\n"); html } fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) } /// Render the report in the given format. pub fn render(report: &SecurityReport, format: ReportFormat) -> String { match format { ReportFormat::Json => render_json(report), ReportFormat::Html => render_html(report), ReportFormat::Csv => render_csv(report), } } #[cfg(test)] mod tests { use super::*; use crate::core::database::Database; #[test] fn test_render_json_empty() { let db = Database::open_in_memory().unwrap(); let report = build_report(&db, None); let json = render_json(&report); assert!(json.contains("\"apps\"")); assert!(json.contains("\"totals\"")); assert!(json.contains("\"driftwood_version\"")); } #[test] fn test_render_csv_header() { let db = Database::open_in_memory().unwrap(); let report = build_report(&db, None); let csv = render_csv(&report); assert!(csv.starts_with("App,Version,Path,CVE ID")); } #[test] fn test_render_html_structure() { let db = Database::open_in_memory().unwrap(); let report = build_report(&db, None); let html = render_html(&report); assert!(html.contains("")); assert!(html.contains("Driftwood Security Report")); assert!(html.contains("")); } #[test] fn test_report_format_from_str() { assert!(matches!(ReportFormat::from_str("json"), Some(ReportFormat::Json))); assert!(matches!(ReportFormat::from_str("HTML"), Some(ReportFormat::Html))); assert!(matches!(ReportFormat::from_str("csv"), Some(ReportFormat::Csv))); assert!(ReportFormat::from_str("xml").is_none()); } #[test] fn test_csv_escape() { assert_eq!(csv_escape("hello \"world\""), "hello \"\"world\"\""); } #[test] fn test_html_escape() { assert_eq!(html_escape("