/
githubmirror
/
deno
Обзор
Документация
Войти
/
githubmirror
/
deno
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
cli/tools/pm/audit.rs
1 128 строк
32 KB
Nathan Whitaker
fix(cli): escape control characters in external metadata (#36198)
06 авг 2026, 00:25
Не верифицирован
06 авг 2026, 00:25
fdda8b7
Код
Авторство
О чём код?
// Copyright 2018-2026 the Deno authors. MIT license. use std::io::Write; use std::sync::Arc; use deno_core::anyhow::Context; use deno_core::error::AnyError; use deno_core::futures; use deno_core::futures::FutureExt; use deno_core::futures::StreamExt; use deno_core::serde_json; use deno_npm::resolution::NpmResolutionSnapshot; use eszip::v2::Url; use http::header::HeaderName; use http::header::HeaderValue; use serde::Deserialize; use crate::args::AuditFlags; use crate::args::Flags; use crate::colors; use crate::factory::CliFactory; use crate::http_util; use crate::http_util::HttpClient; use crate::http_util::HttpClientProvider; use crate::util::console::escape_terminal_control_chars; struct FixableAction { module_name: String, target_version: String, is_major: bool, } struct AuditResult { exit_code: i32, fixable_actions: Vec<FixableAction>, } pub async fn audit( flags: Arc<Flags>, audit_flags: AuditFlags, ) -> Result<i32, AnyError> { let factory = CliFactory::from_flags(flags.clone()); let npm_resolver = factory.npm_resolver().await?; let npm_resolver = npm_resolver.as_managed().unwrap(); let snapshot = npm_resolver.resolution().snapshot(); let npm_url = &factory.npmrc()?.default_config.registry_url; let http_provider = HttpClientProvider::new(None, None); let http_client = http_provider .get_or_create() .context("Failed to create HTTP client")?; let use_socket = audit_flags.socket; let fix = audit_flags.fix; let result = npm::call_audits_api(audit_flags, npm_url, &snapshot, http_client).await?; if use_socket { socket_dev::call_firewall_api( &snapshot, http_provider.get_or_create().unwrap(), ) .await?; } if fix && !result.fixable_actions.is_empty() { apply_fixes(&factory, flags, &result.fixable_actions).await?; } Ok(result.exit_code) } async fn apply_fixes( factory: &CliFactory, flags: Arc<Flags>, fixable_actions: &[FixableAction], ) -> Result<(), AnyError> { use deno_semver::VersionReq; use super::CacheTopLevelDepsOptions; let (mut deps, jsr_fetch_resolver) = super::create_dep_manager_and_resolvers(factory).await?; let mut fixed = Vec::new(); let mut unfixable = Vec::new(); // Build a map of dep name -> (dep_id, version_req_str) for matching. // If multiple deps share the same package name (e.g. aliases), treat // them as unfixable to avoid updating the wrong one. let mut dep_lookup: std::collections::HashMap< String, Option<(super::deps::DepId, String)>, > = std::collections::HashMap::new(); for (id, dep) in deps.deps_with_ids() { let name = dep.req.name.to_string(); let entry = dep_lookup.entry(name); match entry { std::collections::hash_map::Entry::Vacant(e) => { e.insert(Some((id, dep.req.version_req.to_string()))); } std::collections::hash_map::Entry::Occupied(mut e) => { // Duplicate - mark as ambiguous e.insert(None); } } } for action in fixable_actions { if action.is_major { unfixable.push(format!( "{} (major upgrade to {})", action.module_name, action.target_version )); continue; } match dep_lookup.get(&action.module_name) { Some(Some((dep_id, version_req_str))) => { // Preserve the original version requirement style. // Only handle simple spec styles (caret, tilde, exact pin) // to avoid silently rewriting complex ranges. let trimmed = version_req_str.trim(); let new_spec = if trimmed.starts_with('~') { format!("~{}", action.target_version) } else if trimmed.starts_with('^') { format!("^{}", action.target_version) } else if trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) { action.target_version.clone() } else { unfixable.push(format!( "{} (unsupported version spec: {})", action.module_name, version_req_str )); continue; }; let new_version_req = VersionReq::parse_from_specifier(&new_spec)?; deps.update_dep(*dep_id, new_version_req); fixed.push(format!( "{} {} -> {}", action.module_name, version_req_str, new_spec )); } Some(None) => { unfixable.push(format!( "{} (ambiguous: multiple dependencies with this name)", action.module_name )); } None => { unfixable .push(format!("{} (transitive dependency)", action.module_name)); } } } if !fixed.is_empty() { deps.commit_changes()?; super::npm_install_after_modification( flags, Some(jsr_fetch_resolver), CacheTopLevelDepsOptions { lockfile_only: false, additional_roots: vec![], }, ) .await?; } print_fix_summary(&mut std::io::stdout(), &fixed, &unfixable); Ok(()) } fn print_fix_summary( stdout: &mut impl Write, fixed: &[String], unfixable: &[String], ) { if !fixed.is_empty() { _ = writeln!( stdout, "\nFixed {} vulnerabilit{}:", fixed.len(), if fixed.len() == 1 { "y" } else { "ies" } ); for f in fixed { _ = writeln!(stdout, " {}", escape_terminal_control_chars(f)); } } if !unfixable.is_empty() { _ = writeln!( stdout, "\n{} vulnerabilit{} could not be fixed automatically:", unfixable.len(), if unfixable.len() == 1 { "y" } else { "ies" } ); for u in unfixable { _ = writeln!(stdout, " {}", escape_terminal_control_chars(u)); } } } mod npm { use std::collections::HashMap; use std::collections::HashSet; use super::*; #[derive(Clone, Copy, Debug, PartialEq, PartialOrd)] enum AdvisorySeverity { Low, Moderate, High, Critical, } impl AdvisorySeverity { fn parse(str_: &str) -> Option<Self> { match str_ { "low" => Some(Self::Low), "moderate" => Some(Self::Moderate), "high" => Some(Self::High), "critical" => Some(Self::Critical), _ => None, } } } pub async fn call_audits_api_inner( client: &HttpClient, npm_url: Url, body: serde_json::Value, ) -> Result<BulkAuditResponse, AnyError> { let url = npm_url.join("-/npm/v1/security/advisories/bulk").unwrap(); let future = client.post_json(url, &body)?.send().boxed_local(); let response = future.await?; let json_str = http_util::body_to_string(response) .await .context("Failed to read response from the npm registry API")?; let response: BulkAuditResponse = serde_json::from_str(&json_str) .context("Failed to deserialize response from the npm registry API")?; Ok(response) } pub async fn call_audits_api( audit_flags: AuditFlags, npm_url: &Url, npm_resolution_snapshot: &NpmResolutionSnapshot, client: HttpClient, ) -> Result<super::AuditResult, AnyError> { // Build request body for the bulk advisory endpoint: // { "pkg-name": ["ver1", "ver2"], ... } let mut body_map: HashMap<String, HashSet<String>> = HashMap::new(); for pkg in npm_resolution_snapshot.all_packages_for_every_system() { body_map .entry(pkg.id.nv.name.to_string()) .or_default() .insert(pkg.id.nv.version.to_string()); } let body: HashMap<String, Vec<String>> = body_map .into_iter() .map(|(k, v)| (k, v.into_iter().collect())) .collect(); let body = serde_json::to_value(&body).unwrap(); let bulk_response = match call_audits_api_inner(&client, npm_url.clone(), body).await { Ok(s) => s, Err(err) => { if audit_flags.ignore_registry_errors { log::error!("Failed to get data from the registry: {}", err); return Ok(super::AuditResult { exit_code: 0, fixable_actions: vec![], }); } else { return Err(err); } } }; // Convert bulk response to flat list of advisories let mut advisories: Vec<AuditAdvisory> = Vec::new(); for (pkg_name, pkg_advisories) in &bulk_response { for adv in pkg_advisories { advisories.push(AuditAdvisory { title: adv.title.clone(), severity: adv.severity.clone(), url: adv.url.clone(), module_name: pkg_name.clone(), vulnerable_versions: adv.vulnerable_versions.clone(), patched_versions: adv.patched_versions.clone().unwrap_or_default(), cves: adv.cves.clone(), }); } } // Build map of installed versions per package for vulnerability filtering // and fix target computation. let mut installed_versions: HashMap<String, Vec<deno_semver::Version>> = HashMap::new(); for pkg in npm_resolution_snapshot.all_packages_for_every_system() { installed_versions .entry(pkg.id.nv.name.to_string()) .or_default() .push(pkg.id.nv.version.clone()); } // Filter out advisories where no installed version falls within // the vulnerable range. This handles package.json overrides that // force a patched version. advisories.retain(|adv| { let Ok(vulnerable_range) = deno_semver::VersionReq::parse_from_npm(&adv.vulnerable_versions) else { return true; }; if let Some(versions) = installed_versions.get(&adv.module_name) { versions.iter().any(|v| vulnerable_range.matches(v)) } else { false } }); // Filter out ignored CVEs if !audit_flags.ignore.is_empty() { advisories.retain(|adv| { !adv.cves.iter().any(|cve| audit_flags.ignore.contains(cve)) }); } // Compute vulnerability counts from remaining advisories let mut vulns = AuditVulnerabilities { low: 0, moderate: 0, high: 0, critical: 0, }; for adv in &advisories { match AdvisorySeverity::parse(&adv.severity) { Some(AdvisorySeverity::Low) => vulns.low += 1, Some(AdvisorySeverity::Moderate) => vulns.moderate += 1, Some(AdvisorySeverity::High) => vulns.high += 1, Some(AdvisorySeverity::Critical) => vulns.critical += 1, None => {} } } if vulns.total() == 0 { _ = writeln!(&mut std::io::stdout(), "No known vulnerabilities found",); return Ok(super::AuditResult { exit_code: 0, fixable_actions: vec![], }); } advisories.sort_by_cached_key(|adv| { format!("{}@{}", adv.module_name, adv.vulnerable_versions) }); let minimal_severity = AdvisorySeverity::parse(&audit_flags.severity).unwrap(); print_report( &vulns, &advisories, minimal_severity, audit_flags.ignore_unfixable, ); // Derive fixable actions from advisories at or above the severity // threshold that have patched versions. The bulk API does not return // explicit "actions" like the retired full audit API did, so we // extract the minimum satisfying version from patched_versions ranges. let fixable_actions = derive_fixable_actions( &advisories, &installed_versions, minimal_severity, ); // Exit code 1 only if there are vulnerabilities at or above the specified level let exit_code = if vulns.count_at_or_above(minimal_severity) > 0 { 1 } else { 0 }; Ok(super::AuditResult { exit_code, fixable_actions, }) } /// Derive fix actions from advisory patched_versions ranges. /// /// Only considers advisories at or above `min_severity` so that /// `--fix` respects the `--severity` flag. For each vulnerable /// package, parse the patched_versions range to find the minimum /// version that fixes the vulnerability. If multiple advisories /// affect the same package, pick the highest target version so all /// are resolved. Compare the target major version with the installed /// major version to determine if it is a major upgrade. fn derive_fixable_actions( advisories: &[AuditAdvisory], installed_versions: &HashMap<String, Vec<deno_semver::Version>>, min_severity: AdvisorySeverity, ) -> Vec<super::FixableAction> { use deno_semver::Version; // module_name -> (target_version, is_major) let mut best_target: HashMap<String, (Version, bool)> = HashMap::new(); for adv in advisories { // Skip advisories below the severity threshold let Some(severity) = AdvisorySeverity::parse(&adv.severity) else { continue; }; if severity < min_severity { continue; } if adv.patched_versions.is_empty() { continue; } let Some(target) = min_version_from_range(&adv.patched_versions) else { continue; }; let installed_major = installed_versions .get(&adv.module_name) .and_then(|vs| vs.iter().min()) .map(|v| v.major) .unwrap_or(0); let is_major = target.major > installed_major; match best_target.get(&adv.module_name) { Some((existing, _)) if *existing >= target => {} _ => { best_target.insert(adv.module_name.clone(), (target, is_major)); } } } best_target .into_iter() .map(|(module_name, (target, is_major))| super::FixableAction { module_name, target_version: target.to_string(), is_major, }) .collect() } /// Extract the minimum patched version from a npm version range string. /// /// Handles common patched_versions formats from npm advisories: /// - ">=1.1.0" or ">=1.1.0 <2.0.0" (lower-bounded range) /// - ">1.0.0" (exclusive lower bound -- we cannot determine exact /// min version, so skip) /// - "=1.1.0" or "1.1.0" (exact version) /// /// Returns None if the range cannot be parsed or has no usable lower /// bound, in which case the advisory is skipped for auto-fix. fn min_version_from_range(range: &str) -> Option<deno_semver::Version> { let trimmed = range.trim(); // ">=X.Y.Z ..." -- most common npm patched_versions format if let Some(rest) = trimmed.strip_prefix(">=") { let ver_str = rest.split_whitespace().next()?; return deno_semver::Version::parse_standard(ver_str).ok(); } // "=X.Y.Z" -- exact version if let Some(rest) = trimmed.strip_prefix('=') { let ver_str = rest.split_whitespace().next()?; return deno_semver::Version::parse_standard(ver_str).ok(); } // Bare version "X.Y.Z" (no operator) -- treat as exact if trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) { let ver_str = trimmed.split_whitespace().next()?; return deno_semver::Version::parse_standard(ver_str).ok(); } // ">X.Y.Z", "~X.Y.Z", "^X.Y.Z", "||" ranges, etc. // We cannot reliably determine the exact minimum version // for these, so skip them (advisory will not be auto-fixed). None } fn print_report( vulns: &AuditVulnerabilities, advisories: &[AuditAdvisory], minimal_severity: AdvisorySeverity, ignore_unfixable: bool, ) { let stdout = &mut std::io::stdout(); print_report_to( stdout, vulns, advisories, minimal_severity, ignore_unfixable, ); } fn print_report_to( stdout: &mut impl Write, vulns: &AuditVulnerabilities, advisories: &[AuditAdvisory], minimal_severity: AdvisorySeverity, ignore_unfixable: bool, ) { for adv in advisories { let Some(severity) = AdvisorySeverity::parse(&adv.severity) else { continue; }; if severity < minimal_severity { continue; } let has_fix = !adv.patched_versions.is_empty(); if !has_fix && ignore_unfixable { continue; } let title = escape_terminal_control_chars(&adv.title); let module_name = escape_terminal_control_chars(&adv.module_name); let vulnerable_versions = escape_terminal_control_chars(&adv.vulnerable_versions); let patched_versions = escape_terminal_control_chars(&adv.patched_versions); let url = escape_terminal_control_chars(&adv.url); _ = writeln!(stdout, "╭ {}", colors::bold(title)); _ = writeln!( stdout, "│ {} {}", colors::gray("Severity:"), match severity { AdvisorySeverity::Low => colors::bold("low"), AdvisorySeverity::Moderate => colors::yellow("moderate"), AdvisorySeverity::High => colors::red("high"), AdvisorySeverity::Critical => colors::red("critical"), } ); _ = writeln!(stdout, "│ {} {}", colors::gray("Package:"), module_name); _ = writeln!( stdout, "│ {} {}", colors::gray("Vulnerable:"), vulnerable_versions ); if has_fix { _ = writeln!( stdout, "│ {} {}", colors::gray("Patched:"), patched_versions ); _ = writeln!(stdout, "│ {} {}", colors::gray("Info:"), url); _ = writeln!( stdout, "╰ {} update {} to {}", colors::gray("Actions:"), module_name, patched_versions ); } else { _ = writeln!(stdout, "╰ {} {}", colors::gray("Info:"), url); } _ = writeln!(stdout); } _ = writeln!( stdout, "Found {} vulnerabilities", colors::red(vulns.total()), ); _ = writeln!( stdout, "Severity: {} {}, {} {}, {} {}, {} {}", colors::bold(vulns.low), colors::bold("low"), colors::yellow(vulns.moderate), colors::yellow("moderate"), colors::red(vulns.high), colors::red("high"), colors::red(vulns.critical), colors::red("critical"), ); } #[cfg(test)] mod tests { use super::*; #[test] fn print_report_escapes_advisory_controls() { let vulns = AuditVulnerabilities { low: 0, moderate: 0, high: 1, critical: 0, }; let advisories = [AuditAdvisory { title: "title\x1b[2J".to_string(), severity: "high".to_string(), url: "https://example.com/\u{202e}info".to_string(), module_name: "pkg\nname".to_string(), vulnerable_versions: "<1\u{009b}31m".to_string(), patched_versions: ">=2\x07".to_string(), cves: vec![], }]; let mut output = Vec::new(); print_report_to( &mut output, &vulns, &advisories, AdvisorySeverity::Low, false, ); let output = String::from_utf8(output).unwrap(); assert!(output.contains(r"title\u{1b}[2J")); assert!(output.contains(r"pkg\nname")); assert!(output.contains(r"<1\u{9b}31m")); assert!(output.contains(r">=2\u{7}")); assert!(output.contains(r"https://example.com/\u{202e}info")); assert!(!output.contains("title\x1b[2J")); assert!(!output.contains('\u{202e}')); } } /// Advisory item from the bulk API response. #[derive(Debug, Deserialize)] pub struct BulkAdvisoryItem { pub url: String, pub title: String, pub severity: String, pub vulnerable_versions: String, #[serde(default)] pub patched_versions: Option<String>, #[serde(default)] pub cves: Vec<String>, #[serde(default)] #[allow(dead_code, reason = "deserialized but not yet displayed")] pub cwe: Vec<String>, } /// The bulk advisory endpoint response: { "package-name": [advisory, ...] } pub type BulkAuditResponse = HashMap<String, Vec<BulkAdvisoryItem>>; /// Internal advisory representation with module name from the response key. struct AuditAdvisory { title: String, severity: String, url: String, module_name: String, vulnerable_versions: String, patched_versions: String, cves: Vec<String>, } struct AuditVulnerabilities { low: i32, moderate: i32, high: i32, critical: i32, } impl AuditVulnerabilities { fn total(&self) -> i32 { self.low + self.moderate + self.high + self.critical } fn count_at_or_above(&self, min_severity: AdvisorySeverity) -> i32 { match min_severity { AdvisorySeverity::Low => self.total(), AdvisorySeverity::Moderate => self.moderate + self.high + self.critical, AdvisorySeverity::High => self.high + self.critical, AdvisorySeverity::Critical => self.critical, } } } } mod socket_dev { use super::*; pub async fn call_firewall_api( npm_resolution_snapshot: &NpmResolutionSnapshot, client: HttpClient, ) -> Result<(), AnyError> { let purls = npm_resolution_snapshot .all_packages_for_every_system() .map(|package| { format!("pkg:npm/{}@{}", package.id.nv.name, package.id.nv.version) }) .collect::<Vec<_>>(); let api_key = std::env::var("SOCKET_API_KEY").ok(); let mut purl_responses = if let Some(api_key) = api_key { call_authenticated_api(&client, &purls, &api_key).await? } else { call_unauthenticated_api(&client, &purls).await? }; purl_responses.sort_by_cached_key(|r| r.name.to_string()); print_firewall_report(&purl_responses); Ok(()) } async fn call_authenticated_api( client: &HttpClient, purls: &[String], api_key: &str, ) -> Result<Vec<FirewallResponse>, AnyError> { let socket_dev_url = std::env::var("SOCKET_DEV_URL").ok().unwrap_or_else(|| { "https://api.socket.dev/v0/purl?actions=error,warn".to_string() }); let url = Url::parse(&socket_dev_url).unwrap(); let body = serde_json::json!({ "components": purls.iter().map(|purl| { serde_json::json!({ "purl": purl }) }).collect::<Vec<_>>() }); let auth_value = HeaderValue::from_str(&format!("Bearer {}", api_key)) .context("Failed to create Authorization header")?; let request = client .post_json(url, &body)? .header(HeaderName::from_static("authorization"), auth_value); let response = request.send().boxed_local().await?; let text = http_util::body_to_string(response).await?; // Response is nJSON let responses = text .lines() .filter(|line| !line.trim().is_empty()) .map(|line| { serde_json::from_str::<FirewallResponse>(line) .context("Failed to parse Socket.dev response") }) .collect::<Result<Vec<_>, _>>()?; Ok(responses) } async fn call_unauthenticated_api( client: &HttpClient, purls: &[String], ) -> Result<Vec<FirewallResponse>, AnyError> { let socket_dev_url = std::env::var("SOCKET_DEV_URL") .ok() .unwrap_or_else(|| "https://firewall-api.socket.dev/".to_string()); let futures = purls .iter() .map(|purl| { let url = Url::parse(&format!( "{}purl/{}", socket_dev_url, percent_encoding::utf8_percent_encode( purl, percent_encoding::NON_ALPHANUMERIC ) )) .unwrap(); client.download_text(url).boxed_local() }) .collect::<Vec<_>>(); let purl_results = futures::stream::iter(futures) .buffer_unordered(20) .collect::<Vec<_>>() .await; let responses = purl_results .into_iter() .filter_map(|result| match result { Ok(a) => Some(a), Err(err) => { log::error!("Failed to get PURL result {:?}", err); None } }) .filter_map(|json_response| { match serde_json::from_str::<FirewallResponse>(&json_response) { Ok(response) => Some(response), Err(err) => { log::error!("Failed deserializing socket.dev response {:?}", err); None } } }) .collect::<Vec<_>>(); Ok(responses) } fn print_firewall_report(responses: &[FirewallResponse]) { let stdout = &mut std::io::stdout(); print_firewall_report_to(stdout, responses); } fn print_firewall_report_to( stdout: &mut impl Write, responses: &[FirewallResponse], ) { let responses_with_alerts = responses .iter() .filter(|r| !r.alerts.is_empty()) .collect::<Vec<_>>(); if responses_with_alerts.is_empty() { return; } _ = writeln!(stdout); _ = writeln!(stdout, "{}", colors::bold("Socket.dev firewall report")); _ = writeln!(stdout); // Count total alerts by severity let mut total_critical = 0; let mut total_high = 0; let mut total_medium = 0; let mut total_low = 0; let mut packages_with_issues = 0; for response in responses_with_alerts { packages_with_issues += 1; _ = writeln!( stdout, "╭ pkg:npm/{}@{}", escape_terminal_control_chars(&response.name), escape_terminal_control_chars(&response.version) ); if let Some(score) = &response.score { _ = writeln!( stdout, "│ {:<20} {:>3}", colors::gray("Supply Chain Risk:"), format_score(score.supply_chain) ); _ = writeln!( stdout, "│ {:<20} {:>3}", colors::gray("Maintenance:"), format_score(score.maintenance) ); _ = writeln!( stdout, "│ {:<20} {:>3}", colors::gray("Quality:"), format_score(score.quality) ); _ = writeln!( stdout, "│ {:<20} {:>3}", colors::gray("Vulnerabilities:"), format_score(score.vulnerability) ); _ = writeln!( stdout, "│ {:<20} {:>3}", colors::gray("License:"), format_score(score.license) ); } // critical and high are counted as one for display. let mut critical_count = 0; let mut medium_count = 0; let mut low_count = 0; for alert in &response.alerts { match alert.severity.as_str() { "critical" => { total_critical += 1; critical_count += 1; } "high" => { total_high += 1; critical_count += 1; } "medium" => { total_medium += 1; medium_count += 1; } "low" => { total_low += 1; low_count += 1; } _ => {} } } if !response.alerts.is_empty() { let alerts_str = response .alerts .iter() .map(|alert| { let severity_bracket = match alert.severity.as_str() { "critical" => colors::red("critical").to_string(), "high" => colors::red("high").to_string(), "medium" => colors::yellow("medium").to_string(), "low" => "low".to_string(), _ => escape_terminal_control_chars(&alert.severity).into_owned(), }; format!( "[{}] {}", severity_bracket, escape_terminal_control_chars(&alert.r#type) ) }) .collect::<Vec<_>>() .join(", "); let label = format!( "Alerts ({}/{}/{}):", critical_count, medium_count, low_count ); _ = writeln!(stdout, "╰ {:<20} {}", colors::gray(&label), alerts_str); } else { _ = writeln!(stdout, "╰"); } _ = writeln!(stdout); } let total_alerts = total_critical + total_high + total_medium + total_low; if total_alerts == 0 && packages_with_issues == 0 { _ = writeln!(stdout, "No security alerts found from Socket.dev"); return; } if total_alerts > 0 { _ = writeln!( stdout, "Found {} alerts across {} packages", colors::red(total_alerts), colors::bold(packages_with_issues) ); _ = writeln!( stdout, "Severity: {} {}, {} {}, {} {}, {} {}", colors::bold(total_low), colors::bold("low"), colors::yellow(total_medium), colors::yellow("medium"), colors::red(total_high), colors::red("high"), colors::red(total_critical), colors::red("critical"), ); } } fn format_score(score: f64) -> String { let percentage = (score * 100.0) as i32; let colored = if percentage >= 80 { colors::green(percentage) } else if percentage >= 60 { colors::yellow(percentage) } else { colors::red(percentage) }; format!("{}", colored) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FirewallScore { pub license: f64, pub maintenance: f64, #[allow(dead_code, reason = "we don't use it yet")] pub overall: f64, pub quality: f64, pub supply_chain: f64, pub vulnerability: f64, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FirewallAlert { pub r#type: String, #[allow(dead_code, reason = "we don't use it yet")] pub action: String, pub severity: String, #[allow(dead_code, reason = "we don't use it yet")] pub category: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FirewallResponse { #[allow(dead_code, reason = "we don't use it yet")] pub id: String, pub name: String, pub version: String, pub score: Option<FirewallScore>, #[serde(default)] pub alerts: Vec<FirewallAlert>, } #[cfg(test)] mod tests { use super::*; #[test] fn print_firewall_report_escapes_external_text() { let responses = [FirewallResponse { id: "id".to_string(), name: "pkg\nname".to_string(), version: "1\u{202e}.0".to_string(), score: None, alerts: vec![ FirewallAlert { r#type: "supply\x1b[2J\u{200b}chain".to_string(), action: "warn".to_string(), severity: "high".to_string(), category: "test".to_string(), }, FirewallAlert { r#type: "other".to_string(), action: "warn".to_string(), severity: "custom\nseverity".to_string(), category: "test".to_string(), }, ], }]; let mut output = Vec::new(); print_firewall_report_to(&mut output, &responses); let output = String::from_utf8(output).unwrap(); assert!(output.contains(r"pkg\nname")); assert!(output.contains(r"1\u{202e}.0")); assert!(output.contains(r"supply\u{1b}[2J\u{200b}chain")); assert!(output.contains(r"custom\nseverity")); assert!(!output.contains("pkg\nname")); assert!(!output.contains('\u{202e}')); assert!(!output.contains("supply\x1b[2J")); } } } #[cfg(test)] mod tests { use deno_core::serde_json; use super::npm::BulkAuditResponse; use super::print_fix_summary; #[test] fn print_fix_summary_escapes_external_text() { let fixed = vec!["pkg\nname ^1 -> ^2".to_string()]; let unfixable = vec!["pkg\u{200b}name (transitive dependency)".to_string()]; let mut output = Vec::new(); print_fix_summary(&mut output, &fixed, &unfixable); let output = String::from_utf8(output).unwrap(); assert!(output.contains(r"pkg\nname ^1 -> ^2")); assert!(output.contains(r"pkg\u{200b}name (transitive dependency)")); assert!(!output.contains("pkg\nname")); assert!(!output.contains('\u{200b}')); } #[test] fn test_bulk_audit_response_deserialize_empty() { let json = r#"{}"#; let response: BulkAuditResponse = serde_json::from_str(json).unwrap(); assert!(response.is_empty()); } #[test] fn test_bulk_audit_response_deserialize_with_advisory() { let json = r#"{ "@denotest/with-vuln1": [{ "url": "https://example.com/vuln/101010", "title": "test vulnerability", "severity": "high", "vulnerable_versions": "<1.1.0" }] }"#; let response: BulkAuditResponse = serde_json::from_str(json).unwrap(); assert_eq!(response.len(), 1); let advisories = &response["@denotest/with-vuln1"]; assert_eq!(advisories.len(), 1); assert_eq!(advisories[0].severity, "high"); assert!(advisories[0].patched_versions.is_none()); assert!(advisories[0].cves.is_empty()); } #[test] fn test_bulk_audit_response_deserialize_with_optional_fields() { let json = r#"{ "test-pkg": [{ "url": "https://example.com", "title": "test", "severity": "critical", "vulnerable_versions": "<2.0.0", "patched_versions": ">=2.0.0", "cves": ["CVE-2025-0001"], "cwe": ["CWE-1333"] }] }"#; let response: BulkAuditResponse = serde_json::from_str(json).unwrap(); let advisories = &response["test-pkg"]; assert_eq!(advisories[0].patched_versions.as_deref(), Some(">=2.0.0")); assert_eq!(advisories[0].cves, vec!["CVE-2025-0001"]); assert_eq!(advisories[0].cwe, vec!["CWE-1333"]); } }