Skip to content

audit: fail closed on malformed registry responses and escape control characters in the report - #38557

Open
robobun wants to merge 1 commit into
mainfrom
farm/60d2a81d/audit-fail-closed-escape-output
Open

audit: fail closed on malformed registry responses and escape control characters in the report#38557
robobun wants to merge 1 commit into
mainfrom
farm/60d2a81d/audit-fail-closed-escape-output

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • bun audit treats a 200 response that is not an advisory report as a clean audit. A body of [], null, "..." or a number is written to stdout and the command exits 0; an object whose values are not arrays (for example {"error":"audit is not supported"} from a mirror) prints nothing and exits 0. bun audit --json exits 1 for the same bodies, so the two modes disagree, and a misconfigured mirror or proxy reads as "no vulnerabilities" in CI.
  • A body that is not JSON at all (captive portal, proxy error page) is written to stdout verbatim in both modes (print_enhanced_audit_report parse-failure branch, and --json echoed before parsing). bun audit outputs non readable text #35887 is this path in the wild: a registry served a gzip body without Content-Encoding and bun audit printed the compressed bytes. With this change that case prints the response is not JSON error instead; making that audit actually succeed is audit: decompress gzip responses served without Content-Encoding #35890, so bun audit outputs non readable text #35887 stays open after this PR (it is left to audit: decompress gzip responses served without Content-Encoding #35890).
  • Package names, title, url and vulnerable_versions from the response are printed byte for byte (src/runtime/cli/audit_command.rs, the BStr::new(..) arguments in print_enhanced_audit_report), so an advisory can emit terminal escape sequences (clear screen, set title, OSC 8 links) or use CR/LF to overwrite or forge report lines.
  • Cause of the exit codes: print_enhanced_audit_report only counted advisories it could find and returned 0 otherwise, including for the else branch that echoed a non-object root (audit_command.rs ~745-750 and ~975-980 before this change).

