diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-08 23:22:34 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-08 23:22:34 +0900 |
| commit | d770693bac655da4a21144b4cae7592536fecb8b (patch) | |
| tree | 5d29005db018416c03a14c9d367f412b8148650c /crates/mozart-registry/src | |
| parent | eeb845f2f8629e3ccfb8ee1a1ec0602c0f186427 (diff) | |
| download | php-mozart-d770693bac655da4a21144b4cae7592536fecb8b.tar.gz php-mozart-d770693bac655da4a21144b4cae7592536fecb8b.tar.zst php-mozart-d770693bac655da4a21144b4cae7592536fecb8b.zip | |
fix(audit): align with Composer's AuditCommand pipeline
- Add mozart-core::advisory::{AuditFormat, AbandonedHandling, AuditConfig}
mirroring Composer\Advisory\AuditConfig; reads audit.ignore,
audit.ignore-severity, audit.ignore-abandoned, audit.abandoned,
audit.block-insecure, audit.block-abandoned, audit.ignore-unreachable
from composer.json config with full apply-scope support
- Add mozart-registry::advisory::Auditor mirroring Composer\Advisory\Auditor;
process_advisories() filters by package name, advisory ID, CVE, source
remote ID, and severity; filter_abandoned_packages() respects ignore-abandoned
- Add RepositorySet::get_matching_security_advisories() wrapping
fetch_security_advisories with version-matching and unreachable-repo tracking
- JSON output now includes ignored-advisories and unreachable-repositories keys
- --abandoned falls back to audit.abandoned config (was hardcoded to "fail")
- --ignore-severity merges with audit.ignore-severity config
- --ignore-unreachable ORs with audit.ignore-unreachable config
- Move normalize_or_separator into repository/mod.rs alongside version matching
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart-registry/src')
| -rw-r--r-- | crates/mozart-registry/src/advisory.rs | 739 | ||||
| -rw-r--r-- | crates/mozart-registry/src/lib.rs | 1 | ||||
| -rw-r--r-- | crates/mozart-registry/src/repository/mod.rs | 125 |
3 files changed, 865 insertions, 0 deletions
diff --git a/crates/mozart-registry/src/advisory.rs b/crates/mozart-registry/src/advisory.rs new file mode 100644 index 0000000..97242b3 --- /dev/null +++ b/crates/mozart-registry/src/advisory.rs @@ -0,0 +1,739 @@ +use std::collections::BTreeMap; + +use indexmap::IndexMap; +use mozart_core::advisory::{AbandonedHandling, AuditFormat}; +use mozart_core::console::Console; +use mozart_core::{console_format, 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>, +} + +/// 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], + format: AuditFormat, + warning_only: bool, + ignore_list: &IndexMap<String, Option<String>>, + abandoned: AbandonedHandling, + ignored_severities: &IndexMap<String, Option<String>>, + ignore_unreachable: bool, + ignore_abandoned: &IndexMap<String, Option<String>>, + ) -> anyhow::Result<u8> { + let (all_advisories, unreachable_repos) = repo_set + .get_matching_security_advisories( + packages, + format == AuditFormat::Summary, + ignore_unreachable, + ) + .await?; + + let ProcessedAdvisories { + advisories, + ignored_advisories, + } = self.process_advisories(all_advisories, ignore_list, ignored_severities); + + let abandoned_packages = if abandoned == AbandonedHandling::Ignore { + vec![] + } else { + self.filter_abandoned_packages(packages, ignore_abandoned) + }; + + let abandoned_count = if 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, &console_format!("<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 warning_only { + console_writeln_error!(console, &console_format!("<warning>{msg}</warning>")); + } else { + console_writeln_error!(console, &console_format!("<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, + &console_format!("<info>No security vulnerability advisories found.</info>") + ); + } + + if !unreachable_repos.is_empty() { + console_writeln_error!( + console, + &console_format!("<warning>The following repositories were unreachable:</warning>") + ); + for repo in &unreachable_repos { + console_writeln_error!(console, &format!(" - {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, + &format!( + "| {:<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, &format!("Package: {}", adv.package_name)); + console_writeln_error!(console, &format!("Version: {installed_version}")); + console_writeln_error!( + console, + &format!("Severity: {}", adv.severity.as_deref().unwrap_or("")) + ); + console_writeln_error!(console, &format!("Advisory ID: {}", adv.advisory_id)); + console_writeln_error!( + console, + &format!("CVE: {}", adv.cve.as_deref().unwrap_or("NO CVE")) + ); + console_writeln_error!(console, &format!("Title: {}", adv.title)); + console_writeln_error!( + console, + &format!("URL: {}", adv.link.as_deref().unwrap_or("")) + ); + console_writeln_error!( + console, + &format!("Affected versions: {}", adv.affected_versions) + ); + console_writeln_error!(console, &format!("Reported at: {}", adv.reported_at)); + if let Some(reason) = ignore_reason { + console_writeln_error!(console, &format!("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, + &console_format!("<error>Found {count} abandoned package{plurality}:</error>") + ); + + if format == AuditFormat::Plain { + for pkg in packages { + match &pkg.replacement { + Some(repl) => console_writeln_error!( + console, + &format!( + "{} ({}) is abandoned. Use {} instead.", + pkg.name, pkg.version, repl + ), + ), + None => console_writeln_error!( + console, + &format!( + "{} ({}) 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, + &format!( + "| {:<nw$} | {:<vw$} | {:<rw$} |", + "Abandoned Package", + "Version", + "Suggested Replacement", + nw = name_width, + vw = ver_width, + rw = repl_width + ), + ); + console_writeln_error!( + console, + &format!( + "+-{:-<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, + &format!( + "| {:<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() + } +} diff --git a/crates/mozart-registry/src/lib.rs b/crates/mozart-registry/src/lib.rs index 9d72c36..8f9af91 100644 --- a/crates/mozart-registry/src/lib.rs +++ b/crates/mozart-registry/src/lib.rs @@ -1,3 +1,4 @@ +pub mod advisory; pub mod browse_repos; pub mod cache; pub mod composer_repo; diff --git a/crates/mozart-registry/src/repository/mod.rs b/crates/mozart-registry/src/repository/mod.rs index 6642638..46f62f0 100644 --- a/crates/mozart-registry/src/repository/mod.rs +++ b/crates/mozart-registry/src/repository/mod.rs @@ -10,6 +10,9 @@ //! the live Packagist HTTP repo, [`inline_package_repo`] for `type: package` //! entries embedded in `composer.json`, and [`vcs_repo`] for VCS repositories. +use std::collections::BTreeMap; + +use crate::advisory::{MatchedAdvisory, PackageInfo}; use crate::packagist::{PackagistVersion, SearchResult}; pub mod inline_package_repo; @@ -191,4 +194,126 @@ impl RepositorySet { } Ok(all) } + + /// Fetch security advisories matching the installed packages, with version filtering. + /// + /// Mirrors `Composer\Repository\RepositorySet::getMatchingSecurityAdvisories()`. + /// Returns the matched advisories (already filtered by installed version) and a list + /// of unreachable repository URLs. When `ignore_unreachable` is false and a repository + /// is unreachable, the error is propagated instead. + pub async fn get_matching_security_advisories( + &self, + packages: &[PackageInfo], + _allow_partial: bool, + ignore_unreachable: bool, + ) -> anyhow::Result<(BTreeMap<String, Vec<MatchedAdvisory>>, Vec<String>)> { + let names: Vec<&str> = packages.iter().map(|p| p.name.as_str()).collect(); + + let (raw_advisories, unreachable_repos) = + match crate::packagist::fetch_security_advisories(&names).await { + Ok(a) => (a, vec![]), + Err(e) if ignore_unreachable => { + tracing::warn!("Packagist advisory fetch failed (ignored): {e}"); + let unreachable = vec!["https://packagist.org".to_string()]; + (BTreeMap::new(), unreachable) + } + Err(e) => return Err(e), + }; + + let matched = version_filter_advisories(&raw_advisories, packages); + + Ok((matched, unreachable_repos)) + } +} + +/// Normalize single-pipe OR separators (`|`) in a version constraint string to +/// double-pipe (`||`) so the constraint parser can handle both forms. +/// +/// The Packagist security advisories API may return constraints with single `|` +/// as the OR separator (e.g. `>=1.0,<1.5|>=2.0,<2.3`), but Mozart's +/// `VersionConstraint::parse` expects `||`. +/// +/// TODO: fix `mozart_semver::VersionConstraint::parse` to accept single `|` and remove this. +fn normalize_or_separator(constraint: &str) -> String { + let bytes = constraint.as_bytes(); + let mut result = String::with_capacity(constraint.len() + 4); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'|' { + if i + 1 < bytes.len() && bytes[i + 1] == b'|' { + result.push_str("||"); + i += 2; + } else { + result.push_str("||"); + i += 1; + } + } else { + result.push(bytes[i] as char); + i += 1; + } + } + result +} + +/// Filter raw advisories by installed package versions. +/// +/// Mirrors the version-matching step inside Composer's repository advisory fetch. +fn version_filter_advisories( + all_advisories: &BTreeMap<String, Vec<crate::packagist::SecurityAdvisory>>, + packages: &[PackageInfo], +) -> BTreeMap<String, Vec<MatchedAdvisory>> { + let mut result: BTreeMap<String, Vec<MatchedAdvisory>> = BTreeMap::new(); + + for pkg in packages { + let Some(advisories) = all_advisories.get(&pkg.name) else { + continue; + }; + + let version_str = pkg + .version_normalized + .as_deref() + .unwrap_or(pkg.version.as_str()); + + let installed_ver = match mozart_semver::Version::parse(version_str) { + Ok(v) => v, + Err(_) => { + tracing::warn!( + "Could not parse version {:?} for package {:?}, skipping advisory matching", + version_str, + pkg.name + ); + continue; + } + }; + + let mut matched: Vec<MatchedAdvisory> = Vec::new(); + + for advisory in advisories { + let normalized = normalize_or_separator(&advisory.affected_versions); + let constraint = match mozart_semver::VersionConstraint::parse(&normalized) { + Ok(c) => c, + Err(_) => { + tracing::warn!( + "Could not parse affected versions {:?} for advisory {:?}, skipping", + advisory.affected_versions, + advisory.advisory_id + ); + continue; + } + }; + + if constraint.matches(&installed_ver) { + matched.push(MatchedAdvisory { + advisory: advisory.clone(), + installed_version: pkg.version.clone(), + }); + } + } + + if !matched.is_empty() { + result.insert(pkg.name.clone(), matched); + } + } + + result } |
