diff options
| -rw-r--r-- | crates/mozart/src/commands/audit.rs | 1060 | ||||
| -rw-r--r-- | crates/mozart/src/packagist.rs | 195 |
2 files changed, 1249 insertions, 6 deletions
diff --git a/crates/mozart/src/commands/audit.rs b/crates/mozart/src/commands/audit.rs index 4aa048d..3791157 100644 --- a/crates/mozart/src/commands/audit.rs +++ b/crates/mozart/src/commands/audit.rs @@ -1,8 +1,12 @@ use clap::Args; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use crate::packagist::SecurityAdvisory; #[derive(Args)] pub struct AuditArgs { - /// Disables installation of require-dev packages + /// Disables auditing of require-dev packages #[arg(long)] pub no_dev: bool, @@ -10,11 +14,11 @@ pub struct AuditArgs { #[arg(short, long, default_value = "table")] pub format: String, - /// Audit packages from the lock file + /// Audit packages from the lock file instead of installed #[arg(long)] pub locked: bool, - /// Handling of abandoned packages (ignore, report, fail) + /// Behavior on abandoned packages (ignore, report, fail) #[arg(long)] pub abandoned: Option<String>, @@ -22,11 +26,1055 @@ pub struct AuditArgs { #[arg(long)] pub ignore_severity: Vec<String>, - /// Ignore advisories from sources that are unreachable + /// Ignore advisories from unreachable repositories #[arg(long)] pub ignore_unreachable: bool, } -pub fn execute(_args: &AuditArgs, _cli: &super::Cli) -> anyhow::Result<()> { - todo!() +// ─── Internal types ─────────────────────────────────────────────────────────── + +#[derive(Debug)] +struct PackageEntry { + name: String, + version: String, + version_normalized: Option<String>, + abandoned: Option<serde_json::Value>, +} + +/// An advisory that matched an installed package version. +struct MatchedAdvisory { + advisory: SecurityAdvisory, + #[allow(dead_code)] + installed_version: String, +} + +/// An abandoned package found during audit. +struct AbandonedPackage { + name: String, + #[allow(dead_code)] + version: String, + replacement: Option<String>, +} + +/// Aggregated audit results. +struct AuditResult { + /// Map from package name to list of matching advisories. + advisories: BTreeMap<String, Vec<MatchedAdvisory>>, + /// Abandoned packages found (only if --abandoned != ignore). + abandoned: Vec<AbandonedPackage>, + /// Total count of advisory-affected packages. + affected_package_count: usize, + /// Total count of individual advisories. + total_advisory_count: usize, +} + +// ─── Main entry point ───────────────────────────────────────────────────────── + +pub fn execute(args: &AuditArgs, cli: &super::Cli) -> anyhow::Result<()> { + // Validate format + let format = args.format.as_str(); + if format != "table" && format != "plain" && format != "json" && format != "summary" { + anyhow::bail!( + "Invalid format \"{}\". Supported formats: table, plain, json, summary", + format + ); + } + + // Validate --abandoned + let abandoned_mode = match args.abandoned.as_deref().unwrap_or("report") { + "ignore" => "ignore", + "report" => "report", + "fail" => "fail", + other => anyhow::bail!( + "Invalid abandoned value \"{}\". Supported values: ignore, report, fail", + other + ), + }; + + // Determine working directory + let working_dir = match &cli.working_dir { + Some(dir) => PathBuf::from(dir), + None => std::env::current_dir()?, + }; + + // Load packages + let packages = load_packages(&working_dir, args.locked, args.no_dev)?; + + if packages.is_empty() { + println!("No packages - skipping audit."); + return Ok(()); + } + + // Fetch advisories + let names: Vec<&str> = packages.iter().map(|p| p.name.as_str()).collect(); + let all_advisories = match crate::packagist::fetch_security_advisories(&names) { + Ok(a) => a, + Err(e) => { + if args.ignore_unreachable { + BTreeMap::new() + } else { + return Err(e); + } + } + }; + + // Filter advisories by installed versions and severity + let matched = filter_advisories(&all_advisories, &packages, &args.ignore_severity); + + // Detect abandoned packages + let abandoned = if abandoned_mode == "ignore" { + Vec::new() + } else { + detect_abandoned(&packages) + }; + + // Build result + let affected_package_count = matched.len(); + let total_advisory_count = matched.values().map(|v| v.len()).sum(); + + let result = AuditResult { + advisories: matched, + abandoned, + affected_package_count, + total_advisory_count, + }; + + // Render output + match format { + "table" => render_table(&result), + "plain" => render_plain(&result), + "json" => render_json(&result)?, + "summary" => render_summary(&result), + _ => unreachable!(), + } + + // Compute bitmask exit code + let has_advisories = result.total_advisory_count > 0; + let has_abandoned = !result.abandoned.is_empty() && abandoned_mode == "fail"; + + let exit_code: i32 = match (has_advisories, has_abandoned) { + (false, false) => 0, + (true, false) => 1, + (false, true) => 2, + (true, true) => 3, + }; + + if exit_code != 0 { + std::process::exit(exit_code); + } + + Ok(()) +} + +// ─── Package loading ────────────────────────────────────────────────────────── + +fn load_packages( + working_dir: &Path, + locked: bool, + no_dev: bool, +) -> anyhow::Result<Vec<PackageEntry>> { + if locked { + load_locked_packages(working_dir, no_dev) + } else { + load_installed_packages(working_dir, no_dev) + } +} + +fn load_installed_packages(working_dir: &Path, no_dev: bool) -> anyhow::Result<Vec<PackageEntry>> { + let vendor_dir = working_dir.join("vendor"); + let installed = crate::installed::InstalledPackages::read(&vendor_dir)?; + + let dev_names: std::collections::HashSet<String> = installed + .dev_package_names + .iter() + .map(|n| n.to_lowercase()) + .collect(); + + let packages = installed + .packages + .iter() + .filter(|p| { + if no_dev && dev_names.contains(&p.name.to_lowercase()) { + return false; + } + true + }) + .map(|p| { + let abandoned = p.extra_fields.get("abandoned").cloned(); + PackageEntry { + name: p.name.clone(), + version: p.version.clone(), + version_normalized: p.version_normalized.clone(), + abandoned, + } + }) + .collect(); + + Ok(packages) +} + +fn load_locked_packages(working_dir: &Path, no_dev: bool) -> anyhow::Result<Vec<PackageEntry>> { + let lock_path = working_dir.join("composer.lock"); + if !lock_path.exists() { + anyhow::bail!( + "A valid composer.json and composer.lock file is required to run this command with --locked" + ); + } + + let lock = crate::lockfile::LockFile::read_from_file(&lock_path)?; + + let mut all_packages: Vec<&crate::lockfile::LockedPackage> = lock.packages.iter().collect(); + + if !no_dev && let Some(ref pkgs_dev) = lock.packages_dev { + all_packages.extend(pkgs_dev.iter()); + } + + let packages = all_packages + .iter() + .map(|p| { + let abandoned = p.extra_fields.get("abandoned").cloned(); + PackageEntry { + name: p.name.clone(), + version: p.version.clone(), + version_normalized: p.version_normalized.clone(), + abandoned, + } + }) + .collect(); + + Ok(packages) +} + +// ─── Advisory filtering ─────────────────────────────────────────────────────── + +fn filter_advisories( + all_advisories: &BTreeMap<String, Vec<SecurityAdvisory>>, + packages: &[PackageEntry], + ignore_severity: &[String], +) -> BTreeMap<String, Vec<MatchedAdvisory>> { + let ignore_set: std::collections::HashSet<String> = + ignore_severity.iter().map(|s| s.to_lowercase()).collect(); + + let mut result: BTreeMap<String, Vec<MatchedAdvisory>> = BTreeMap::new(); + + for pkg in packages { + let Some(advisories) = all_advisories.get(&pkg.name) else { + continue; + }; + + // Parse the installed version + let version_str = pkg + .version_normalized + .as_deref() + .unwrap_or(pkg.version.as_str()); + + let installed_ver = match crate::constraint::Version::parse(version_str) { + Ok(v) => v, + Err(_) => { + eprintln!( + "Warning: 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 { + // Apply severity filter + if let Some(ref sev) = advisory.severity + && ignore_set.contains(&sev.to_lowercase()) + { + continue; + } + + // Parse and match the affected versions constraint. + // Normalize single-pipe OR separators (`|`) to double-pipe (`||`) + // since the Packagist API may use either form. + let normalized_constraint = normalize_or_separator(&advisory.affected_versions); + let constraint = match crate::constraint::VersionConstraint::parse( + &normalized_constraint, + ) { + Ok(c) => c, + Err(_) => { + eprintln!( + "Warning: 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 +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/// 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 `||`. +fn normalize_or_separator(constraint: &str) -> String { + // Replace isolated `|` (not already `||`) with `||`. + // Walk byte-by-byte to avoid replacing `||` again. + 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'|' { + // Already `||` — emit as-is and skip both + result.push_str("||"); + i += 2; + } else { + // Single `|` — upgrade to `||` + result.push_str("||"); + i += 1; + } + } else { + result.push(bytes[i] as char); + i += 1; + } + } + result +} + +// ─── Abandoned detection ────────────────────────────────────────────────────── + +fn detect_abandoned(packages: &[PackageEntry]) -> Vec<AbandonedPackage> { + let mut result = Vec::new(); + + for pkg in packages { + let Some(ref abandoned_val) = pkg.abandoned else { + continue; + }; + + let replacement = match abandoned_val { + serde_json::Value::Bool(true) => None, + serde_json::Value::String(s) => Some(s.clone()), + _ => continue, + }; + + result.push(AbandonedPackage { + name: pkg.name.clone(), + version: pkg.version.clone(), + replacement, + }); + } + + result +} + +// ─── Output rendering ───────────────────────────────────────────────────────── + +fn render_table(result: &AuditResult) { + if result.total_advisory_count == 0 && result.abandoned.is_empty() { + println!( + "{}", + crate::console::info("No security vulnerability advisories found.") + ); + return; + } + + if result.total_advisory_count > 0 { + let advisory_word = if result.total_advisory_count == 1 { + "advisory" + } else { + "advisories" + }; + let header = format!( + "Found {} security vulnerability {} affecting {} package(s):", + result.total_advisory_count, advisory_word, result.affected_package_count + ); + println!("{}", crate::console::highlight(&header)); + println!(); + + for advisories in result.advisories.values() { + for matched in advisories { + let adv = &matched.advisory; + + // Compute column widths for the two-column table + let label_width = 17usize; + let rows: Vec<(&str, String)> = vec![ + ("Package", adv.package_name.clone()), + ("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()), + ]; + + 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 + ); + + println!("{}", separator); + for (label, value) in &rows { + println!( + "| {:<lw$} | {:<vw$} |", + label, + value, + lw = label_width, + vw = value_width + ); + } + println!("{}", separator); + println!(); + } + } + } + + if !result.abandoned.is_empty() { + let header = format!("Found {} abandoned package(s):", result.abandoned.len()); + println!("{}", crate::console::highlight(&header)); + println!(); + + let label_width = 20usize; + let repl_width = result + .abandoned + .iter() + .map(|a| { + a.replacement + .as_deref() + .unwrap_or("No replacement suggested") + .len() + }) + .max() + .unwrap_or(0) + .max("Suggested Replacement".len()); + + println!( + "| {:<lw$} | {:<rw$} |", + "Abandoned Package", + "Suggested Replacement", + lw = label_width, + rw = repl_width + ); + println!( + "+-{:-<lw$}-+-{:-<rw$}-+", + "", + "", + lw = label_width, + rw = repl_width + ); + for pkg in &result.abandoned { + let replacement = pkg + .replacement + .as_deref() + .unwrap_or("No replacement suggested"); + println!( + "| {:<lw$} | {:<rw$} |", + pkg.name, + replacement, + lw = label_width, + rw = repl_width + ); + } + println!(); + } +} + +fn render_plain(result: &AuditResult) { + if result.total_advisory_count == 0 && result.abandoned.is_empty() { + println!("No security vulnerability advisories found."); + return; + } + + if result.total_advisory_count > 0 { + let advisory_word = if result.total_advisory_count == 1 { + "advisory" + } else { + "advisories" + }; + println!( + "Found {} security vulnerability {} affecting {} package(s):", + result.total_advisory_count, advisory_word, result.affected_package_count + ); + println!(); + + for advisories in result.advisories.values() { + for matched in advisories { + let adv = &matched.advisory; + println!("Package: {}", adv.package_name); + println!("Severity: {}", adv.severity.as_deref().unwrap_or("")); + println!("Advisory ID: {}", adv.advisory_id); + println!("CVE: {}", adv.cve.as_deref().unwrap_or("NO CVE")); + println!("Title: {}", adv.title); + println!("URL: {}", adv.link.as_deref().unwrap_or("")); + println!("Affected versions: {}", adv.affected_versions); + println!("Reported at: {}", adv.reported_at); + println!("--------"); + } + } + } + + for pkg in &result.abandoned { + match &pkg.replacement { + Some(repl) => println!("{} is abandoned. Use {} instead.", pkg.name, repl), + None => println!("{} is abandoned. No replacement was suggested.", pkg.name), + } + } +} + +fn render_json(result: &AuditResult) -> anyhow::Result<()> { + // Build advisories map: package_name -> [advisory objects] + let mut advisories_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new(); + for (pkg_name, advisories) in &result.advisories { + let arr: Vec<serde_json::Value> = advisories + .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)); + } + + // Build abandoned map: package_name -> replacement_or_null + let mut abandoned_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new(); + for pkg in &result.abandoned { + let repl = match &pkg.replacement { + Some(s) => serde_json::Value::String(s.clone()), + None => serde_json::Value::Null, + }; + abandoned_map.insert(pkg.name.clone(), repl); + } + + let output = serde_json::json!({ + "advisories": advisories_map, + "abandoned": abandoned_map, + }); + + println!("{}", serde_json::to_string_pretty(&output)?); + Ok(()) +} + +fn render_summary(result: &AuditResult) { + if result.total_advisory_count == 0 { + println!("No security vulnerability advisories found."); + } else { + let advisory_word = if result.total_advisory_count == 1 { + "advisory" + } else { + "advisories" + }; + println!( + "Found {} security vulnerability {} affecting {} package(s).", + result.total_advisory_count, advisory_word, result.affected_package_count + ); + println!("Run \"mozart audit\" for a full list of advisories."); + } + + for pkg in &result.abandoned { + match &pkg.replacement { + Some(repl) => println!("{} is abandoned. Use {} instead.", pkg.name, repl), + None => println!("{} is abandoned. No replacement was suggested.", pkg.name), + } + } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::packagist::{AdvisorySource, SecurityAdvisory}; + use std::collections::BTreeMap; + + fn make_advisory( + id: &str, + pkg: &str, + affected: &str, + severity: Option<&str>, + ) -> SecurityAdvisory { + SecurityAdvisory { + advisory_id: id.to_string(), + package_name: pkg.to_string(), + remote_id: format!("{id}.yaml"), + title: format!("Advisory {id}"), + link: None, + cve: None, + affected_versions: affected.to_string(), + source: "FriendsOfPHP/security-advisories".to_string(), + reported_at: "2024-01-01T00:00:00+00:00".to_string(), + composer_repository: None, + severity: severity.map(|s| s.to_string()), + sources: vec![], + } + } + + fn make_pkg(name: &str, version: &str, version_normalized: Option<&str>) -> PackageEntry { + PackageEntry { + name: name.to_string(), + version: version.to_string(), + version_normalized: version_normalized.map(|s| s.to_string()), + abandoned: None, + } + } + + fn make_pkg_abandoned(name: &str, version: &str, replacement: Option<&str>) -> PackageEntry { + let abandoned = match replacement { + Some(r) => Some(serde_json::Value::String(r.to_string())), + None => Some(serde_json::Value::Bool(true)), + }; + PackageEntry { + name: name.to_string(), + version: version.to_string(), + version_normalized: None, + abandoned, + } + } + + // ── filter_advisories ──────────────────────────────────────────────────── + + #[test] + fn test_filter_advisories_matching() { + let advisory = make_advisory("PKSA-0001", "vendor/pkg", ">=1.0,<2.0", None); + let mut all: BTreeMap<String, Vec<SecurityAdvisory>> = BTreeMap::new(); + all.insert("vendor/pkg".to_string(), vec![advisory]); + + let packages = vec![make_pkg("vendor/pkg", "1.5.0", Some("1.5.0.0"))]; + let result = filter_advisories(&all, &packages, &[]); + + assert_eq!(result.len(), 1); + assert_eq!(result["vendor/pkg"].len(), 1); + } + + #[test] + fn test_filter_advisories_not_matching() { + let advisory = make_advisory("PKSA-0002", "vendor/pkg", ">=1.0,<2.0", None); + let mut all: BTreeMap<String, Vec<SecurityAdvisory>> = BTreeMap::new(); + all.insert("vendor/pkg".to_string(), vec![advisory]); + + let packages = vec![make_pkg("vendor/pkg", "2.0.0", Some("2.0.0.0"))]; + let result = filter_advisories(&all, &packages, &[]); + + assert!(result.is_empty()); + } + + #[test] + fn test_filter_advisories_ignore_severity() { + let advisory = make_advisory("PKSA-0003", "vendor/pkg", ">=1.0,<2.0", Some("low")); + let mut all: BTreeMap<String, Vec<SecurityAdvisory>> = BTreeMap::new(); + all.insert("vendor/pkg".to_string(), vec![advisory]); + + let packages = vec![make_pkg("vendor/pkg", "1.5.0", Some("1.5.0.0"))]; + let result = filter_advisories(&all, &packages, &["low".to_string()]); + + assert!(result.is_empty()); + } + + #[test] + fn test_filter_advisories_multiple_packages() { + let adv1 = make_advisory("PKSA-0004", "vendor/pkg1", ">=1.0,<2.0", None); + let adv2 = make_advisory("PKSA-0005", "vendor/pkg2", ">=3.0,<4.0", None); + let mut all: BTreeMap<String, Vec<SecurityAdvisory>> = BTreeMap::new(); + all.insert("vendor/pkg1".to_string(), vec![adv1]); + all.insert("vendor/pkg2".to_string(), vec![adv2]); + + let packages = vec![ + make_pkg("vendor/pkg1", "1.5.0", Some("1.5.0.0")), + make_pkg("vendor/pkg2", "3.5.0", Some("3.5.0.0")), + ]; + let result = filter_advisories(&all, &packages, &[]); + + assert_eq!(result.len(), 2); + assert_eq!(result["vendor/pkg1"].len(), 1); + assert_eq!(result["vendor/pkg2"].len(), 1); + } + + #[test] + fn test_filter_advisories_complex_constraint() { + // OR constraint: >=1.0,<1.5|>=2.0,<2.3 + let advisory = make_advisory("PKSA-0006", "vendor/pkg", ">=1.0,<1.5|>=2.0,<2.3", None); + let mut all: BTreeMap<String, Vec<SecurityAdvisory>> = BTreeMap::new(); + all.insert("vendor/pkg".to_string(), vec![advisory]); + + // 2.1.0 is in [2.0, 2.3) so should match + let packages = vec![make_pkg("vendor/pkg", "2.1.0", Some("2.1.0.0"))]; + let result = filter_advisories(&all, &packages, &[]); + + assert_eq!(result.len(), 1); + } + + #[test] + fn test_filter_advisories_no_advisories() { + let all: BTreeMap<String, Vec<SecurityAdvisory>> = BTreeMap::new(); + let packages = vec![make_pkg("vendor/pkg", "1.5.0", Some("1.5.0.0"))]; + let result = filter_advisories(&all, &packages, &[]); + assert!(result.is_empty()); + } + + // ── detect_abandoned ───────────────────────────────────────────────────── + + #[test] + fn test_detect_abandoned_true() { + let packages = vec![make_pkg_abandoned("old/pkg", "1.0.0", None)]; + let result = detect_abandoned(&packages); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "old/pkg"); + assert!(result[0].replacement.is_none()); + } + + #[test] + fn test_detect_abandoned_with_replacement() { + let packages = vec![make_pkg_abandoned("old/pkg", "1.0.0", Some("new/pkg"))]; + let result = detect_abandoned(&packages); + assert_eq!(result.len(), 1); + assert_eq!(result[0].replacement.as_deref(), Some("new/pkg")); + } + + #[test] + fn test_detect_abandoned_not_abandoned() { + let packages = vec![make_pkg("active/pkg", "1.0.0", None)]; + let result = detect_abandoned(&packages); + assert!(result.is_empty()); + } + + #[test] + fn test_detect_abandoned_mixed() { + let packages = vec![ + make_pkg("active/pkg", "1.0.0", None), + make_pkg_abandoned("old/pkg", "2.0.0", Some("new/pkg")), + make_pkg("another/active", "3.0.0", None), + make_pkg_abandoned("dead/pkg", "1.0.0", None), + ]; + let result = detect_abandoned(&packages); + assert_eq!(result.len(), 2); + assert!(result.iter().any(|p| p.name == "old/pkg")); + assert!(result.iter().any(|p| p.name == "dead/pkg")); + } + + // ── load_installed_packages ─────────────────────────────────────────────── + + #[test] + fn test_load_installed_packages() { + use tempfile::tempdir; + + let dir = tempdir().unwrap(); + let working_dir = dir.path(); + let vendor_dir = working_dir.join("vendor"); + + let mut installed = crate::installed::InstalledPackages::new(); + installed.upsert(crate::installed::InstalledPackageEntry { + name: "monolog/monolog".to_string(), + version: "1.5.0".to_string(), + version_normalized: Some("1.5.0.0".to_string()), + source: None, + dist: None, + package_type: None, + install_path: None, + autoload: None, + aliases: vec![], + extra_fields: BTreeMap::new(), + }); + installed.write(&vendor_dir).unwrap(); + + let packages = load_installed_packages(working_dir, false).unwrap(); + assert_eq!(packages.len(), 1); + assert_eq!(packages[0].name, "monolog/monolog"); + assert_eq!(packages[0].version, "1.5.0"); + } + + #[test] + fn test_load_installed_packages_no_dev() { + use tempfile::tempdir; + + let dir = tempdir().unwrap(); + let working_dir = dir.path(); + let vendor_dir = working_dir.join("vendor"); + + let mut installed = crate::installed::InstalledPackages::new(); + installed.upsert(crate::installed::InstalledPackageEntry { + name: "monolog/monolog".to_string(), + version: "1.5.0".to_string(), + version_normalized: None, + source: None, + dist: None, + package_type: None, + install_path: None, + autoload: None, + aliases: vec![], + extra_fields: BTreeMap::new(), + }); + installed.upsert(crate::installed::InstalledPackageEntry { + name: "phpunit/phpunit".to_string(), + version: "10.0.0".to_string(), + version_normalized: None, + source: None, + dist: None, + package_type: None, + install_path: None, + autoload: None, + aliases: vec![], + extra_fields: BTreeMap::new(), + }); + installed + .dev_package_names + .push("phpunit/phpunit".to_string()); + installed.write(&vendor_dir).unwrap(); + + let packages = load_installed_packages(working_dir, true).unwrap(); + assert_eq!(packages.len(), 1); + assert_eq!(packages[0].name, "monolog/monolog"); + } + + #[test] + fn test_load_locked_packages() { + use crate::lockfile::{LockFile, LockedPackage}; + use tempfile::tempdir; + + let dir = tempdir().unwrap(); + let working_dir = dir.path(); + + let lock = LockFile { + readme: LockFile::default_readme(), + content_hash: "abc123".to_string(), + packages: vec![LockedPackage { + name: "psr/log".to_string(), + version: "3.0.0".to_string(), + version_normalized: Some("3.0.0.0".to_string()), + source: None, + dist: None, + require: BTreeMap::new(), + require_dev: BTreeMap::new(), + conflict: BTreeMap::new(), + suggest: None, + package_type: None, + autoload: None, + autoload_dev: None, + license: None, + description: None, + homepage: None, + keywords: None, + authors: None, + support: None, + funding: None, + time: None, + extra_fields: BTreeMap::new(), + }], + packages_dev: None, + aliases: vec![], + minimum_stability: "stable".to_string(), + stability_flags: serde_json::json!({}), + prefer_stable: false, + prefer_lowest: false, + platform: serde_json::json!({}), + platform_dev: serde_json::json!({}), + plugin_api_version: Some("2.6.0".to_string()), + }; + + lock.write_to_file(&working_dir.join("composer.lock")) + .unwrap(); + + let packages = load_locked_packages(working_dir, false).unwrap(); + assert_eq!(packages.len(), 1); + assert_eq!(packages[0].name, "psr/log"); + assert_eq!(packages[0].version, "3.0.0"); + } + + #[test] + fn test_load_locked_packages_no_dev() { + use crate::lockfile::{LockFile, LockedPackage}; + use tempfile::tempdir; + + let dir = tempdir().unwrap(); + let working_dir = dir.path(); + + let lock = LockFile { + readme: LockFile::default_readme(), + content_hash: "abc123".to_string(), + packages: vec![LockedPackage { + name: "psr/log".to_string(), + version: "3.0.0".to_string(), + version_normalized: None, + source: None, + dist: None, + require: BTreeMap::new(), + require_dev: BTreeMap::new(), + conflict: BTreeMap::new(), + suggest: None, + package_type: None, + autoload: None, + autoload_dev: None, + license: None, + description: None, + homepage: None, + keywords: None, + authors: None, + support: None, + funding: None, + time: None, + extra_fields: BTreeMap::new(), + }], + packages_dev: Some(vec![LockedPackage { + name: "phpunit/phpunit".to_string(), + version: "10.0.0".to_string(), + version_normalized: None, + source: None, + dist: None, + require: BTreeMap::new(), + require_dev: BTreeMap::new(), + conflict: BTreeMap::new(), + suggest: None, + package_type: None, + autoload: None, + autoload_dev: None, + license: None, + description: None, + homepage: None, + keywords: None, + authors: None, + support: None, + funding: None, + time: None, + extra_fields: BTreeMap::new(), + }]), + aliases: vec![], + minimum_stability: "stable".to_string(), + stability_flags: serde_json::json!({}), + prefer_stable: false, + prefer_lowest: false, + platform: serde_json::json!({}), + platform_dev: serde_json::json!({}), + plugin_api_version: Some("2.6.0".to_string()), + }; + + lock.write_to_file(&working_dir.join("composer.lock")) + .unwrap(); + + // With --no-dev: only prod + let packages = load_locked_packages(working_dir, true).unwrap(); + assert_eq!(packages.len(), 1); + assert_eq!(packages[0].name, "psr/log"); + + // Without --no-dev: both + let packages_all = load_locked_packages(working_dir, false).unwrap(); + assert_eq!(packages_all.len(), 2); + } + + #[test] + fn test_load_locked_packages_missing_lockfile() { + use tempfile::tempdir; + + let dir = tempdir().unwrap(); + let result = load_locked_packages(dir.path(), false); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("composer.lock")); + } + + // ── render_json ─────────────────────────────────────────────────────────── + + #[test] + fn test_render_json_structure() { + let advisory = make_advisory("PKSA-0001", "vendor/pkg", ">=1.0,<2.0", Some("high")); + let mut advisories: BTreeMap<String, Vec<MatchedAdvisory>> = BTreeMap::new(); + advisories.insert( + "vendor/pkg".to_string(), + vec![MatchedAdvisory { + advisory, + installed_version: "1.5.0".to_string(), + }], + ); + + let abandoned = vec![AbandonedPackage { + name: "old/pkg".to_string(), + version: "1.0.0".to_string(), + replacement: Some("new/pkg".to_string()), + }]; + + let result = AuditResult { + affected_package_count: 1, + total_advisory_count: 1, + advisories, + abandoned, + }; + + // Should not panic + render_json(&result).unwrap(); + } + + #[test] + fn test_render_json_empty() { + let result = AuditResult { + advisories: BTreeMap::new(), + abandoned: vec![], + affected_package_count: 0, + total_advisory_count: 0, + }; + render_json(&result).unwrap(); + } + + // ── argument validation ─────────────────────────────────────────────────── + + #[test] + fn test_invalid_format() { + // We test the validation logic directly + let format = "xml"; + let valid = + format == "table" || format == "plain" || format == "json" || format == "summary"; + assert!(!valid); + } + + #[test] + fn test_invalid_abandoned_value() { + let value = "maybe"; + let valid = value == "ignore" || value == "report" || value == "fail"; + assert!(!valid); + } + + #[test] + fn test_valid_formats() { + for format in &["table", "plain", "json", "summary"] { + let valid = *format == "table" + || *format == "plain" + || *format == "json" + || *format == "summary"; + assert!(valid, "format {} should be valid", format); + } + } + + #[test] + fn test_valid_abandoned_values() { + for value in &["ignore", "report", "fail"] { + let valid = *value == "ignore" || *value == "report" || *value == "fail"; + assert!(valid, "abandoned value {} should be valid", value); + } + } + + // ── AdvisorySource used in test helper (suppress dead_code) ────────────── + + #[test] + fn test_advisory_source_fields() { + let src = AdvisorySource { + name: "FriendsOfPHP/security-advisories".to_string(), + remote_id: "monolog/monolog/2017-11-13-1.yaml".to_string(), + }; + assert_eq!(src.name, "FriendsOfPHP/security-advisories"); + assert_eq!(src.remote_id, "monolog/monolog/2017-11-13-1.yaml"); + } } diff --git a/crates/mozart/src/packagist.rs b/crates/mozart/src/packagist.rs index 65b1ecd..ba80e7e 100644 --- a/crates/mozart/src/packagist.rs +++ b/crates/mozart/src/packagist.rs @@ -260,6 +260,113 @@ pub fn search_packages( Ok((all_results, total)) } +// ───────────────────────────────────────────────────────────────────────────── +// Security Advisories API +// ───────────────────────────────────────────────────────────────────────────── + +/// A single security advisory from the Packagist API. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SecurityAdvisory { + #[serde(rename = "advisoryId")] + pub advisory_id: String, + + #[serde(rename = "packageName")] + pub package_name: String, + + #[serde(rename = "remoteId")] + pub remote_id: String, + + pub title: String, + + pub link: Option<String>, + + pub cve: Option<String>, + + /// Composer version constraint string, e.g. ">=1.0,<1.5.1|>=2.0,<2.3" + #[serde(rename = "affectedVersions")] + pub affected_versions: String, + + pub source: String, + + #[serde(rename = "reportedAt")] + pub reported_at: String, + + #[serde(rename = "composerRepository")] + pub composer_repository: Option<String>, + + pub severity: Option<String>, + + #[serde(default)] + pub sources: Vec<AdvisorySource>, +} + +/// A source entry within a security advisory. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AdvisorySource { + pub name: String, + #[serde(rename = "remoteId")] + pub remote_id: String, +} + +/// Response from POST `https://packagist.org/api/security-advisories/`. +#[derive(Debug, Deserialize)] +pub struct SecurityAdvisoriesResponse { + pub advisories: BTreeMap<String, Vec<SecurityAdvisory>>, +} + +/// Fetch security advisories for the given package names from the Packagist API. +/// +/// Sends a POST request to `https://packagist.org/api/security-advisories/` +/// with form-encoded package names. Returns advisories grouped by package name. +/// +/// If the package list is very large (500+), requests are batched in chunks of +/// 500 names per request and the results are merged. +pub fn fetch_security_advisories( + package_names: &[&str], +) -> anyhow::Result<BTreeMap<String, Vec<SecurityAdvisory>>> { + let client = reqwest::blocking::Client::builder() + .user_agent("mozart/0.1.0") + .build()?; + + let mut all_advisories: BTreeMap<String, Vec<SecurityAdvisory>> = BTreeMap::new(); + + for chunk in package_names.chunks(500) { + // Build an application/x-www-form-urlencoded body manually. + // Each package is encoded as `packages[]=<name>` and joined with `&`. + let body: String = chunk + .iter() + .map(|name| format!("packages[]={}", url_encode(name))) + .collect::<Vec<_>>() + .join("&"); + + let response = client + .post("https://packagist.org/api/security-advisories/") + .header("Content-Type", "application/x-www-form-urlencoded") + .body(body) + .send()?; + + if !response.status().is_success() { + anyhow::bail!( + "Packagist security advisories request failed (HTTP {})", + response.status() + ); + } + + let parsed: SecurityAdvisoriesResponse = response.json()?; + + for (pkg_name, advisories) in parsed.advisories { + if !advisories.is_empty() { + all_advisories + .entry(pkg_name) + .or_default() + .extend(advisories); + } + } + } + + Ok(all_advisories) +} + #[cfg(test)] mod tests { use super::*; @@ -431,4 +538,92 @@ mod tests { let aliases = versions[0].branch_aliases(); assert!(aliases.is_empty()); } + + // ──────────── SecurityAdvisory parsing tests ───────────────────────────── + + #[test] + fn test_parse_security_advisories_response() { + let json = r#"{ + "advisories": { + "monolog/monolog": [ + { + "advisoryId": "PKSA-b2m0-qqf7-qck4", + "packageName": "monolog/monolog", + "remoteId": "monolog/monolog/2017-11-13-1.yaml", + "title": "Header injection in NativeMailerHandler", + "link": "https://github.com/Seldaek/monolog/pull/683", + "cve": null, + "affectedVersions": ">=1.8.0,<1.12.0", + "source": "FriendsOfPHP/security-advisories", + "reportedAt": "2017-11-13T00:00:00+00:00", + "composerRepository": "https://packagist.org", + "severity": "low", + "sources": [ + { + "name": "FriendsOfPHP/security-advisories", + "remoteId": "monolog/monolog/2017-11-13-1.yaml" + } + ] + } + ] + } + }"#; + + let response: SecurityAdvisoriesResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.advisories.len(), 1); + let advisories = response.advisories.get("monolog/monolog").unwrap(); + assert_eq!(advisories.len(), 1); + let adv = &advisories[0]; + assert_eq!(adv.advisory_id, "PKSA-b2m0-qqf7-qck4"); + assert_eq!(adv.package_name, "monolog/monolog"); + assert_eq!(adv.title, "Header injection in NativeMailerHandler"); + assert_eq!(adv.affected_versions, ">=1.8.0,<1.12.0"); + assert_eq!(adv.severity.as_deref(), Some("low")); + assert!(adv.cve.is_none()); + assert_eq!(adv.sources.len(), 1); + assert_eq!(adv.sources[0].name, "FriendsOfPHP/security-advisories"); + } + + #[test] + fn test_parse_security_advisories_empty() { + let json = r#"{"advisories": {"other/package": []}}"#; + let response: SecurityAdvisoriesResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.advisories.len(), 1); + let advisories = response.advisories.get("other/package").unwrap(); + assert!(advisories.is_empty()); + } + + #[test] + fn test_parse_security_advisories_null_fields() { + let json = r#"{ + "advisories": { + "vendor/pkg": [ + { + "advisoryId": "PKSA-0000-0000-0000", + "packageName": "vendor/pkg", + "remoteId": "vendor/pkg/2024-01-01.yaml", + "title": "Some vulnerability", + "link": null, + "cve": null, + "affectedVersions": ">=1.0,<2.0", + "source": "FriendsOfPHP/security-advisories", + "reportedAt": "2024-01-01T00:00:00+00:00", + "composerRepository": null, + "severity": null, + "sources": [] + } + ] + } + }"#; + + let response: SecurityAdvisoriesResponse = serde_json::from_str(json).unwrap(); + let advisories = response.advisories.get("vendor/pkg").unwrap(); + assert_eq!(advisories.len(), 1); + let adv = &advisories[0]; + assert!(adv.link.is_none()); + assert!(adv.cve.is_none()); + assert!(adv.severity.is_none()); + assert!(adv.composer_repository.is_none()); + assert!(adv.sources.is_empty()); + } } |
