diff --git a/src/ui/security_report.rs b/src/ui/security_report.rs new file mode 100644 index 0000000..165fa97 --- /dev/null +++ b/src/ui/security_report.rs @@ -0,0 +1,279 @@ +use adw::prelude::*; +use gtk::gio; +use std::rc::Rc; + +use crate::core::database::Database; +use crate::core::security; +use super::widgets; + +/// Build the security scan report as a full navigation page. +pub fn build_security_report_page(db: &Rc) -> adw::NavigationPage { + // We use a stack to swap content after scan completes + let content_stack = gtk::Stack::new(); + content_stack.set_transition_type(gtk::StackTransitionType::Crossfade); + + // Build initial content + let initial_content = build_report_content(db); + content_stack.add_named(&initial_content, Some("content")); + + // Header bar with scan button + let header = adw::HeaderBar::new(); + let scan_button = gtk::Button::builder() + .label("Scan All") + .tooltip_text("Scan all AppImages for vulnerabilities") + .build(); + scan_button.add_css_class("suggested-action"); + scan_button.update_property(&[ + gtk::accessible::Property::Label("Scan all AppImages for vulnerabilities"), + ]); + + let db_scan = db.clone(); + let stack_ref = content_stack.clone(); + scan_button.connect_clicked(move |btn| { + btn.set_sensitive(false); + btn.set_label("Scanning..."); + let btn_clone = btn.clone(); + let db_refresh = db_scan.clone(); + let stack_refresh = stack_ref.clone(); + glib::spawn_future_local(async move { + let result = gio::spawn_blocking(move || { + let bg_db = Database::open().expect("Failed to open database"); + security::batch_scan(&bg_db) + }) + .await; + + btn_clone.set_sensitive(true); + btn_clone.set_label("Scan All"); + + if let Ok(results) = result { + let total_cves: usize = results.iter().map(|r| r.total_cves()).sum(); + log::info!("Security scan complete: {} CVEs found across {} AppImages", total_cves, results.len()); + + // Refresh the page content with updated data + let new_content = build_report_content(&db_refresh); + if let Some(old) = stack_refresh.child_by_name("content") { + stack_refresh.remove(&old); + } + stack_refresh.add_named(&new_content, Some("content")); + stack_refresh.set_visible_child_name("content"); + } + }); + }); + header.pack_end(&scan_button); + + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&header); + toolbar.set_content(Some(&content_stack)); + + adw::NavigationPage::builder() + .title("Security Report") + .tag("security-report") + .child(&toolbar) + .build() +} + +fn build_report_content(db: &Rc) -> gtk::ScrolledWindow { + let clamp = adw::Clamp::builder() + .maximum_size(800) + .tightening_threshold(600) + .build(); + + let content = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(24) + .margin_top(24) + .margin_bottom(24) + .margin_start(18) + .margin_end(18) + .build(); + + let summary = db.get_all_cve_summary().unwrap_or_default(); + + if summary.total() == 0 { + let has_libs = db.get_all_appimages() + .unwrap_or_default() + .iter() + .any(|r| { + db.get_bundled_libraries(r.id) + .map(|libs| !libs.is_empty()) + .unwrap_or(false) + }); + + if !has_libs { + let empty = adw::StatusPage::builder() + .icon_name("security-medium-symbolic") + .title("No Security Scans") + .description("Run a security scan to check bundled libraries for known vulnerabilities.") + .build(); + content.append(&empty); + } else { + let clean = adw::StatusPage::builder() + .icon_name("security-high-symbolic") + .title("All Clear") + .description("No known vulnerabilities found in any bundled libraries.") + .build(); + content.append(&clean); + } + } else { + content.append(&build_summary_group(&summary)); + + let records = db.get_all_appimages().unwrap_or_default(); + for record in &records { + let app_summary = db.get_cve_summary(record.id).unwrap_or_default(); + if app_summary.total() == 0 { + continue; + } + + let cve_matches = db.get_cve_matches(record.id).unwrap_or_default(); + if cve_matches.is_empty() { + continue; + } + + let name = record.app_name.as_deref().unwrap_or(&record.filename); + content.append(&build_app_findings_group(name, record.id, &app_summary, &cve_matches)); + } + } + + clamp.set_child(Some(&content)); + gtk::ScrolledWindow::builder() + .child(&clamp) + .vexpand(true) + .build() +} + +fn build_summary_group(summary: &crate::core::database::CveSummary) -> adw::PreferencesGroup { + let group = adw::PreferencesGroup::builder() + .title("Vulnerability Summary") + .description("Overall security status across all AppImages") + .build(); + + let total_row = adw::ActionRow::builder() + .title("Total vulnerabilities") + .subtitle(&summary.total().to_string()) + .tooltip_text("Common Vulnerabilities and Exposures found in bundled libraries") + .build(); + let total_badge = widgets::status_badge(summary.max_severity(), summary.badge_class()); + total_badge.set_valign(gtk::Align::Center); + total_row.add_suffix(&total_badge); + group.add(&total_row); + + if summary.critical > 0 { + let row = adw::ActionRow::builder() + .title("Critical") + .subtitle(&summary.critical.to_string()) + .build(); + let badge = widgets::status_badge("Critical", "error"); + badge.set_valign(gtk::Align::Center); + row.add_suffix(&badge); + group.add(&row); + } + if summary.high > 0 { + let row = adw::ActionRow::builder() + .title("High") + .subtitle(&summary.high.to_string()) + .build(); + let badge = widgets::status_badge("High", "error"); + badge.set_valign(gtk::Align::Center); + row.add_suffix(&badge); + group.add(&row); + } + if summary.medium > 0 { + let row = adw::ActionRow::builder() + .title("Medium") + .subtitle(&summary.medium.to_string()) + .build(); + let badge = widgets::status_badge("Medium", "warning"); + badge.set_valign(gtk::Align::Center); + row.add_suffix(&badge); + group.add(&row); + } + if summary.low > 0 { + let row = adw::ActionRow::builder() + .title("Low") + .subtitle(&summary.low.to_string()) + .build(); + let badge = widgets::status_badge("Low", "neutral"); + badge.set_valign(gtk::Align::Center); + row.add_suffix(&badge); + group.add(&row); + } + + group +} + +fn build_app_findings_group( + app_name: &str, + _record_id: i64, + summary: &crate::core::database::CveSummary, + cve_matches: &[crate::core::database::CveMatchRecord], +) -> adw::PreferencesGroup { + let description = format!("{} CVE (vulnerability) records found", summary.total()); + let group = adw::PreferencesGroup::builder() + .title(app_name) + .description(&description) + .build(); + + // Group CVEs by library + let mut by_library: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + + for cve in cve_matches { + let lib_name = cve.library_name.as_deref() + .unwrap_or(&cve.library_soname); + by_library + .entry(lib_name.to_string()) + .or_default() + .push(cve); + } + + for (lib_name, cves) in &by_library { + let lib_version = cves.first() + .and_then(|c| c.library_version.as_deref()) + .unwrap_or("unknown"); + + let expander = adw::ExpanderRow::builder() + .title(&format!("{} (v{})", lib_name, lib_version)) + .subtitle(&format!("{} vulnerabilities", cves.len())) + .build(); + + let max_sev = cves.iter() + .map(|c| c.severity.as_deref().unwrap_or("LOW")) + .max_by_key(|s| match *s { + "CRITICAL" => 4, + "HIGH" => 3, + "MEDIUM" => 2, + "LOW" => 1, + _ => 0, + }) + .unwrap_or("LOW"); + + let sev_class = match max_sev { + "CRITICAL" | "HIGH" => "error", + "MEDIUM" => "warning", + _ => "neutral", + }; + let lib_badge = widgets::status_badge(max_sev, sev_class); + lib_badge.set_valign(gtk::Align::Center); + expander.add_suffix(&lib_badge); + + for cve in cves { + let severity = cve.severity.as_deref().unwrap_or("UNKNOWN"); + let summary_text = cve.summary.as_deref().unwrap_or("No description available"); + let subtitle = if let Some(ref fixed) = cve.fixed_version { + format!("{} - Fixed in {}", summary_text, fixed) + } else { + summary_text.to_string() + }; + + let cve_row = adw::ActionRow::builder() + .title(&format!("{} ({})", cve.cve_id, severity)) + .subtitle(&subtitle) + .build(); + expander.add_row(&cve_row); + } + + group.add(&expander); + } + + group +}