Add Phase 5 enhancements: security, i18n, analysis, backup, notifications

This commit is contained in:
2026-02-27 17:16:41 +02:00
parent 870ef2a739
commit 40f7f12834
50 changed files with 10446 additions and 481 deletions

322
src/core/report.rs Normal file
View 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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
/// 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>&"), "&lt;script&gt;&amp;");
}
}