diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-21 19:07:56 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-21 19:07:56 +0900 |
| commit | ef9cb52b7e7ea83434a7b391d614eb198175a990 (patch) | |
| tree | 456a4afc4f780f44159fdb46d2e3e28bf7a8bb7f /crates | |
| parent | 727b75d20ee54563fe0f1619341fd4cfaf814211 (diff) | |
| download | php-mozart-ef9cb52b7e7ea83434a7b391d614eb198175a990.tar.gz php-mozart-ef9cb52b7e7ea83434a7b391d614eb198175a990.tar.zst php-mozart-ef9cb52b7e7ea83434a7b391d614eb198175a990.zip | |
feat(audit): implement audit command to check packages for security vulnerabilities
Query Packagist security advisories API for known vulnerabilities
affecting installed or locked packages, with version constraint
matching, severity filtering, abandoned package detection, and
multiple output formats (table, plain, json, summary).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates')
| -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()); + } } |
