Add Phase 5 enhancements: security, i18n, analysis, backup, notifications
This commit is contained in:
322
src/core/report.rs
Normal file
322
src/core/report.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
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<Self> {
|
||||
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<f64>,
|
||||
pub summary: String,
|
||||
pub library_name: String,
|
||||
pub library_version: String,
|
||||
pub fixed_version: Option<String>,
|
||||
}
|
||||
|
||||
/// Per-app entry in a report.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ReportAppEntry {
|
||||
pub name: String,
|
||||
pub version: Option<String>,
|
||||
pub path: String,
|
||||
pub libraries_scanned: usize,
|
||||
pub cve_summary: ReportCveSummaryData,
|
||||
pub findings: Vec<ReportCveFinding>,
|
||||
}
|
||||
|
||||
/// 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<ReportAppEntry>,
|
||||
pub totals: ReportCveSummaryData,
|
||||
}
|
||||
|
||||
/// Generate a security report from the database.
|
||||
pub fn build_report(db: &Database, single_app_id: Option<i64>) -> 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<ReportCveFinding> = 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("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
|
||||
html.push_str("<meta charset=\"UTF-8\">\n");
|
||||
html.push_str("<title>Driftwood Security Report</title>\n");
|
||||
html.push_str("<style>\n");
|
||||
html.push_str("body { font-family: system-ui, -apple-system, sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1em; color: #333; }\n");
|
||||
html.push_str("h1 { border-bottom: 2px solid #333; padding-bottom: 0.3em; }\n");
|
||||
html.push_str("h2 { margin-top: 2em; }\n");
|
||||
html.push_str("table { border-collapse: collapse; width: 100%; margin: 1em 0; }\n");
|
||||
html.push_str("th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }\n");
|
||||
html.push_str("th { background: #f5f5f5; }\n");
|
||||
html.push_str(".critical { color: #d32f2f; font-weight: bold; }\n");
|
||||
html.push_str(".high { color: #e65100; font-weight: bold; }\n");
|
||||
html.push_str(".medium { color: #f9a825; }\n");
|
||||
html.push_str(".low { color: #666; }\n");
|
||||
html.push_str(".summary-box { background: #f5f5f5; border-radius: 8px; padding: 1em; margin: 1em 0; }\n");
|
||||
html.push_str("footer { margin-top: 3em; padding-top: 1em; border-top: 1px solid #ddd; font-size: 0.85em; color: #666; }\n");
|
||||
html.push_str("</style>\n</head>\n<body>\n");
|
||||
|
||||
html.push_str("<h1>Driftwood Security Report</h1>\n");
|
||||
html.push_str(&format!("<p>Generated: {} | Driftwood v{}</p>\n",
|
||||
report.generated_at, report.driftwood_version));
|
||||
|
||||
// Summary
|
||||
html.push_str("<div class=\"summary-box\">\n");
|
||||
html.push_str("<h2>Summary</h2>\n");
|
||||
html.push_str(&format!("<p>Apps scanned: {} | Total CVEs: {}</p>\n",
|
||||
report.apps.len(), report.totals.total));
|
||||
html.push_str(&format!(
|
||||
"<p><span class=\"critical\">Critical: {}</span> | <span class=\"high\">High: {}</span> | <span class=\"medium\">Medium: {}</span> | <span class=\"low\">Low: {}</span></p>\n",
|
||||
report.totals.critical, report.totals.high, report.totals.medium, report.totals.low));
|
||||
html.push_str("</div>\n");
|
||||
|
||||
// Per-app sections
|
||||
for app in &report.apps {
|
||||
html.push_str(&format!("<h2>{}", html_escape(&app.name)));
|
||||
if let Some(ref ver) = app.version {
|
||||
html.push_str(&format!(" v{}", html_escape(ver)));
|
||||
}
|
||||
html.push_str("</h2>\n");
|
||||
html.push_str(&format!("<p>Path: <code>{}</code> | Libraries scanned: {}</p>\n",
|
||||
html_escape(&app.path), app.libraries_scanned));
|
||||
|
||||
if app.findings.is_empty() {
|
||||
html.push_str("<p>No known vulnerabilities found.</p>\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
html.push_str("<table>\n<tr><th>CVE</th><th>Severity</th><th>CVSS</th><th>Library</th><th>Fixed In</th><th>Summary</th></tr>\n");
|
||||
for f in &app.findings {
|
||||
let sev_class = f.severity.to_lowercase();
|
||||
html.push_str(&format!(
|
||||
"<tr><td>{}</td><td class=\"{}\">{}</td><td>{}</td><td>{} {}</td><td>{}</td><td>{}</td></tr>\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("</table>\n");
|
||||
}
|
||||
|
||||
html.push_str("<footer>\n");
|
||||
html.push_str("<p>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.</p>\n");
|
||||
html.push_str("</footer>\n");
|
||||
html.push_str("</body>\n</html>\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("<!DOCTYPE html>"));
|
||||
assert!(html.contains("Driftwood Security Report"));
|
||||
assert!(html.contains("</html>"));
|
||||
}
|
||||
|
||||
#[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("<script>&"), "<script>&");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user