Critical: fix unsquashfs arg order, quote Exec paths with spaces, fix compare_versions antisymmetry, chunk-based signature detection, bounded ELF header reads. High: handle NULL CVE severity, prevent pipe deadlock in inspector, fix glob_match edge case, fix backup archive path collisions, async crash detection with stderr capture. Medium: gate scan on auto-scan setting, fix window size persistence, fix announce() for Stack containers, claim lightbox gesture, use serde_json for CLI output, remove dead CSS @media blocks, add detail-tab persistence, remove invalid metainfo categories, byte-level fuse signature search. Low: tighten Wayland env var detection, ELF magic validation, timeout for update info extraction, quoted arg parsing, stop watcher timer on window destroy, GSettings choices/range constraints, remove unused CSS classes, define status-ok/status-attention CSS.
413 lines
14 KiB
Rust
413 lines
14 KiB
Rust
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<Database>) -> 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::<gtk::FileFilter>();
|
|
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::<gtk::Window>();
|
|
|
|
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<Database> 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<Database>) -> 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("Overall security status across all your apps")
|
|
.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())
|
|
.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!("{} known security issues 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<String, Vec<&crate::core::database::CveMatchRecord>> =
|
|
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
|
|
}
|