From 507c80d33149ff6170488b73dc4d4bc83da261be Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:57:27 +0000 Subject: [PATCH] audit: fail closed on malformed registry responses and escape control characters in the report A 200 response that is not `{ [package]: Advisory[] }` used to exit 0 from `bun audit` (non-object bodies were echoed to stdout, objects of the wrong shape printed nothing at all), while `--json` exited 1 for the same bodies. Both modes now reject such a response with an error naming the audit URL and exit 1, and a body that is not JSON is no longer written to stdout in either mode. Package names, titles, URLs and version ranges from the response were printed byte for byte, so an advisory could emit terminal escape sequences or overwrite lines of the report. They now go through bun_core::fmt::escape_control_chars, which renders C0 controls, DEL and C1 controls as \n / \u001b style escapes. A report whose advisories are all filtered out (or whose packages all have empty advisory lists) prints "No vulnerabilities found" instead of nothing, and `--json` exits 0 for it instead of 1. --- docs/pm/cli/audit.mdx | 2 + src/bun_core/fmt.rs | 51 +++ src/runtime/cli/audit_command.rs | 516 ++++++++++++++--------------- test/cli/install/bun-audit.test.ts | 145 ++++++++ 4 files changed, 443 insertions(+), 271 deletions(-) diff --git a/docs/pm/cli/audit.mdx b/docs/pm/cli/audit.mdx index 0038c4997aa9..328048a0607f 100644 --- a/docs/pm/cli/audit.mdx +++ b/docs/pm/cli/audit.mdx @@ -58,3 +58,5 @@ bun audit --json ### Exit code `bun audit` exits with code `0` if no vulnerabilities are found and `1` if the report lists any, including when `--json` is passed. + +It also exits with code `1`, after printing an error, when the registry's response is not an advisory report (for example, a registry or proxy that answers the audit request with an HTML page or an error object). A response that cannot be read never counts as a clean audit. diff --git a/src/bun_core/fmt.rs b/src/bun_core/fmt.rs index 2dc33cbfe4fc..8f64b6782f61 100644 --- a/src/bun_core/fmt.rs +++ b/src/bun_core/fmt.rs @@ -3325,6 +3325,57 @@ fn escape_powershell_impl(str: &[u8], writer: &mut impl fmt::Write) -> fmt::Resu write_bytes(writer, remain) } +// ─────────────────────────────────────────────────────────────────────────── +// escapeControlChars +// ─────────────────────────────────────────────────────────────────────────── + +pub struct EscapeControlChars<'a>(pub(crate) &'a [u8]); + +/// For text that came over the network (registry metadata, advisory titles) +/// and is about to be printed on a terminal. Control characters are written +/// as `\n` / `\u001b` escapes, so the text can neither emit escape sequences +/// nor overwrite or forge lines of the surrounding report. Escaped: C0 +/// (`0x00..=0x1F`), DEL, and C1 (U+0080..=U+009F, UTF-8 `C2 80..=C2 9F`), +/// which terminals such as xterm also accept as CSI/OSC introducers. +/// Everything else, including invalid UTF-8, is printed as [`bstr::BStr`] +/// prints it. +pub fn escape_control_chars(text: &[u8]) -> EscapeControlChars<'_> { + EscapeControlChars(text) +} + +impl Display for EscapeControlChars<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + let text = self.0; + let mut run_start = 0; + let mut i = 0; + while i < text.len() { + let (code_point, len) = match text[i] { + byte @ (0x00..=0x1F | 0x7F) => (byte as u16, 1), + 0xC2 if matches!(text.get(i + 1), Some(0x80..=0x9F)) => (text[i + 1] as u16, 2), + _ => { + i += 1; + continue; + } + }; + write!(f, "{}", bstr::BStr::new(&text[run_start..i]))?; + match code_point { + 0x08 => f.write_str("\\b")?, + 0x09 => f.write_str("\\t")?, + 0x0A => f.write_str("\\n")?, + 0x0C => f.write_str("\\f")?, + 0x0D => f.write_str("\\r")?, + _ => { + f.write_str("\\u")?; + write_bytes(f, &hex_u16::(code_point))?; + } + } + i += len; + run_start = i; + } + write!(f, "{}", bstr::BStr::new(&text[run_start..])) + } +} + // js_bindings (fmtString for highlighter.test.ts) lives in src/jsc/fmt_jsc.rs // alongside fmt_jsc.bind.ts; bun_core/ stays JSC-free. diff --git a/src/runtime/cli/audit_command.rs b/src/runtime/cli/audit_command.rs index b24816aff445..0e9a9c992d9e 100644 --- a/src/runtime/cli/audit_command.rs +++ b/src/runtime/cli/audit_command.rs @@ -3,6 +3,7 @@ use std::io::Write as _; use bun_ast::{ExprData, e as E}; use bun_collections::{StringArrayHashMap, StringHashMap}; +use bun_core::fmt::escape_control_chars; use bun_core::{Global, Output, pretty, prettyln}; use bun_core::{MutableString, strings}; use bun_http::{self as http, HeaderBuilder}; @@ -101,9 +102,8 @@ impl AuditCommand { Global::exit(code); } - /// Returns the exit code of the command. 0 if no vulnerabilities were found, 1 if vulnerabilities were found. - /// The exception is when you pass --json, it will simply return 0 as that was considered a successful "request - /// for the audit information" + /// Returns the exit code of the command: 0 when the registry reported no vulnerabilities, 1 when + /// it reported some (with `--json` too) or when its response was not an advisory report at all. fn audit( _ctx: Command::Context, pm: &mut PackageManager, @@ -130,59 +130,76 @@ impl AuditCommand { let packages_result = collect_packages_for_audit(pm, audit_prod_only)?; - let response_text = send_audit_request(pm, &packages_result.audit_body)?; + let mut audit_url: Vec = Vec::new(); + write!( + &mut audit_url, + "{}/-/npm/v1/security/advisories/bulk", + BStr::new(strings::without_trailing_slash(pm.options.scope.url.href())) + ) + .expect("unreachable"); + + let response_text = send_audit_request(pm, &audit_url, &packages_result.audit_body)?; + + let source = bun_ast::Source::init_path_string(b"audit-response.json", &response_text[..]); + let mut log = bun_ast::Log::init(); + let Ok(parsed) = bun_json::ParsedJson::parse_json(&source, &mut log) else { + return Ok(reject_response(&audit_url, "JSON")); + }; if json_output { let _ = Output::writer().write_all(&response_text); let _ = Output::writer().write_all(b"\n"); + } - if !response_text.is_empty() { - let source = - bun_ast::Source::init_path_string(b"audit-response.json", &response_text[..]); - let mut log = bun_ast::Log::init(); - - let parsed = match bun_json::ParsedJson::parse_json(&source, &mut log) { - Ok(e) => e, - Err(_) => { - bun_core::pretty_errorln!( - "error: audit request failed to parse json. Is the registry down?" - ); - return Ok(1); // If we can't parse then safe to assume a similar failure - } - }; - - // If the response is an empty object, no vulnerabilities - if let ExprData::EObjectJSON(obj) = &parsed.root.data { - if obj.get().properties().is_empty() { - return Ok(0); - } - } + let Some(advisories) = bulk_advisories(&parsed.root) else { + return Ok(reject_response(&audit_url, "a list of advisories")); + }; - // If there's any content in the response, there are vulnerabilities - return Ok(1); - } + if json_output { + return Ok(u32::from(!advisories.is_empty())); + } - return Ok(0); - } else if !response_text.is_empty() { - let exit_code = print_enhanced_audit_report( - &response_text, - pm, - &dependency_tree, - audit_level, - ignore_list, - )?; + let exit_code = print_enhanced_audit_report( + &advisories, + pm, + &dependency_tree, + audit_level, + ignore_list, + )?; - print_skipped_packages(&packages_result.skipped_packages); + print_skipped_packages(&packages_result.skipped_packages); - return Ok(exit_code); - } else { - prettyln!("No vulnerabilities found"); + Ok(exit_code) + } +} - print_skipped_packages(&packages_result.skipped_packages); +/// The bulk advisory endpoint answers `{ [package name]: Advisory[] }`; this flattens that into +/// `(package name, advisory)` pairs. `None` for a document of any other shape (an HTML error page, +/// a mirror's `{"error": ...}`, ...), which must not pass for a clean audit. +fn bulk_advisories(root: &bun_ast::Expr) -> Option> { + let ExprData::EObjectJSON(report) = &root.data else { + return None; + }; - return Ok(0); + let mut advisories = Vec::new(); + for package in report.get().properties() { + for advisory in package.value.as_array()?.items() { + advisories.push((package.key.slice(), advisory.as_object()?)); } } + + Some(advisories) +} + +/// Exit code for a response that is not an advisory report. Only the URL is named: the body is +/// whatever a proxy, captive portal or audit-less mirror sent, and never goes to the terminal raw. +fn reject_response(audit_url: &[u8], expected: &str) -> u32 { + bun_core::pretty_errorln!( + "error: audit request to {} failed: response is not {}", + bun_core::fmt::redacted_npm_url(audit_url), + expected + ); + 1 } fn print_skipped_packages(skipped_packages: &[Box<[u8]>]) { @@ -192,7 +209,7 @@ fn print_skipped_packages(skipped_packages: &[Box<[u8]>]) { if i > 0 { pretty!(", "); } - pretty!("{}", BStr::new(package_name)); + pretty!("{}", escape_control_chars(package_name)); } if skipped_packages.len() > 1 { @@ -426,6 +443,7 @@ fn collect_packages_for_audit( fn send_audit_request( pm: &mut PackageManager, + audit_url: &[u8], body: &[u8], ) -> Result, bun_alloc::AllocError> { libdeflate::load(); @@ -464,14 +482,7 @@ fn send_audit_request( ); } - let mut url_str: Vec = Vec::new(); - write!( - &mut url_str, - "{}/-/npm/v1/security/advisories/bulk", - BStr::new(strings::without_trailing_slash(pm.options.scope.url.href())) - ) - .expect("unreachable"); - let url = URL::parse(&url_str); + let url = URL::parse(audit_url); let http_proxy = pm.http_proxy(&url); @@ -723,259 +734,222 @@ struct VulnCounts { } fn print_enhanced_audit_report( - response_text: &[u8], + advisories: &[(&[u8], &E::ObjectJSON)], pm: &mut PackageManager, dependency_tree: &StringHashMap>>, audit_level: Option, ignore_list: &[&[u8]], ) -> Result { - let source = bun_ast::Source::init_path_string(b"audit-response.json", response_text); - let mut log = bun_ast::Log::init(); - - let parsed = match bun_json::ParsedJson::parse_json(&source, &mut log) { - Ok(e) => e, - Err(_) => { - let _ = Output::writer().write_all(response_text); - let _ = Output::writer().write_all(b"\n"); - return Ok(1); - } - }; - let expr = parsed.root; - - if let ExprData::EObjectJSON(obj) = &expr.data { - if obj.get().properties().is_empty() { - prettyln!("No vulnerabilities found"); - return Ok(0); - } - } - let mut audit_result = AuditResult::init(); let mut vuln_counts = VulnCounts::default(); - if let ExprData::EObjectJSON(obj) = &expr.data { - for prop in obj.get().properties() { - let package_name: &[u8] = prop.key.slice(); + for &(package_name, advisory) in advisories { + let vulnerability = parse_vulnerability(package_name, advisory)?; - if let Some(arr) = prop.value.as_array() { - for vuln in arr.items() { - if let Some(vuln_obj) = vuln.as_object() { - let vulnerability = parse_vulnerability(package_name, vuln_obj)?; - - if let Some(level) = audit_level { - if !level.should_include_severity(&vulnerability.severity) { - continue; - } - } - - if !ignore_list.is_empty() { - let mut should_ignore = false; - for ignored_cve in ignore_list { - if strings::eql(&vulnerability.id, ignored_cve) - || strings::index_of(&vulnerability.url, ignored_cve).is_some() - { - should_ignore = true; - break; - } - } - if should_ignore { - continue; - } - } - - if vulnerability.severity.as_ref() == b"low" { - vuln_counts.low += 1; - } else if vulnerability.severity.as_ref() == b"moderate" { - vuln_counts.moderate += 1; - } else if vulnerability.severity.as_ref() == b"high" { - vuln_counts.high += 1; - } else if vulnerability.severity.as_ref() == b"critical" { - vuln_counts.critical += 1; - } else { - vuln_counts.moderate += 1; - } + if let Some(level) = audit_level { + if !level.should_include_severity(&vulnerability.severity) { + continue; + } + } - audit_result.all_vulnerabilities.push(vulnerability); - } + if !ignore_list.is_empty() { + let mut should_ignore = false; + for ignored_cve in ignore_list { + if strings::eql(&vulnerability.id, ignored_cve) + || strings::index_of(&vulnerability.url, ignored_cve).is_some() + { + should_ignore = true; + break; } } + if should_ignore { + continue; + } + } + + if vulnerability.severity.as_ref() == b"low" { + vuln_counts.low += 1; + } else if vulnerability.severity.as_ref() == b"moderate" { + vuln_counts.moderate += 1; + } else if vulnerability.severity.as_ref() == b"high" { + vuln_counts.high += 1; + } else if vulnerability.severity.as_ref() == b"critical" { + vuln_counts.critical += 1; + } else { + vuln_counts.moderate += 1; } - for vulnerability in &audit_result.all_vulnerabilities { - let paths = find_dependency_paths(&vulnerability.package_name, dependency_tree, pm)?; + audit_result.all_vulnerabilities.push(vulnerability); + } + + if audit_result.all_vulnerabilities.is_empty() { + prettyln!("No vulnerabilities found"); + return Ok(0); + } - let result = audit_result - .vulnerable_packages - .get_or_put(&vulnerability.package_name)?; - if !result.found_existing { - *result.value_ptr = PackageInfo { - vulnerabilities: Vec::new(), - dependents: paths, - }; + for vulnerability in &audit_result.all_vulnerabilities { + let paths = find_dependency_paths(&vulnerability.package_name, dependency_tree, pm)?; + + let result = audit_result + .vulnerable_packages + .get_or_put(&vulnerability.package_name)?; + if !result.found_existing { + *result.value_ptr = PackageInfo { + vulnerabilities: Vec::new(), + dependents: paths, + }; + } + result.value_ptr.vulnerabilities.push(VulnerabilityInfo { + severity: vulnerability.severity.clone(), + title: vulnerability.title.clone(), + url: vulnerability.url.clone(), + vulnerable_versions: vulnerability.vulnerable_versions.clone(), + id: vulnerability.id.clone(), + package_name: vulnerability.package_name.clone(), + }); + } + + for (_, package_info) in audit_result.vulnerable_packages.iter() { + if !package_info.vulnerabilities.is_empty() { + let main_vuln = &package_info.vulnerabilities[0]; + + // const is_direct_dependency: bool = brk: { + // for (package_info.dependents.items) |path| { + // if (path.is_direct) { + // break :brk true; + // } + // } + // + // break :brk false; + // }; + + if !main_vuln.vulnerable_versions.is_empty() { + prettyln!( + "{} {}", + escape_control_chars(&main_vuln.package_name), + escape_control_chars(&main_vuln.vulnerable_versions) + ); + } else { + prettyln!("{}", escape_control_chars(&main_vuln.package_name)); } - result.value_ptr.vulnerabilities.push(VulnerabilityInfo { - severity: vulnerability.severity.clone(), - title: vulnerability.title.clone(), - url: vulnerability.url.clone(), - vulnerable_versions: vulnerability.vulnerable_versions.clone(), - id: vulnerability.id.clone(), - package_name: vulnerability.package_name.clone(), - }); - } - for (_, package_info) in audit_result.vulnerable_packages.iter() { - if !package_info.vulnerabilities.is_empty() { - let main_vuln = &package_info.vulnerabilities[0]; - - // const is_direct_dependency: bool = brk: { - // for (package_info.dependents.items) |path| { - // if (path.is_direct) { - // break :brk true; - // } - // } - // - // break :brk false; - // }; - - if !main_vuln.vulnerable_versions.is_empty() { - prettyln!( - "{} {}", - BStr::new(&main_vuln.package_name), - BStr::new(&main_vuln.vulnerable_versions) - ); - } else { - prettyln!("{}", BStr::new(&main_vuln.package_name)); - } + for path in &package_info.dependents { + if path.path.len() > 1 { + if path.path[0].starts_with(b"workspace:") { + let vulnerable_pkg = &path.path[path.path.len() - 1]; + let workspace_part = &path.path[0]; - for path in &package_info.dependents { - if path.path.len() > 1 { - if path.path[0].starts_with(b"workspace:") { - let vulnerable_pkg = &path.path[path.path.len() - 1]; - let workspace_part = &path.path[0]; - - prettyln!( - " {} › {}", - BStr::new(workspace_part), - BStr::new(vulnerable_pkg) - ); - } else { - let vulnerable_pkg = &path.path[0]; - - let mut reversed_items: Vec<&[u8]> = Vec::new(); - for item in &path.path[1..] { - reversed_items.push(item); - } - reversed_items.reverse(); - - let mut vuln_pkg_path: Vec = Vec::new(); - for (i, item) in reversed_items.iter().enumerate() { - if i > 0 { - vuln_pkg_path.extend_from_slice(" › ".as_bytes()); - } - vuln_pkg_path.extend_from_slice(item); - } + prettyln!( + " {} › {}", + escape_control_chars(workspace_part), + escape_control_chars(vulnerable_pkg) + ); + } else { + let vulnerable_pkg = &path.path[0]; - prettyln!( - " {} › {}", - BStr::new(&vuln_pkg_path), - BStr::new(vulnerable_pkg) - ); + let mut reversed_items: Vec<&[u8]> = Vec::new(); + for item in &path.path[1..] { + reversed_items.push(item); } - } else { - prettyln!(" (direct dependency)"); - } - } + reversed_items.reverse(); - for vuln in &package_info.vulnerabilities { - if !vuln.title.is_empty() { - if vuln.severity.as_ref() == b"critical" { - prettyln!( - " critical: {} - {}", - BStr::new(&vuln.title), - BStr::new(&vuln.url) - ); - } else if vuln.severity.as_ref() == b"high" { - prettyln!( - " high: {} - {}", - BStr::new(&vuln.title), - BStr::new(&vuln.url) - ); - } else if vuln.severity.as_ref() == b"moderate" { - prettyln!( - " moderate: {} - {}", - BStr::new(&vuln.title), - BStr::new(&vuln.url) - ); - } else { - prettyln!( - " low: {} - {}", - BStr::new(&vuln.title), - BStr::new(&vuln.url) - ); + let mut vuln_pkg_path: Vec = Vec::new(); + for (i, item) in reversed_items.iter().enumerate() { + if i > 0 { + vuln_pkg_path.extend_from_slice(" › ".as_bytes()); + } + vuln_pkg_path.extend_from_slice(item); } + + prettyln!( + " {} › {}", + escape_control_chars(&vuln_pkg_path), + escape_control_chars(vulnerable_pkg) + ); } + } else { + prettyln!(" (direct dependency)"); } - - // if (is_direct_dependency) { - // Output.prettyln(" To fix: `bun update {s}`", .{package_info.name}); - // } else { - // Output.prettyln(" To fix: `bun update --latest` (may be a breaking change)", .{}); - // } - - prettyln!(""); } - } - let total = - vuln_counts.low + vuln_counts.moderate + vuln_counts.high + vuln_counts.critical; - if total > 0 { - pretty!("{} vulnerabilities (", 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!(", "); - } - pretty!("{} high", vuln_counts.high); - has_previous = true; - } - if vuln_counts.moderate > 0 { - if has_previous { - pretty!(", "); - } - pretty!("{} moderate", vuln_counts.moderate); - has_previous = true; - } - if vuln_counts.low > 0 { - if has_previous { - pretty!(", "); + for vuln in &package_info.vulnerabilities { + if !vuln.title.is_empty() { + if vuln.severity.as_ref() == b"critical" { + prettyln!( + " critical: {} - {}", + escape_control_chars(&vuln.title), + escape_control_chars(&vuln.url) + ); + } else if vuln.severity.as_ref() == b"high" { + prettyln!( + " high: {} - {}", + escape_control_chars(&vuln.title), + escape_control_chars(&vuln.url) + ); + } else if vuln.severity.as_ref() == b"moderate" { + prettyln!( + " moderate: {} - {}", + escape_control_chars(&vuln.title), + escape_control_chars(&vuln.url) + ); + } else { + prettyln!( + " low: {} - {}", + escape_control_chars(&vuln.title), + escape_control_chars(&vuln.url) + ); + } } - pretty!("{} low", vuln_counts.low); } - prettyln!(")"); - prettyln!(""); - prettyln!("To update all dependencies to the latest compatible versions:"); - prettyln!(" bun update"); - prettyln!(""); - prettyln!( - "To update all dependencies to the latest versions (including breaking changes):" - ); - prettyln!(" bun update --latest"); + // if (is_direct_dependency) { + // Output.prettyln(" To fix: `bun update {s}`", .{package_info.name}); + // } else { + // Output.prettyln(" To fix: `bun update --latest` (may be a breaking change)", .{}); + // } + prettyln!(""); } + } + + let total = vuln_counts.low + vuln_counts.moderate + vuln_counts.high + vuln_counts.critical; + pretty!("{} vulnerabilities (", total); - if total > 0 { - return Ok(1); + 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!(", "); + } + pretty!("{} high", vuln_counts.high); + has_previous = true; + } + if vuln_counts.moderate > 0 { + if has_previous { + pretty!(", "); } - } else { - let _ = Output::writer().write_all(response_text); - let _ = Output::writer().write_all(b"\n"); + pretty!("{} moderate", vuln_counts.moderate); + has_previous = true; } + if vuln_counts.low > 0 { + if has_previous { + pretty!(", "); + } + pretty!("{} low", vuln_counts.low); + } + prettyln!(")"); + + prettyln!(""); + prettyln!("To update all dependencies to the latest compatible versions:"); + prettyln!(" bun update"); + prettyln!(""); + prettyln!("To update all dependencies to the latest versions (including breaking changes):"); + prettyln!(" bun update --latest"); + prettyln!(""); - Ok(0) + Ok(1) } diff --git a/test/cli/install/bun-audit.test.ts b/test/cli/install/bun-audit.test.ts index e9a49a72911e..33e249906f9f 100644 --- a/test/cli/install/bun-audit.test.ts +++ b/test/cli/install/bun-audit.test.ts @@ -398,4 +398,149 @@ describe("`bun audit`", () => { expect(stdout).toBe("No vulnerabilities found\n"); expect(exitCode).toBe(0); }); + + describe("registry responses", () => { + const projectDependingOnMs: DirectoryTree = { + "package.json": JSON.stringify({ + name: "test", + version: "1.0.0", + dependencies: { + ms: "0.7.0", + }, + }), + "bun.lock": JSON.stringify({ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "test", + "dependencies": { + ms: "0.7.0", + }, + }, + }, + "packages": { + ms: ["ms@0.7.0", "", {}, fakeIntegrity], + }, + }), + }; + + async function auditAgainst(responseBody: string, args: string[] = []) { + await using registry = Bun.serve({ + port: 0, + fetch: () => new Response(responseBody, { headers: { "content-type": "application/json" } }), + }); + using dir = tempDir("bun-test-audit-registry-response", projectDependingOnMs); + + await using proc = spawn({ + cmd: [bunExe(), "audit", ...args], + cwd: String(dir), + env: { ...bunEnv, NPM_CONFIG_REGISTRY: registry.url.href }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const [banner, ...rest] = stderr.split("\n"); + expect(banner).toStartWith("bun audit v"); + + const auditUrl = `${registry.url.origin}/-/npm/v1/security/advisories/bulk`; + return { + result: { stdout, stderr: rest.join("\n"), exitCode }, + rejected: (expected: "JSON" | "a list of advisories") => + `error: audit request to ${auditUrl} failed: response is not ${expected}\n`, + }; + } + + const notAdvisoryLists = [ + "[]", + "null", + '"all clear"', + '{"error":"audit is not supported by this registry"}', + '{"ms":{"severity":"high","title":"advisory object where the array should be"}}', + '{"ms":["high"]}', + ]; + + test.concurrent.each(notAdvisoryLists)("%s is a failed audit, not a clean one", async responseBody => { + const { result, rejected } = await auditAgainst(responseBody); + expect(result).toEqual({ stdout: "", stderr: rejected("a list of advisories"), exitCode: 1 }); + }); + + test.concurrent.each(notAdvisoryLists)("--json relays %s and exits 1", async responseBody => { + const { result, rejected } = await auditAgainst(responseBody, ["--json"]); + expect(result).toEqual({ stdout: `${responseBody}\n`, stderr: rejected("a list of advisories"), exitCode: 1 }); + }); + + const notJson = ["\x1b[2J\x1b]0;owned\x07sign in to continue", "Not Found"]; + + test.concurrent.each(notJson)("a non-JSON body (%j) is neither echoed nor a clean audit", async responseBody => { + const { result, rejected } = await auditAgainst(responseBody); + expect(result).toEqual({ stdout: "", stderr: rejected("JSON"), exitCode: 1 }); + }); + + test.concurrent.each(notJson)("--json does not echo a non-JSON body (%j) either", async responseBody => { + const { result, rejected } = await auditAgainst(responseBody, ["--json"]); + expect(result).toEqual({ stdout: "", stderr: rejected("JSON"), exitCode: 1 }); + }); + + test.concurrent("a package listed with no advisories is a clean audit", async () => { + const { result } = await auditAgainst('{"ms":[]}'); + expect(result).toEqual({ stdout: "No vulnerabilities found\n", stderr: "", exitCode: 0 }); + }); + + test.concurrent("--json exits 0 when the packages are listed with no advisories", async () => { + const { result } = await auditAgainst('{"ms":[]}', ["--json"]); + expect(result).toEqual({ stdout: '{"ms":[]}\n', stderr: "", exitCode: 0 }); + }); + + test.concurrent("an empty body is a clean audit", async () => { + const { result } = await auditAgainst(""); + expect(result).toEqual({ stdout: "No vulnerabilities found\n", stderr: "", exitCode: 0 }); + }); + + test.concurrent("--audit-level filtering out every advisory prints a clean audit, not nothing", async () => { + const { result } = await auditAgainst( + JSON.stringify({ ms: [{ severity: "low", title: "minor", url: "https://example.com/minor" }] }), + ["--audit-level", "critical"], + ); + expect(result).toEqual({ stdout: "No vulnerabilities found\n", stderr: "", exitCode: 0 }); + }); + + test.concurrent("control characters in advisories and package names are escaped in the report", async () => { + const { result } = await auditAgainst( + JSON.stringify({ + ms: [ + { + severity: "high", + title: "ms \x1b[2J\x1b]0;owned\x07 ReDoS in \\d+\r\n\tparsing\x7f \x9b \u00a9 2015", + url: "https://example.com/ms\x1b]8;;https://evil.example/\x07", + vulnerable_versions: "<2.0.0\x1b[0m", + }, + ], + "ms\x1b[31m": [{ severity: "critical", title: "not a real package", url: "https://example.com/fake" }], + }), + ); + expect(result).toEqual({ + stdout: [ + "ms <2.0.0\\u001b[0m", + " (direct dependency)", + " high: ms \\u001b[2J\\u001b]0;owned\\u0007 ReDoS in \\d+\\r\\n\\tparsing\\u007f \\u009b \u00a9 2015 - https://example.com/ms\\u001b]8;;https://evil.example/\\u0007", + "", + "ms\\u001b[31m", + " critical: not a real package - https://example.com/fake", + "", + "2 vulnerabilities (1 critical, 1 high)", + "", + "To update all dependencies to the latest compatible versions:", + " bun update", + "", + "To update all dependencies to the latest versions (including breaking changes):", + " bun update --latest", + "", + "", + ].join("\n"), + stderr: "", + exitCode: 1, + }); + }); + }); });