323 lines
11 KiB
Rust
323 lines
11 KiB
Rust
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>&");
|
|
}
|
|
}
|