Add WCAG tooltips and expanded abbreviations to security report

This commit is contained in:
lashman
2026-02-27 10:06:21 +02:00
parent 0aeb445554
commit 3642fbd542

279
src/ui/security_report.rs Normal file
View File

@@ -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<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 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<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 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<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
}