diff options
Diffstat (limited to 'crates/mozart-registry/src/advisory.rs')
| -rw-r--r-- | crates/mozart-registry/src/advisory.rs | 733 |
1 files changed, 0 insertions, 733 deletions
diff --git a/crates/mozart-registry/src/advisory.rs b/crates/mozart-registry/src/advisory.rs deleted file mode 100644 index 86d37af..0000000 --- a/crates/mozart-registry/src/advisory.rs +++ /dev/null @@ -1,733 +0,0 @@ -use std::collections::BTreeMap; - -use indexmap::IndexMap; -use mozart_core::advisory::{AbandonedHandling, AuditFormat}; -use mozart_core::console::Console; -use mozart_core::{console_writeln, console_writeln_error}; - -use crate::packagist::SecurityAdvisory; -use crate::repository::RepositorySet; - -/// A package being audited, with version and abandonment information. -#[derive(Debug, Clone)] -pub struct PackageInfo { - pub name: String, - pub version: String, - pub version_normalized: Option<String>, - /// Raw abandoned field from JSON: `true` = abandoned no replacement, `String` = replacement name. - pub abandoned_raw: Option<serde_json::Value>, -} - -impl PackageInfo { - /// Mirrors `CompletePackage::isAbandoned()`. - pub fn is_abandoned(&self) -> bool { - matches!( - &self.abandoned_raw, - Some(serde_json::Value::Bool(true)) | Some(serde_json::Value::String(_)) - ) - } - - /// Mirrors `CompletePackage::getReplacementPackage()`. - pub fn replacement_package(&self) -> Option<&str> { - match &self.abandoned_raw { - Some(serde_json::Value::String(s)) => Some(s.as_str()), - _ => None, - } - } -} - -/// An advisory paired with the installed version of the package it affects. -#[derive(Debug, Clone)] -pub struct MatchedAdvisory { - pub advisory: SecurityAdvisory, - pub installed_version: String, -} - -/// A matched advisory that was filtered out by the ignore list. -#[derive(Debug, Clone)] -pub struct IgnoredAdvisory { - pub advisory: SecurityAdvisory, - pub installed_version: String, - pub ignore_reason: Option<String>, -} - -/// Result of `Auditor::process_advisories`. -#[derive(Debug, Default)] -pub struct ProcessedAdvisories { - pub advisories: BTreeMap<String, Vec<MatchedAdvisory>>, - pub ignored_advisories: BTreeMap<String, Vec<IgnoredAdvisory>>, -} - -/// An abandoned package found during audit. -#[derive(Debug, Clone)] -pub struct AbandonedPackage { - pub name: String, - pub version: String, - pub replacement: Option<String>, -} - -/// Options passed to `Auditor::audit()`. -pub struct AuditOptions<'a> { - pub format: AuditFormat, - pub warning_only: bool, - pub ignore_list: &'a IndexMap<String, Option<String>>, - pub abandoned: AbandonedHandling, - pub ignored_severities: &'a IndexMap<String, Option<String>>, - pub ignore_unreachable: bool, - pub ignore_abandoned: &'a IndexMap<String, Option<String>>, -} - -/// Mirrors `Composer\Advisory\Auditor`. -pub struct Auditor; - -impl Auditor { - pub fn new() -> Self { - Self - } - - /// Main audit entry point. Mirrors `Composer\Advisory\Auditor::audit()`. - /// - /// Returns a bitmask: 0=ok, 1=vulnerable, 2=abandoned, 3=both. - pub async fn audit( - &self, - console: &Console, - repo_set: &RepositorySet, - packages: &[PackageInfo], - options: &AuditOptions<'_>, - ) -> anyhow::Result<u8> { - let format = options.format; - let (all_advisories, unreachable_repos) = repo_set - .get_matching_security_advisories( - packages, - format == AuditFormat::Summary, - options.ignore_unreachable, - ) - .await?; - - let ProcessedAdvisories { - advisories, - ignored_advisories, - } = self.process_advisories( - all_advisories, - options.ignore_list, - options.ignored_severities, - ); - - let abandoned_packages = if options.abandoned == AbandonedHandling::Ignore { - vec![] - } else { - self.filter_abandoned_packages(packages, options.ignore_abandoned) - }; - - let abandoned_count = if options.abandoned == AbandonedHandling::Fail { - abandoned_packages.len() - } else { - 0 - }; - - let affected_packages_count = advisories.len(); - let bitmask = self.calculate_bitmask(affected_packages_count > 0, abandoned_count > 0); - - if format == AuditFormat::Json { - self.render_json( - &advisories, - &ignored_advisories, - &unreachable_repos, - &abandoned_packages, - console, - ); - return Ok(bitmask); - } - - let (ignored_pkg_count, ignored_total) = self.count_ignored(&ignored_advisories); - let (active_pkg_count, active_total) = self.count_matched(&advisories); - - if active_pkg_count > 0 || ignored_pkg_count > 0 { - if ignored_pkg_count > 0 { - let plurality = if ignored_total == 1 { "y" } else { "ies" }; - let pkg_plurality = if ignored_pkg_count == 1 { "" } else { "s" }; - let punctuation = if format == AuditFormat::Summary { - "." - } else { - ":" - }; - let msg = format!( - "Found {ignored_total} ignored security vulnerability advisor{plurality} affecting {ignored_pkg_count} package{pkg_plurality}{punctuation}" - ); - console_writeln_error!(console, "<info>{msg}</info>"); - self.output_advisories_ignored(console, &ignored_advisories, format); - } - - if active_pkg_count > 0 { - let plurality = if active_total == 1 { "y" } else { "ies" }; - let pkg_plurality = if active_pkg_count == 1 { "" } else { "s" }; - let punctuation = if format == AuditFormat::Summary { - "." - } else { - ":" - }; - let msg = format!( - "Found {active_total} security vulnerability advisor{plurality} affecting {active_pkg_count} package{pkg_plurality}{punctuation}" - ); - if options.warning_only { - console_writeln_error!(console, "<warning>{msg}</warning>"); - } else { - console_writeln_error!(console, "<error>{msg}</error>"); - } - self.output_advisories(console, &advisories, format); - } - - if format == AuditFormat::Summary { - console_writeln_error!( - console, - "Run \"mozart audit\" for a full list of advisories." - ); - } - } else { - console_writeln_error!( - console, - "<info>No security vulnerability advisories found.</info>", - ); - } - - if !unreachable_repos.is_empty() { - console_writeln_error!( - console, - "<warning>The following repositories were unreachable:</warning>", - ); - for repo in &unreachable_repos { - console_writeln_error!(console, " - {repo}"); - } - } - - if !abandoned_packages.is_empty() && format != AuditFormat::Summary { - self.output_abandoned_packages(console, &abandoned_packages, format); - } - - Ok(bitmask) - } - - /// Mirrors `Composer\Advisory\Auditor::processAdvisories()`. - /// - /// Splits advisories into active and ignored based on the ignore list and ignored severities. - /// Checks by: package name, advisory ID, severity, CVE, and source remote IDs. - pub fn process_advisories( - &self, - all_advisories: BTreeMap<String, Vec<MatchedAdvisory>>, - ignore_list: &IndexMap<String, Option<String>>, - ignored_severities: &IndexMap<String, Option<String>>, - ) -> ProcessedAdvisories { - if ignore_list.is_empty() && ignored_severities.is_empty() { - return ProcessedAdvisories { - advisories: all_advisories, - ignored_advisories: BTreeMap::new(), - }; - } - - let mut advisories: BTreeMap<String, Vec<MatchedAdvisory>> = BTreeMap::new(); - let mut ignored: BTreeMap<String, Vec<IgnoredAdvisory>> = BTreeMap::new(); - - for (package, pkg_advisories) in all_advisories { - for matched in pkg_advisories { - let adv = &matched.advisory; - let mut is_active = true; - let mut ignore_reason: Option<String> = None; - - // Check by package name - if let Some(reason) = ignore_list.get(&package) { - is_active = false; - ignore_reason = reason.clone(); - } - - // Check by advisory ID - if is_active && let Some(reason) = ignore_list.get(&adv.advisory_id) { - is_active = false; - ignore_reason = reason.clone(); - } - - // Check by severity - if is_active - && let Some(ref sev) = adv.severity - && let Some(reason) = ignored_severities.get(sev.as_str()) - { - is_active = false; - ignore_reason = reason - .clone() - .or_else(|| Some(format!("{sev} severity is ignored"))); - } - - // Check by CVE - if is_active - && let Some(ref cve) = adv.cve - && let Some(reason) = ignore_list.get(cve.as_str()) - { - is_active = false; - ignore_reason = reason.clone(); - } - - // Check by source remote IDs - if is_active { - for source in &adv.sources { - if let Some(reason) = ignore_list.get(&source.remote_id) { - is_active = false; - ignore_reason = reason.clone(); - break; - } - } - } - - if is_active { - advisories.entry(package.clone()).or_default().push(matched); - } else { - ignored - .entry(package.clone()) - .or_default() - .push(IgnoredAdvisory { - advisory: matched.advisory, - installed_version: matched.installed_version, - ignore_reason, - }); - } - } - } - - ProcessedAdvisories { - advisories, - ignored_advisories: ignored, - } - } - - /// Mirrors `Composer\Advisory\Auditor::filterAbandonedPackages()`. - pub fn filter_abandoned_packages( - &self, - packages: &[PackageInfo], - ignore_abandoned: &IndexMap<String, Option<String>>, - ) -> Vec<AbandonedPackage> { - packages - .iter() - .filter(|pkg| { - if !pkg.is_abandoned() { - return false; - } - if !ignore_abandoned.is_empty() { - let name_lower = pkg.name.to_lowercase(); - // Case-insensitive exact name match (wildcard support deferred) - if ignore_abandoned - .keys() - .any(|k| k.to_lowercase() == name_lower) - { - return false; - } - } - true - }) - .map(|pkg| AbandonedPackage { - name: pkg.name.clone(), - version: pkg.version.clone(), - replacement: pkg.replacement_package().map(|s| s.to_string()), - }) - .collect() - } - - /// Mirrors `Composer\Advisory\Auditor::needsCompleteAdvisoryLoad()`. - /// - /// Mozart always fetches full advisories (no partial optimization), so this is always false. - pub fn needs_complete_advisory_load( - &self, - advisories: &BTreeMap<String, Vec<MatchedAdvisory>>, - _ignore_list: &IndexMap<String, Option<String>>, - ) -> bool { - let _ = advisories; - false - } - - fn calculate_bitmask(&self, has_vulnerable: bool, has_abandoned: bool) -> u8 { - let mut bitmask = 0u8; - if has_vulnerable { - bitmask |= 1; - } - if has_abandoned { - bitmask |= 2; - } - bitmask - } - - fn count_ignored(&self, advisories: &BTreeMap<String, Vec<IgnoredAdvisory>>) -> (usize, usize) { - let pkg_count = advisories.len(); - let total = advisories.values().map(|v| v.len()).sum(); - (pkg_count, total) - } - - fn count_matched(&self, advisories: &BTreeMap<String, Vec<MatchedAdvisory>>) -> (usize, usize) { - let pkg_count = advisories.len(); - let total = advisories.values().map(|v| v.len()).sum(); - (pkg_count, total) - } - - fn output_advisories( - &self, - console: &Console, - advisories: &BTreeMap<String, Vec<MatchedAdvisory>>, - format: AuditFormat, - ) { - match format { - AuditFormat::Table => self.output_advisories_table(console, advisories), - AuditFormat::Plain => self.output_advisories_plain(console, advisories), - AuditFormat::Summary => {} - AuditFormat::Json => unreachable!(), - } - } - - fn output_advisories_ignored( - &self, - console: &Console, - advisories: &BTreeMap<String, Vec<IgnoredAdvisory>>, - format: AuditFormat, - ) { - match format { - AuditFormat::Table => self.output_ignored_advisories_table(console, advisories), - AuditFormat::Plain => self.output_ignored_advisories_plain(console, advisories), - AuditFormat::Summary => {} - AuditFormat::Json => unreachable!(), - } - } - - fn output_advisories_table( - &self, - console: &Console, - advisories: &BTreeMap<String, Vec<MatchedAdvisory>>, - ) { - for pkg_advisories in advisories.values() { - for matched in pkg_advisories { - self.render_advisory_table( - console, - &matched.advisory, - &matched.installed_version, - None, - ); - } - } - } - - fn output_ignored_advisories_table( - &self, - console: &Console, - advisories: &BTreeMap<String, Vec<IgnoredAdvisory>>, - ) { - for pkg_advisories in advisories.values() { - for ignored in pkg_advisories { - self.render_advisory_table( - console, - &ignored.advisory, - &ignored.installed_version, - ignored.ignore_reason.as_deref(), - ); - } - } - } - - fn render_advisory_table( - &self, - console: &Console, - adv: &SecurityAdvisory, - installed_version: &str, - ignore_reason: Option<&str>, - ) { - let label_width = 17usize; - let mut rows: Vec<(&str, String)> = vec![ - ("Package", adv.package_name.clone()), - ("Version", installed_version.to_string()), - ("Severity", adv.severity.clone().unwrap_or_default()), - ("Advisory ID", adv.advisory_id.clone()), - ( - "CVE", - adv.cve.clone().unwrap_or_else(|| "NO CVE".to_string()), - ), - ("Title", adv.title.clone()), - ("URL", adv.link.clone().unwrap_or_default()), - ("Affected versions", adv.affected_versions.clone()), - ("Reported at", adv.reported_at.clone()), - ]; - if let Some(reason) = ignore_reason { - rows.push(("Ignore reason", reason.to_string())); - } - - let value_width = rows.iter().map(|(_, v)| v.len()).max().unwrap_or(0).max(20); - let separator = format!( - "+-{:-<lw$}-+-{:-<vw$}-+", - "", - "", - lw = label_width, - vw = value_width - ); - - console_writeln_error!(console, "{}", separator); - for (label, value) in &rows { - console_writeln_error!( - console, - "| {:<lw$} | {:<vw$} |", - label, - value, - lw = label_width, - vw = value_width, - ); - } - console_writeln_error!(console, "{}", &separator); - console_writeln_error!(console, ""); - } - - fn output_advisories_plain( - &self, - console: &Console, - advisories: &BTreeMap<String, Vec<MatchedAdvisory>>, - ) { - let mut first = true; - for pkg_advisories in advisories.values() { - for matched in pkg_advisories { - if !first { - console_writeln_error!(console, "--------"); - } - self.render_advisory_plain( - console, - &matched.advisory, - &matched.installed_version, - None, - ); - first = false; - } - } - } - - fn output_ignored_advisories_plain( - &self, - console: &Console, - advisories: &BTreeMap<String, Vec<IgnoredAdvisory>>, - ) { - let mut first = true; - for pkg_advisories in advisories.values() { - for ignored in pkg_advisories { - if !first { - console_writeln_error!(console, "--------"); - } - self.render_advisory_plain( - console, - &ignored.advisory, - &ignored.installed_version, - ignored.ignore_reason.as_deref(), - ); - first = false; - } - } - } - - fn render_advisory_plain( - &self, - console: &Console, - adv: &SecurityAdvisory, - installed_version: &str, - ignore_reason: Option<&str>, - ) { - console_writeln_error!(console, "Package: {}", adv.package_name); - console_writeln_error!(console, "Version: {installed_version}"); - console_writeln_error!( - console, - "Severity: {}", - adv.severity.as_deref().unwrap_or(""), - ); - console_writeln_error!(console, "Advisory ID: {}", adv.advisory_id); - console_writeln_error!(console, "CVE: {}", adv.cve.as_deref().unwrap_or("NO CVE")); - console_writeln_error!(console, "Title: {}", adv.title); - console_writeln_error!(console, "URL: {}", adv.link.as_deref().unwrap_or("")); - console_writeln_error!(console, "Affected versions: {}", adv.affected_versions); - console_writeln_error!(console, "Reported at: {}", adv.reported_at); - if let Some(reason) = ignore_reason { - console_writeln_error!(console, "Ignore reason: {reason}"); - } - } - - fn output_abandoned_packages( - &self, - console: &Console, - packages: &[AbandonedPackage], - format: AuditFormat, - ) { - let count = packages.len(); - let plurality = if count == 1 { "" } else { "s" }; - console_writeln_error!( - console, - "<error>Found {count} abandoned package{plurality}:</error>", - ); - - if format == AuditFormat::Plain { - for pkg in packages { - match &pkg.replacement { - Some(repl) => console_writeln_error!( - console, - "{} ({}) is abandoned. Use {} instead.", - pkg.name, - pkg.version, - repl, - ), - None => console_writeln_error!( - console, - "{} ({}) is abandoned. No replacement was suggested.", - pkg.name, - pkg.version, - ), - } - } - return; - } - - // Table format - let name_width = 20usize; - let ver_width = packages - .iter() - .map(|a| a.version.len()) - .max() - .unwrap_or(0) - .max("Version".len()); - let repl_width = packages - .iter() - .map(|a| { - a.replacement - .as_deref() - .unwrap_or("No replacement suggested") - .len() - }) - .max() - .unwrap_or(0) - .max("Suggested Replacement".len()); - - console_writeln_error!( - console, - "| {:<nw$} | {:<vw$} | {:<rw$} |", - "Abandoned Package", - "Version", - "Suggested Replacement", - nw = name_width, - vw = ver_width, - rw = repl_width, - ); - console_writeln_error!( - console, - "+-{:-<nw$}-+-{:-<vw$}-+-{:-<rw$}-+", - "", - "", - "", - nw = name_width, - vw = ver_width, - rw = repl_width, - ); - for pkg in packages { - let replacement = pkg - .replacement - .as_deref() - .unwrap_or("No replacement suggested"); - console_writeln_error!( - console, - "| {:<nw$} | {:<vw$} | {:<rw$} |", - pkg.name, - pkg.version, - replacement, - nw = name_width, - vw = ver_width, - rw = repl_width, - ); - } - console_writeln_error!(console, ""); - } - - fn render_json( - &self, - advisories: &BTreeMap<String, Vec<MatchedAdvisory>>, - ignored_advisories: &BTreeMap<String, Vec<IgnoredAdvisory>>, - unreachable_repos: &[String], - abandoned_packages: &[AbandonedPackage], - console: &Console, - ) { - let mut advisories_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new(); - for (pkg_name, matched_list) in advisories { - let arr: Vec<serde_json::Value> = matched_list - .iter() - .map(|m| serde_json::to_value(&m.advisory).unwrap_or(serde_json::Value::Null)) - .collect(); - advisories_map.insert(pkg_name.clone(), serde_json::Value::Array(arr)); - } - - let mut output = serde_json::json!({ "advisories": advisories_map }); - - // ignored-advisories (only if non-empty) - if !ignored_advisories.is_empty() { - let mut ignored_map: serde_json::Map<String, serde_json::Value> = - serde_json::Map::new(); - for (pkg_name, ignored_list) in ignored_advisories { - let arr: Vec<serde_json::Value> = ignored_list - .iter() - .map(|i| { - let mut val = - serde_json::to_value(&i.advisory).unwrap_or(serde_json::Value::Null); - if let serde_json::Value::Object(ref mut obj) = val { - obj.insert( - "ignoreReason".to_string(), - i.ignore_reason - .as_ref() - .map(|r| serde_json::Value::String(r.clone())) - .unwrap_or(serde_json::Value::Null), - ); - } - val - }) - .collect(); - ignored_map.insert(pkg_name.clone(), serde_json::Value::Array(arr)); - } - if let serde_json::Value::Object(ref mut obj) = output { - obj.insert( - "ignored-advisories".to_string(), - serde_json::Value::Object(ignored_map), - ); - } - } - - // unreachable-repositories (only if non-empty) - if !unreachable_repos.is_empty() { - let repos_arr: Vec<serde_json::Value> = unreachable_repos - .iter() - .map(|r| serde_json::Value::String(r.clone())) - .collect(); - if let serde_json::Value::Object(ref mut obj) = output { - obj.insert( - "unreachable-repositories".to_string(), - serde_json::Value::Array(repos_arr), - ); - } - } - - // abandoned map: package_name => replacement (null if none) - let mut abandoned_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new(); - for pkg in abandoned_packages { - abandoned_map.insert( - pkg.name.clone(), - pkg.replacement - .as_ref() - .map(|r| serde_json::Value::String(r.clone())) - .unwrap_or(serde_json::Value::Null), - ); - } - if let serde_json::Value::Object(ref mut obj) = output { - obj.insert( - "abandoned".to_string(), - serde_json::Value::Object(abandoned_map), - ); - } - - let json_str = serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string()); - console_writeln!(console, "{}", &json_str); - } -} - -impl Default for Auditor { - fn default() -> Self { - Self::new() - } -} |
