use adw::prelude::*; use gtk::gio; use std::rc::Rc; use crate::config::APP_ID; use crate::core::database::Database; use crate::core::notification; use crate::core::report; 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 and export buttons let header = adw::HeaderBar::new(); // Export button let export_button = gtk::Button::builder() .label("Export") .tooltip_text("Save this report as HTML, JSON, or CSV") .build(); export_button.update_property(&[ gtk::accessible::Property::Label("Export security report as HTML, JSON, or CSV"), ]); // Scan button 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(); for r in &results { log::info!( "Security scan: appimage_id={} found {} CVEs", r.appimage_id, r.total_cves(), ); } log::info!("Security scan complete: {} CVEs found across {} AppImages", total_cves, results.len()); widgets::announce( &stack_refresh, &format!("Security scan complete: {} vulnerabilities found", total_cves), ); // 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"); // Send desktop notifications for new CVE findings if enabled let settings = gio::Settings::new(APP_ID); if settings.boolean("security-notifications") { let threshold = settings.string("security-notification-threshold").to_string(); glib::spawn_future_local(async move { let _ = gio::spawn_blocking(move || { let bg_db = Database::open().expect("Failed to open database"); notification::check_and_notify(&bg_db, &threshold); }) .await; }); } } }); }); header.pack_end(&scan_button); header.pack_end(&export_button); // Toast overlay wraps the toolbar for feedback let toast_overlay = adw::ToastOverlay::new(); let toolbar = adw::ToolbarView::new(); toolbar.add_top_bar(&header); toolbar.set_content(Some(&content_stack)); toast_overlay.set_child(Some(&toolbar)); // Wire export button let db_export = db.clone(); let toast_ref = toast_overlay.clone(); export_button.connect_clicked(move |btn| { let html_filter = gtk::FileFilter::new(); html_filter.set_name(Some("HTML (*.html)")); html_filter.add_pattern("*.html"); html_filter.add_pattern("*.htm"); let json_filter = gtk::FileFilter::new(); json_filter.set_name(Some("JSON (*.json)")); json_filter.add_pattern("*.json"); let csv_filter = gtk::FileFilter::new(); csv_filter.set_name(Some("CSV (*.csv)")); csv_filter.add_pattern("*.csv"); let filters = gio::ListStore::new::(); filters.append(&html_filter); filters.append(&json_filter); filters.append(&csv_filter); let default_format = report::ReportFormat::Html; let initial_name = format!( "driftwood-security-report.{}", default_format.extension(), ); let dialog = gtk::FileDialog::builder() .title("Export Security Report") .initial_name(&initial_name) .filters(&filters) .default_filter(&html_filter) .modal(true) .build(); let btn_clone = btn.clone(); let db_for_save = db_export.clone(); let toast_for_save = toast_ref.clone(); let window = btn.root().and_downcast::(); dialog.save(window.as_ref(), None::<&gio::Cancellable>, move |result| { let Ok(file) = result else { return }; let Some(path) = file.path() else { return }; // Detect format from extension let ext = path.extension() .and_then(|e| e.to_str()) .unwrap_or("html") .to_lowercase(); let format = report::ReportFormat::from_str(&ext) .unwrap_or(report::ReportFormat::Html); btn_clone.set_sensitive(false); btn_clone.set_label("Exporting..."); let btn_done = btn_clone.clone(); let toast_done = toast_for_save.clone(); let db_bg = db_for_save.clone(); glib::spawn_future_local(async move { let path_clone = path.clone(); let result = gio::spawn_blocking(move || { let bg_db = Database::open().expect("Failed to open database"); // Use the records from the bg_db since Rc isn't Send let _ = db_bg; // acknowledge capture but use bg_db let report_data = report::build_report(&bg_db, None); let content = report::render(&report_data, format); std::fs::write(&path_clone, content) }) .await; btn_done.set_sensitive(true); btn_done.set_label("Export"); match result { Ok(Ok(())) => { let filename = path.file_name() .and_then(|n| n.to_str()) .unwrap_or("report"); toast_done.add_toast( adw::Toast::new(&format!("Report saved as {}", filename)), ); } _ => { toast_done.add_toast(adw::Toast::new("Failed to export report")); } } }); }); }); adw::NavigationPage::builder() .title("Security Report") .tag("security-report") .child(&toast_overlay) .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 check to look for known issues in your apps.") .build(); content.append(&empty); } else { let clean = adw::StatusPage::builder() .icon_name("security-high-symbolic") .title("All Clear") .description("No known security issues found in any of your apps.") .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("Driftwood checks the software components bundled inside your apps against a database of known security issues (CVEs). Most issues are in underlying libraries, not the apps themselves.") .build(); let total_row = adw::ActionRow::builder() .title("Total vulnerabilities") .subtitle(&summary.total().to_string()) .tooltip_text("Known security issues found in the components bundled inside your apps") .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()) .tooltip_text("Could allow an attacker to take control of affected components") .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()) .tooltip_text("Could allow unauthorized access to data processed by the app") .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()) .tooltip_text("Could cause the app to behave unexpectedly or crash") .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()) .tooltip_text("Minor issue with limited practical impact") .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!( "{} known security issues found. Check if a newer version is available in the catalog or from the developer's website. Most security issues are fixed in newer releases.", 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 }