diff --git a/docs/pm/cli/audit.mdx b/docs/pm/cli/audit.mdx index e6da948d760d..4a401e64e0b5 100644 --- a/docs/pm/cli/audit.mdx +++ b/docs/pm/cli/audit.mdx @@ -36,6 +36,8 @@ Otherwise, Bun lists each affected package with its severity, a short descriptio bun audit --audit-level=high ``` +Some registries also send the severity `info`. It is below every level. An advisory with any other severity (for example `HIGH`), or with none, has no rank: Bun reports it as `unknown severity` at every `--audit-level`. To accept such an advisory, pass `--ignore `. + **`--prod`** (`-p`, `-P`, `--production`) - Only audit packages reachable through `dependencies`, `optionalDependencies`, or `peerDependencies`: ```bash terminal icon="terminal" diff --git a/src/install/PackageManager/CommandLineArguments.rs b/src/install/PackageManager/CommandLineArguments.rs index aac18351dfa9..fd1a059185e2 100644 --- a/src/install/PackageManager/CommandLineArguments.rs +++ b/src/install/PackageManager/CommandLineArguments.rs @@ -902,7 +902,7 @@ pub struct CommandLineArguments { } #[repr(u8)] -#[derive(Copy, Clone, Eq, PartialEq)] +#[derive(Copy, Clone, Eq, PartialEq, PartialOrd, Ord)] pub enum AuditLevel { Low, Moderate, @@ -920,13 +920,17 @@ bun_core::comptime_string_map! { } impl AuditLevel { - pub(crate) fn from_string(str: &[u8]) -> Option { + pub fn from_string(str: &[u8]) -> Option { AUDIT_LEVEL_MAP.get(str).copied() } - pub fn should_include_severity(self, severity: &[u8]) -> bool { - let severity_level = AuditLevel::from_string(severity).unwrap_or(AuditLevel::Moderate); - (severity_level as u8) >= (self as u8) + pub fn name(self) -> &'static str { + match self { + AuditLevel::Low => "low", + AuditLevel::Moderate => "moderate", + AuditLevel::High => "high", + AuditLevel::Critical => "critical", + } } } diff --git a/src/runtime/cli/audit_command.rs b/src/runtime/cli/audit_command.rs index 0e21504bc1ee..5d528603b916 100644 --- a/src/runtime/cli/audit_command.rs +++ b/src/runtime/cli/audit_command.rs @@ -24,8 +24,16 @@ use crate::cli::Command; use crate::cli::install_command::InstallCommand; use crate::cli::package_manager_command::PackageManagerCommand; +enum Severity { + /// Below every `--audit-level`: npm, pnpm and yarn rank `info` under `low`. + Info, + Ranked(AuditLevel), + /// The registry's word, empty when it sent none. Never below an `--audit-level`. + Unranked(Box<[u8]>), +} + struct VulnerabilityInfo { - severity: Box<[u8]>, + severity: Severity, title: Box<[u8]>, url: Box<[u8]>, vulnerable_versions: Box<[u8]>, @@ -48,15 +56,6 @@ fn plural(n: usize) -> &'static str { if n == 1 { "" } else { "s" } } -fn audit_level_name(level: AuditLevel) -> &'static str { - match level { - AuditLevel::Low => "low", - AuditLevel::Moderate => "moderate", - AuditLevel::High => "high", - AuditLevel::Critical => "critical", - } -} - fn print_no_vulnerabilities(stats: &AuditStats, audit_level: Option) { pretty!( "No vulnerabilities found (checked {} package{}", @@ -68,7 +67,7 @@ fn print_no_vulnerabilities(stats: &AuditStats, audit_level: Option) pretty!( ", {} below --audit-level={}", stats.below_level, - audit_level_name(level) + level.name() ); } } @@ -720,7 +719,7 @@ fn send_audit_request( fn parse_vulnerability(package_name: &[u8], vuln: &E::ObjectJSON) -> VulnerabilityInfo { let mut vulnerability = VulnerabilityInfo { - severity: Box::<[u8]>::from(b"moderate" as &[u8]), + severity: Severity::Unranked(Box::default()), title: Box::<[u8]>::from(b"Vulnerability found" as &[u8]), url: Box::default(), vulnerable_versions: Box::default(), @@ -731,9 +730,18 @@ fn parse_vulnerability(package_name: &[u8], vuln: &E::ObjectJSON) -> Vulnerabili for prop in vuln.properties() { let field_name: &[u8] = prop.key.slice(); match &prop.value { + E::JsonValue::String(val_str) if field_name == b"severity" => { + let word = val_str.slice(); + vulnerability.severity = match AuditLevel::from_string(word) { + Some(level) => Severity::Ranked(level), + None if word == b"info" => Severity::Info, + None => Severity::Unranked(Box::<[u8]>::from(word)), + }; + } E::JsonValue::String(val_str) => { let field = match field_name { - b"severity" => &mut vulnerability.severity, + // Keeps the default title: the report has a line for every counted advisory. + b"title" if val_str.slice().is_empty() => continue, b"title" => &mut vulnerability.title, b"url" => &mut vulnerability.url, b"vulnerable_versions" => &mut vulnerability.vulnerable_versions, @@ -865,7 +873,12 @@ fn keep_vulnerability( stats: &mut AuditStats, ) -> bool { if let Some(level) = audit_level { - if !level.should_include_severity(&vulnerability.severity) { + let below = match &vulnerability.severity { + Severity::Info => true, + Severity::Ranked(rank) => *rank < level, + Severity::Unranked(_) => false, + }; + if below { stats.below_level += 1; return false; } @@ -939,20 +952,26 @@ fn collect_vulnerabilities( Ok(Ok(vulnerabilities)) } -#[derive(Default)] -struct VulnCounts { - low: u32, - moderate: u32, - high: u32, - critical: u32, -} +const UNRANKED_LABEL: &str = "unknown severity"; -fn print_severity(severity: &[u8]) { +fn print_severity(severity: &Severity) { match severity { - b"critical" => pretty!(" critical:"), - b"high" => pretty!(" high:"), - b"low" => pretty!(" low:"), - _ => pretty!(" moderate:"), + Severity::Info => pretty!(" info:"), + Severity::Ranked(level) => match level { + AuditLevel::Critical | AuditLevel::High => { + pretty!(" {}:", level.name()) + } + AuditLevel::Moderate => pretty!(" {}:", level.name()), + AuditLevel::Low => pretty!(" {}:", level.name()), + }, + Severity::Unranked(word) if word.is_empty() => { + pretty!(" {}:", UNRANKED_LABEL) + } + Severity::Unranked(word) => pretty!( + " {} ({}):", + UNRANKED_LABEL, + escape_control_chars(word) + ), } } @@ -1013,15 +1032,6 @@ fn print_enhanced_audit_report( return Ok(0); } - let mut vuln_counts = VulnCounts::default(); - for vulnerability in &vulnerabilities { - match &*vulnerability.severity { - b"low" => vuln_counts.low += 1, - b"high" => vuln_counts.high += 1, - b"critical" => vuln_counts.critical += 1, - _ => vuln_counts.moderate += 1, - } - } let total = vulnerabilities.len() as u32; index_sort::sort_vec_by(&mut vulnerabilities, |a, b| { @@ -1044,9 +1054,6 @@ fn print_enhanced_audit_report( } for vuln in group { - if vuln.title.is_empty() { - continue; - } print_severity(&vuln.severity); pretty!(" {}", escape_control_chars(&vuln.title)); if !vuln.vulnerable_versions.is_empty() { @@ -1061,30 +1068,39 @@ fn print_enhanced_audit_report( pretty!("{} {} (", total, audit_fix::vuln_word(total)); - let mut has_previous = false; - if vuln_counts.critical > 0 { - pretty!("{} critical", vuln_counts.critical); - has_previous = true; - } - if vuln_counts.high > 0 { - if has_previous { - pretty!(", "); + let count = |wanted: &dyn Fn(&Severity) -> bool| { + (vulnerabilities.iter()) + .filter(|vulnerability| wanted(&vulnerability.severity)) + .count() + }; + let mut separator = ""; + for level in [ + AuditLevel::Critical, + AuditLevel::High, + AuditLevel::Moderate, + AuditLevel::Low, + ] { + let count = count(&|severity| matches!(severity, Severity::Ranked(rank) if *rank == level)); + if count == 0 { + continue; } - pretty!("{} high", vuln_counts.high); - has_previous = true; - } - if vuln_counts.moderate > 0 { - if has_previous { - pretty!(", "); + pretty!("{}", separator); + match level { + AuditLevel::Critical => pretty!("{} {}", count, level.name()), + AuditLevel::High => pretty!("{} {}", count, level.name()), + AuditLevel::Moderate => pretty!("{} {}", count, level.name()), + AuditLevel::Low => pretty!("{} {}", count, level.name()), } - pretty!("{} moderate", vuln_counts.moderate); - has_previous = true; + separator = ", "; } - if vuln_counts.low > 0 { - if has_previous { - pretty!(", "); - } - pretty!("{} low", vuln_counts.low); + let info = count(&|severity| matches!(severity, Severity::Info)); + if info > 0 { + pretty!("{}{} info", separator, info); + separator = ", "; + } + let unranked = count(&|severity| matches!(severity, Severity::Unranked(_))); + if unranked > 0 { + pretty!("{}{} {}", separator, unranked, UNRANKED_LABEL); } prettyln!(")"); diff --git a/test/cli/install/bun-audit.test.ts b/test/cli/install/bun-audit.test.ts index 571b0b7ec162..9d3b23493b7e 100644 --- a/test/cli/install/bun-audit.test.ts +++ b/test/cli/install/bun-audit.test.ts @@ -889,6 +889,115 @@ describe("`bun audit`", () => { expect(exitCode).toBe(0); }); + // `severity` is free text from the registry. `info` and the four levels of --audit-level have a rank. + const withLow = (severity?: string) => + JSON.stringify({ + ms: [ + { id: 1, severity, title: "first", url: "https://example.com/1", vulnerable_versions: "<2.0.0" }, + { id: 2, severity: "low", title: "second", url: "https://example.com/2", vulnerable_versions: "<2.0.0" }, + ], + }); + + test.concurrent.each([ + ["HIGH", "unknown severity (HIGH):"], + ["medium", "unknown severity (medium):"], + [undefined, "unknown severity:"], + ])("a severity Bun cannot rank (%p) is never below --audit-level", async (severity, label) => { + const [report, critical, json] = await Promise.all([ + auditAgainst(withLow(severity)), + auditAgainst(withLow(severity), ["--audit-level=critical"]), + auditAgainst(withLow(severity), ["--json", "--audit-level=critical"]), + ]); + + expect(report.stdout).toContain(` ${label} first (<2.0.0) - https://example.com/1\n`); + expect(report.stdout).toContain("2 vulnerabilities (1 low, 1 unknown severity)\n"); + expect(report.exitCode).toBe(1); + + expect(critical.stdout).not.toContain("low: second"); + expect(critical.stdout).toContain(` ${label} first`); + expect(critical.stdout).toContain("1 vulnerability (1 unknown severity)\n"); + expect(critical.exitCode).toBe(1); + + expect(JSON.parse(json.stdout).ms).toHaveLength(2); + expect(json.exitCode).toBe(1); + }); + + test.concurrent("--ignore accepts an advisory that Bun cannot rank", async () => { + const [report, json] = await Promise.all([ + auditAgainst(withLow("HIGH"), ["--audit-level=critical", "--ignore", "1"]), + auditAgainst(withLow("HIGH"), ["--json", "--audit-level=critical", "--ignore", "1"]), + ]); + + expect(normalizeBunSnapshot(report.stdout)).toBe( + AUDIT_HEADER + noVulnerabilities(1, "1 below --audit-level=critical", "1 ignored"), + ); + expect(report.exitCode).toBe(0); + + expect(JSON.parse(json.stdout).ms).toHaveLength(2); + expect(json.exitCode).toBe(0); + }); + + test.concurrent("info is below every --audit-level, and is reported without one", async () => { + const body = JSON.stringify({ ms: [{ severity: "info", title: "minor", url: "https://example.com/minor" }] }); + const [report, low, critical] = await Promise.all([ + auditAgainst(body), + auditAgainst(body, ["--audit-level=low"]), + auditAgainst(body, ["--audit-level=critical"]), + ]); + + expect(report.stdout).toContain(" info: minor - https://example.com/minor\n"); + expect(report.stdout).toContain("1 vulnerability (1 info)\n"); + expect(report.exitCode).toBe(1); + + expect(normalizeBunSnapshot(low.stdout)).toBe(AUDIT_HEADER + noVulnerabilities(1, "1 below --audit-level=low")); + expect(low.exitCode).toBe(0); + + expect(normalizeBunSnapshot(critical.stdout)).toBe( + AUDIT_HEADER + noVulnerabilities(1, "1 below --audit-level=critical"), + ); + expect(critical.exitCode).toBe(0); + }); + + test.concurrent("each --audit-level keeps its own severity and the ones above it", async () => { + const severities = ["info", "low", "moderate", "high", "critical"]; + const body = JSON.stringify({ + ms: severities.map(severity => ({ severity, title: severity, url: `https://example.com/${severity}` })), + }); + const reports = await Promise.all([ + auditAgainst(body), + ...severities.slice(1).map(level => auditAgainst(body, [`--audit-level=${level}`])), + ]); + + expect(reports.map(report => report.stdout.match(/^\d+ vulnerabilit.*$/m)?.[0])).toEqual([ + "5 vulnerabilities (1 critical, 1 high, 1 moderate, 1 low, 1 info)", + "4 vulnerabilities (1 critical, 1 high, 1 moderate, 1 low)", + "3 vulnerabilities (1 critical, 1 high, 1 moderate)", + "2 vulnerabilities (1 critical, 1 high)", + "1 vulnerability (1 critical)", + ]); + expect(reports.map(report => report.exitCode)).toEqual([1, 1, 1, 1, 1]); + }); + + test.concurrent("an advisory with an empty title has a line in the report", async () => { + const { stdout, stderr, exitCode } = await auditAgainst( + JSON.stringify({ ms: [{ severity: "high", title: "", url: "https://example.com/untitled" }] }), + ); + expect(withoutHeader(stdout)).toBe( + [ + "ms@0.7.0", + " (direct dependency)", + " high: Vulnerability found - https://example.com/untitled", + "", + "1 vulnerability (1 high)", + "", + REPORT_FOOTER, + "", + ].join("\n"), + ); + expect(stderr).toBe(""); + expect(exitCode).toBe(1); + }); + test.concurrent("control characters in advisories and package names are escaped in the report", async () => { const { stdout, stderr, exitCode } = await auditAgainst( JSON.stringify({ @@ -899,6 +1008,11 @@ describe("`bun audit`", () => { url: "https://example.com/ms\x1b]8;;https://evil.example/\x07", vulnerable_versions: "<2.0.0\x1b[0m", }, + { + severity: "high\x1b[2J\x07\r\n\x9b\u202e", + title: "a severity Bun cannot rank", + url: "https://example.com/sev", + }, ], "ms\x1b[31m": [{ severity: "critical", title: "not a real package", url: "https://example.com/fake" }], }), @@ -909,11 +1023,12 @@ describe("`bun audit`", () => { "ms@0.7.0", " (direct dependency)", " high: ms \\x1b[2J\\x1b]0;owned\\x07 ReDoS in \\d+\\r\\n\\tparsing\\x7f \\u009b \u00a9 2015 (<2.0.0\\x1b[0m) - https://example.com/ms\\x1b]8;;https://evil.example/\\x07", + " unknown severity (high\\x1b[2J\\x07\\r\\n\\u009b\\u202e): a severity Bun cannot rank - https://example.com/sev", "", "ms\\x1b[31m", " critical: not a real package - https://example.com/fake", "", - "2 vulnerabilities (1 critical, 1 high)", + "3 vulnerabilities (1 critical, 1 high, 1 unknown severity)", "", REPORT_FOOTER, "", @@ -1288,22 +1403,31 @@ describe("`bun audit --help`", () => { }); describe("`bun audit` report", () => { - test.concurrent("an unknown severity is counted and filtered as moderate", async () => { - await using server = startRegistry({ "a-dep": [{ ...adv("<1.0.4"), severity: "info" }] }); - using dir = await setup(server, { name: "foo", dependencies: { "a-dep": "1.0.2" } }); + test.concurrent("audit fix upgrades for an advisory that Bun cannot rank, unless --ignore names it", async () => { + await using server = startRegistry({ + "a-dep": [ + { ...adv("<1.0.4", 1), severity: "HIGH" }, + { ...adv("<1.0.4", 2), severity: "low" }, + ], + }); + using dir = await setupVulnerableADep(server); + const lockBefore = await lock(dir); - const report = await audit(dir); - expect(report.stdout).toContain("1 vulnerability (1 moderate)"); - expect(report.stdout).toContain("moderate: test advisory"); - expect(report.exitCode).toBe(1); + // Neither of these writes to the project. + const [fix, ignored] = await Promise.all([ + auditFix(dir, "--dry-run", "--audit-level=critical"), + auditFix(dir, "--audit-level=critical", "--ignore", "1"), + ]); - const high = await audit(dir, "--audit-level=high"); - expect(normalizeBunSnapshot(high.stdout)).toBe(AUDIT_HEADER + noVulnerabilities(1, "1 below --audit-level=high")); - expect(high.exitCode).toBe(0); + expect(fix.stdout).toContain(" ^ a-dep 1.0.2 -> 1.0.4"); + expect(fix.stdout).toContain("Would fix 1 vulnerability in 1 package"); + expect(fix.exitCode).toBe(0); - const moderate = await audit(dir, "--audit-level=moderate"); - expect(moderate.stdout).toContain("1 vulnerability (1 moderate)"); - expect(moderate.exitCode).toBe(1); + expect(normalizeBunSnapshot(ignored.stdout)).toBe( + FIX_HEADER + noVulnerabilities(1, "1 below --audit-level=critical", "1 ignored"), + ); + expect(ignored.exitCode).toBe(0); + expect(await lock(dir)).toBe(lockBefore); }); test.concurrent("names the installed version, one range per advisory, and ends on the fix commands", async () => {