Fix

  • audit() parses the body once and runs it through the new bulk_advisories(), which accepts exactly { [package]: Advisory[] } (an object whose values are arrays of objects) and flattens it into (package, advisory) pairs. Anything else, and any body that is not JSON, goes to reject_response(): one error line on stderr naming the audit URL (error: audit request to <url> failed: response is not JSON / ... is not a list of advisories) and exit 1, in both modes. The body is never echoed on the non-JSON path; --json still relays a body that is JSON, as documented, and only its exit code changes.
  • print_enhanced_audit_report now takes the validated pairs, prints No vulnerabilities found when nothing is left to report (covers {}, packages listed with empty arrays, and everything filtered out by --audit-level / --ignore, which used to print nothing), and returns 1 otherwise. --json exits on whether the response lists any advisory, so {"ms":[]} exits 0 like the human report does (it exited 1 before). Most of the diff in this function is de-indentation from removing the if let wrappers the validation made redundant (git diff -w).
  • Every string this command prints that did not come from a literal goes through bun_core::fmt::escape_control_chars, a new Display formatter next to quote / escape_powershell: C0 controls, DEL and C1 controls (U+0080..U+009F) are rendered as \n, \r, \t, \^[, \^[ and so on; everything else, backslashes included, passes through unchanged, and invalid UTF-8 is rendered the way BStr renders it. Other commands that print registry metadata (bun pm view, the security scanner output) are not touched here; they can adopt the same formatter.
  • The audit URL is built once in audit() and passed to send_audit_request, so the error message names the URL that was actually requested.
  • Empty 200 bodies still count as clean (they parse as {}), unchanged on purpose: the previous code handled that case explicitly and some registries may answer that way; the new test pins it.
  • Verified with test/cli/install/bun-audit.test.ts (new registry responses block): 20 of the 21 new cases fail on the released bun (USE_SYSTEM_BUN=1) and all 38 tests in the file pass with bun bd test. The one case that passes both ways (an empty body is a clean audit) pins the behavior left unchanged. Also ran cargo clippy -p bun_core -p bun_runtime, cargo fmt --check, and the source-lints byte-search / dead-code tests.
  • Docs: one paragraph added to the exit code section of docs/pm/cli/audit.mdx.
  • Note: install: pnpm parity — dedupe, prune, pm licenses, audit fix, add --filter/--catalog, nested overrides, transitive update, and workspace fixes #38333 rewrites audit_command.rs (multi-registry audit, bun audit fix). This change is independent of it and will need a rebase if it lands first; as of its current head, that branch still exits 0 for {"error":...}-shaped bodies, still echoes a non-JSON body in --json mode, and still prints advisory strings unescaped, so the three pieces here (bulk_advisories, reject_response, escape_control_chars at the print sites) carry over as-is.

Background

  • The bulk advisory endpoint is <registry>/-/npm/v1/security/advisories/bulk. bun audit POSTs { [package]: [versions] } and the registry answers { [package]: Advisory[] }, where each advisory is an object with severity, title, url, vulnerable_versions and id; a package with nothing to report is normally omitted, so {} means clean. The registry is chosen by the project's .npmrc / bunfig.toml, which is why a mirror or proxy can answer with anything.
  • Terminals act on control characters in program output: ESC starts CSI/OSC sequences (clear screen, window title, hyperlinks), BEL terminates OSC, CR and LF move the cursor to overwrite or add lines. C1 controls are the 8-bit equivalents (U+009B is CSI, U+009D is OSC); in UTF-8 they arrive as the two bytes C2 80..C2 9F, and xterm-like terminals honor them, which is why the formatter escapes that range as well as the ASCII range.
  • pretty! / prettyln! rewrite <red>-style tags in the format string at compile time; interpolated arguments are written as-is, so escaping has to happen in the argument, which is what a Display formatter gives us without allocating.
  • bun_json::ParsedJson::parse_json is strict JSON; a raw ESC byte inside a string is a parse error (checked), so a valid document can only carry DEL and C1 controls raw, and everything else reaches the report through \uXXXX escapes that the parser decodes. The formatter handles both forms.
Before / after for the bodies from the report (stub registry answering every POST with the body, project with one npm dependency)

Before (bun 1.4.0-canary.1):

[]                         exit 0, stdout "[]"              (--json: exit 1)
null / 123 / "str"         exit 0, body echoed              (--json: exit 1)
{"error":"rate limited"}   exit 0, stdout ""                (--json: exit 1)
{"ms":{"severity":...}}    exit 0, stdout ""                (--json: exit 1)
<html>ESC[2J...</html>     exit 1, body echoed with ESC bytes, both modes
advisory with ESC/BEL/CR   ESC, BEL, CR, DEL, U+009B written raw to stdout

After:

[] / null / 123 / "str" / {"error":...} / {"ms":{...}} / {"ms":["high"]}
    both modes: stderr "error: audit request to http://127.0.0.1:PORT/-/npm/v1/security/advisories/bulk failed: response is not a list of advisories", exit 1
    (--json still writes the JSON body to stdout)
<html>...</html> / "Not Found"
    both modes: stdout empty, stderr "... response is not JSON", exit 1
advisory with control characters:
    ms  <2.0.0\^[[0m
      (direct dependency)
      high: ms \^[[2J\^[]0;owned\^G ReDoS in \d+\r\n\tparsing\u007f \^[ © 2015 - https://example.com/ms\^[]8;;https://evil.example/\^G
{} / empty body            "No vulnerabilities found", exit 0 (unchanged)
{"ms":[]}                  "No vulnerabilities found", exit 0 (was: stdout "", exit 0; --json was exit 1)

… 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.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1860a184-1276-474a-aba7-fa33367b30fa

📥 Commits

Reviewing files that changed from the base of the PR and between eabb96d and 507c80d.

📒 Files selected for processing (4)
  • docs/pm/cli/audit.mdx
  • src/bun_core/fmt.rs
  • src/runtime/cli/audit_command.rs
  • test/cli/install/bun-audit.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. The diff is green on every CI lane that ran; the build's failed state comes from a lane that never started.

Reproduced on bun 1.4.0-canary.1 with a local stub registry answering the bulk advisory POST: [], null, "...", 123, {"error":...} and {"ms":{...}} all exited 0 from bun audit (and 1 from bun audit --json), an HTML body was written to stdout verbatim in both modes, and ESC / BEL / CR bytes in advisory fields reached stdout unchanged. The new registry responses cases in test/cli/install/bun-audit.test.ts fail on that build (20 of 21) and pass with this branch.

CI (build 96167): 177 of 179 jobs passed, test/cli/install/bun-audit.test.ts included. The other two are the darwin 14 aarch64 - test-bun jobs, which expired four times (16:05 through 20:05 UTC) without ever being picked up: that lane has ~280 jobs queued pipeline-wide on 6 agents, so it is doing the same to every build today and is unrelated to this change. Re-pushing would only re-enter that queue; once it drains, retrying just those two jobs in Buildkite is enough to get the build green.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Two notes from a branch that was about to fix the same escaping in audit_command.rs (farm/f00d9435/audit-escape-control-chars, commit 7b2f6d9, not opened as a PR since this one already covers every print site):

  • Test coverage: the test on that branch (test/cli/install/bun-audit.test.ts, "escapes control characters in advisory text and package names ...") also drives the two dependency path lines (workspace:<ws> › <pkg> and <dependent> › <pkg>, whose names come from the lockfile), the Skipped ... list, and the moderate/low branches, with ESC/CR/LF/NUL/BEL/TAB/DEL/U+009B spread across the fields. This PR changes those sites too but its test only exercises the (direct dependency) shape, so it may be worth folding those cases in.
  • install: escape control characters in the bun pm untrusted/trust script listing #38525 and pm view: escape control characters coming from the registry #38536 each add a bun_core::fmt::escape_control_chars at the same spot in fmt.rs with a different shape from the one here: a generic Display adapter (EscapeControlChars<T: Display>) that renders ESC as \x1b, DEL as \x7f and C1 controls as \u009b, whereas this one scans bytes and renders \u001b / \u007f. Whichever of the three lands first, the other two will conflict in fmt.rs and in their test expectations, so it would be good to settle on one rendering and one signature before merging any of them.

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. bun audit outputs non readable text #35887 - The unreadable terminal output came from the exact fallback this PR deletes (print_enhanced_audit_report writing response_text verbatim when JSON parsing failed); the body now fails validation up front and prints error: audit request to <url> failed: response is not JSON on stderr with exit 1 instead of dumping raw bytes. Note this addresses the symptom, not the root cause — audit: decompress gzip responses served without Content-Encoding #35890 (still open) is what makes the audit actually succeed against a gzip body served without Content-Encoding.

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #35887

🤖 Generated with Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it changes bun audit's exit-code contract (a CI security gate), adds a new public formatter to bun_core::fmt, and overlaps with the #38333 rewrite, a human sign-off would still be worthwhile.

What was reviewed:

  • bulk_advisories shape validation — confirmed JsonValue::as_array (not Expr::as_array) is used, so {"ms":[]} correctly yields Some(vec[]) rather than being rejected.
  • escape_control_chars byte scanning — run/index bookkeeping, the 0xC2 80..9F C1 detection, and truncated-UTF-8 safety via text.get(i+1) all check out; hex_u16::<true> output is ASCII so write_bytes is sound.
  • Removal of the if total > 0 guard is safe: every retained advisory increments a severity bucket, so the summary is only reached with total >= 1.
  • Error path uses redacted_npm_url, so auth material in the registry URL is not leaked to stderr.
Extended reasoning...

Overview

This PR hardens bun audit against malformed registry responses and terminal escape injection. It touches four files: src/runtime/cli/audit_command.rs (refactors audit() to parse once, adds bulk_advisories/reject_response, replaces BStr::new at print sites with escape_control_chars, and de-indents print_enhanced_audit_report after removing now-redundant if let wrappers), src/bun_core/fmt.rs (new escape_control_chars Display formatter for C0/DEL/C1 controls), test/cli/install/bun-audit.test.ts (21 new concurrent subprocess tests covering the response-shape × output-mode matrix), and docs/pm/cli/audit.mdx (one paragraph on the new exit-1 case).

Security risks

The change is net security-positive: it converts a fail-open (mirror returns {"error":...} → exit 0 → CI passes) into fail-closed, and neutralizes terminal escape sequences (CSI/OSC, CR line-forgery, C1 8-bit introducers) in registry-supplied strings. I checked that the new error path routes the URL through redacted_npm_url so tokens embedded in the registry href are not printed. The non-JSON body is never echoed; a JSON-but-wrong-shape body is still echoed under --json (per the documented contract) with exit 1 and an stderr error, which is a defensible choice but one a maintainer may want to confirm.

Level of scrutiny

Medium-high. bun audit is a security gate whose exit code is consumed by CI pipelines, so changing when it exits 0 vs 1 is user-visible and needs to be deliberate. The PR also adds public API surface to bun_core::fmt (a shared crate), and the description flags a known overlap with #38333 (multi-registry / audit fix rewrite) that will require coordination. None of this is a correctness concern — the code and tests look solid — but it exceeds the "simple, mechanical, obvious" bar for auto-approval.

Other factors

Test coverage is thorough and follows the repo's harness conventions (test.concurrent.each, await using for server/proc, Promise.all pipe draining, exact {stdout, stderr, exitCode} object assertions, port: 0, no external network). I verified the one subtle correctness edge: PropertyJSON.value is a JsonValue, whose as_array() returns Some for empty arrays (unlike Expr::as_array), so {"pkg":[]} is a clean audit as tested. The de-indented print_enhanced_audit_report preserves the original logic; the removed if total > 0 guard is provably dead once all_vulnerabilities is non-empty since every advisory increments exactly one bucket. The escape_control_chars design (backslashes pass through unchanged, so output is display-safe but not round-trippable) is appropriate for its stated purpose and is exercised by the \\d+ case in the escape test.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Two notes for whoever signs this off:

  • Not adding Fixes #35887. The garbage in that report is the raw-dump path this PR removes, so that registry now gets the response is not JSON error and exit 1, but the audit itself only succeeds there once the gzip handling in audit: decompress gzip responses served without Content-Encoding #35890 lands. The description links it as related instead.
  • The one judgment call in here: under --json, a body that is valid JSON but not an advisory list is still written to stdout (the documented contract for --json is to relay the registry's JSON), and only the exit code and the stderr error change. A body that is not JSON is not written in either mode. Happy to make --json print nothing on the wrong-shape path too if you would rather have stdout empty whenever the exit is an error.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up on the shared escape_control_chars / EscapeControlChars helper: on #38631 Jarred asked that it not walk the text character by character but use the string helpers instead. #38631 now has that version (2fcf0d2 plus 1e3a599; same name, signature and output, so no call sites or test expectations change). If this PR keeps carrying its own copy of the helper, please swap the writer body for this one so the fmt.rs hunks stay identical across the four branches and whichever lands first still matches the review (#38536's multiline variant and #38557's byte-slice version would want the same scanning approach applied to their own shape rather than a verbatim paste):

struct EscapeControlCharsWriter<'a, 'f>(&'a mut Formatter<'f>);

impl fmt::Write for EscapeControlCharsWriter<'_, '_> {
    fn write_str(&mut self, s: &str) -> fmt::Result {
        let bytes = s.as_bytes();
        let mut start = 0;
        let mut cursor = 0;
        // `\` doubles as the quote char so the scan stops at nothing else extra.
        while let Some(offset) =
            strings::index_of_needs_escape_for_java_script_string(&bytes[cursor..], b'\\')
        {
            let i = cursor + offset as usize;
            let (code_point, len) = match bytes[i] {
                byte @ (0x00..=0x1F | 0x7F) => (byte as u32, 1),
                0xC2 if matches!(bytes.get(i + 1), Some(0x80..=0x9F)) => (bytes[i + 1] as u32, 2),
                byte => {
                    let char_len = strings::wtf8_byte_sequence_length(byte) as usize;
                    cursor = (i + char_len).min(bytes.len());
                    continue;
                }
            };
            self.0.write_str(&s[start..i])?;
            match code_point {
                0x0A => self.0.write_str("\\n")?,
                0x0D => self.0.write_str("\\r")?,
                0x09 => self.0.write_str("\\t")?,
                0x00..=0x7F => write!(self.0, "\\x{:02x}", code_point)?,
                _ => write!(self.0, "\\u{:04x}", code_point)?,
            }
            start = i + len;
            cursor = start;
        }
        self.0.write_str(&s[start..])
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants