Files
bun-src/test/cli/install/bun-audit.test.ts
2026-08-27 21:09:14 +00:00

4352 lines
178 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { file, spawn, write } from "bun";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { exists, readlink } from "fs/promises";
import {
DirectoryTree,
VerdaccioRegistry,
bunEnv,
bunExe,
gunzipJsonRequest,
normalizeBunSnapshot,
runBunInstall,
tempDir,
} from "harness";
import { join } from "node:path";
import { resolveBulkAdvisoryFixture } from "./registry/fixtures/audit/audit-fixtures";
function fixture(
folder:
| "express@3"
| "vuln-with-only-dev-dependencies"
| "safe-is-number@7"
| "mix-of-safe-and-vulnerable-dependencies",
) {
return join(import.meta.dirname, "registry", "fixtures", "audit", folder);
}
let server: Bun.Server;
const verdaccio = new VerdaccioRegistry();
beforeAll(async () => {
server = Bun.serve({
port: 0,
fetch: async req => {
const body = await gunzipJsonRequest(req);
const fixture = resolveBulkAdvisoryFixture(body);
if (!fixture) {
console.log("No fixture found for", body);
return new Response("No fixture found", { status: 404 });
}
return Response.json(fixture);
},
});
await verdaccio.start();
});
afterAll(() => {
server?.stop();
verdaccio.stop();
});
function doAuditTest(
label: string,
options: {
args?: string[];
exitCode: number;
files: DirectoryTree | string;
fn: (std: { stdout: PromiseLike<string>; stderr: PromiseLike<string>; dir: string }) => Promise<void>;
},
) {
test(label, async () => {
await using dir = tempDir("bun-test-audit-" + label.replace(/[^a-zA-Z0-9]/g, "-"), options.files);
const cmd = [bunExe(), "audit", ...(options.args ?? [])];
const url = server.url.toString().slice(0, -1);
await using proc = spawn({
cmd,
stdout: "pipe",
stderr: "pipe",
cwd: dir,
env: {
...bunEnv,
NPM_CONFIG_REGISTRY: url,
},
});
const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
try {
await options.fn({ stdout: Promise.resolve(out), stderr: Promise.resolve(err), dir });
expect(exitCode).toBe(options.exitCode);
} catch (e) {
console.log("ERR:", err);
console.log("OUT:", out);
throw e;
}
});
}
type Advisory = { id: number; title: string; severity: string; url: string; vulnerable_versions: string };
function adv(range: string, id = 1): Advisory {
return {
id,
title: "test advisory",
severity: "high",
url: "https://example.invalid/advisory/" + id,
vulnerable_versions: range,
};
}
type RegistryOptions = {
// Serve the bulk response verbatim instead of filtering advisories by the submitted versions.
bulkResponse?: unknown;
// Served verbatim in request order; the last entry answers every later request.
bulkResponses?: unknown[];
bulkStatus?: number;
// Every bulk request after this many answers 503; lets a test break the registry between the two audit fix requests.
bulkFailAfter?: number;
// Raw bulk response body served with status 200 (e.g. an HTML error page from a proxy).
bulkBody?: string;
// Incremented on every bulk request; lets a test prove the registry was never contacted.
bulkHits?: { count: number };
// Every bulk request's body, in request order.
bulkBodies?: Record<string, string[]>[];
// Package names whose manifest requests answer 404; mutable so a test can break the registry after installing.
denyManifests?: Set<string>;
// Tarball file names (`a-dep-1.0.4.tgz`) whose downloads answer 404.
denyTarballs?: Set<string>;
// Publish times overlaid on a package's manifest: { "a-dep": { "1.0.4": iso } }.
rewriteTime?: Record<string, Record<string, string>>;
};
// Answers the bulk-advisory endpoint itself and proxies everything else to verdaccio, pointing manifest tarball URLs back at itself.
function startRegistry(advisories: Record<string, Advisory[]>, options: RegistryOptions = {}) {
let bulkRequests = 0;
return Bun.serve({
port: 0,
async fetch(req, proxy) {
const url = new URL(req.url);
if (req.method === "POST" && url.pathname === "/-/npm/v1/security/advisories/bulk") {
bulkRequests++;
if (options.bulkHits) options.bulkHits.count++;
const body: Record<string, string[]> = await gunzipJsonRequest(req);
options.bulkBodies?.push(body);
if (options.bulkStatus) return new Response("registry exploded", { status: options.bulkStatus });
if (options.bulkFailAfter !== undefined && bulkRequests > options.bulkFailAfter) {
return new Response("registry exploded", { status: 503 });
}
if (options.bulkBody !== undefined) {
return new Response(options.bulkBody, { headers: { "content-type": "text/html" } });
}
if (options.bulkResponses) {
return Response.json(options.bulkResponses[Math.min(bulkRequests - 1, options.bulkResponses.length - 1)]);
}
if (options.bulkResponse !== undefined) return Response.json(options.bulkResponse);
const out: Record<string, Advisory[]> = {};
for (const [name, versions] of Object.entries(body)) {
const matching = (advisories[name] ?? []).filter(a =>
versions.some(v => Bun.semver.satisfies(v, a.vulnerable_versions)),
);
if (matching.length > 0) out[name] = matching;
}
return Response.json(out);
}
const packageName = decodeURIComponent(url.pathname.slice(1));
if (options.denyManifests?.has(packageName)) {
return new Response("not found", { status: 404 });
}
if (options.denyTarballs?.has(url.pathname.slice(url.pathname.lastIndexOf("/") + 1))) {
return new Response("not found", { status: 404 });
}
const up = await fetch(new URL(url.pathname + url.search, verdaccio.registryUrl()), {
method: req.method,
headers: { accept: req.headers.get("accept") ?? "*/*" },
});
const contentType = up.headers.get("content-type") ?? "application/octet-stream";
if (!up.ok || !contentType.includes("json")) {
return new Response(up.body, { status: up.status, headers: { "content-type": contentType } });
}
const manifest = JSON.parse((await up.text()).replaceAll(verdaccio.registryUrl(), proxy.url.href));
const time = options.rewriteTime?.[packageName];
if (time) manifest.time = { ...manifest.time, ...time };
return Response.json(manifest, { status: up.status });
},
});
}
type Registry = ReturnType<typeof startRegistry>;
function registryHref(server: Registry) {
return server.url.href.slice(0, -1);
}
function writeBunfig(
dir: string,
server: Registry | string,
scopes?: Record<string, string>,
install: Record<string, unknown> = {},
) {
return write(
join(dir, "bunfig.toml"),
Bun.TOML.stringify({
install: {
cache: join(dir, ".bun-cache"),
registry: typeof server === "string" ? server : server.url.href,
saveTextLockfile: true,
...(scopes && { scopes }),
...install,
},
}),
);
}
// A URL nothing listens on any more, for connection-refused cases.
async function deadRegistryHref() {
const server = Bun.serve({ port: 0, fetch: () => new Response() });
const href = server.url.href;
await server.stop(true);
return href;
}
// The CI runner exports one BUN_INSTALL_CACHE_DIR per file, which overrides the bunfig cache the concurrent cases rely on.
function installEnv(dir: string) {
return { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(dir, ".bun-cache") };
}
async function setup(
server: Registry,
pkgJson: object | string,
extraFiles: Record<string, string> = {},
scopes?: Record<string, string>,
) {
const text = typeof pkgJson === "string" ? pkgJson : JSON.stringify(pkgJson);
const dir = tempDir("audit-fix-", { "package.json": text, ...extraFiles });
await writeBunfig(dir, server, scopes);
await runBunInstall(installEnv(dir), dir);
return dir;
}
function pkgJson(dir: string, ...segments: string[]) {
return file(join(dir, ...segments, "package.json")).json();
}
function pkgJsonText(dir: string, ...segments: string[]) {
return file(join(dir, ...segments, "package.json")).text();
}
async function reinstall(dir: string, pkgJson: object) {
await write(join(dir, "package.json"), JSON.stringify(pkgJson));
await runBunInstall(installEnv(dir), dir);
}
async function run(dir: string, args: string[], root: string = dir) {
await using proc = Bun.spawn({
cmd: [bunExe(), ...args],
env: installEnv(root),
cwd: dir,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode };
}
const FROZEN_NOTE = "note: run 'bun audit fix' without --frozen-lockfile / --production";
const NO_SAVE_NOTE = "note: run 'bun audit fix' without --no-save";
const MISSING_LOCKFILE = "error: missing lockfile, nothing to audit\nnote: run 'bun install' first";
const AUDIT_HEADER = "bun audit <version> (<revision>)\n\n";
const FIX_HEADER = "bun audit fix <version> (<revision>)\n\n";
const REPORT_FOOTER =
" bun audit fix upgrade the vulnerable packages within their ranges\n" +
" bun audit fix --latest also cross major versions";
// The `[12.00ms]` every summary line ends with; normalizeBunSnapshot strips it.
const DURATION = / \[\d+\.\d\dm?s\]\n$/;
function noVulnerabilities(checked: number, ...filtered: string[]) {
const packages = `${checked} package${checked === 1 ? "" : "s"}`;
return `No vulnerabilities found (checked ${[packages, ...filtered].join(", ")})`;
}
function skippedWarning(registry: string, status: string, ...packages: string[]) {
return `warn: ${registry} did not answer the audit request (${status}); skipped ${packages.join(", ")}`;
}
// `bun audit` after a fix: the header, the no-op line with its counts, and nothing else on either stream.
function expectClean(result: { stdout: string; stderr: string; exitCode: number }, checked: number) {
expect(normalizeBunSnapshot(result.stdout)).toBe(AUDIT_HEADER + noVulnerabilities(checked));
expect(result.stdout).toMatch(DURATION);
expect(result.stderr).toBe("");
expect(result.exitCode).toBe(0);
}
function auditFix(dir: string, ...args: string[]) {
return run(dir, ["audit", "fix", ...args]);
}
function audit(dir: string, ...args: string[]) {
return run(dir, ["audit", ...args]);
}
function lock(dir: string) {
return file(join(dir, "bun.lock")).text();
}
async function installedVersion(dir: string, ...segments: string[]) {
return (await file(join(dir, "node_modules", ...segments, "package.json")).json()).version;
}
// The version `dependent` resolves `name` to under the hoisted layout: its nested copy, else the root one.
async function resolvedVersion(dir: string, dependent: string, name: string) {
if (await exists(join(dir, "node_modules", dependent, "node_modules", name))) {
return installedVersion(dir, dependent, "node_modules", name);
}
return installedVersion(dir, name);
}
async function expectInstall(dir: string, ...args: string[]) {
const { stderr, exitCode } = await run(dir, ["install", ...args]);
expect(stderr).not.toContain("error:");
expect(exitCode).toBe(0);
}
// Written after `setup` so the initial install runs without it; records the `name@version`s it was sent in scanned.json and flags the listed ones as fatal.
async function configureScanner(dir: string, server: Registry, fatal: string[] = []) {
await write(
join(dir, "scanner.ts"),
`import { writeFileSync } from "node:fs";
import { join } from "node:path";
export const scanner = {
version: "1",
scan(payload: { packages: { name: string; version: string }[] }) {
const packages = payload.packages.map(p => p.name + "@" + p.version).sort();
writeFileSync(join(import.meta.dir, "scanned.json"), JSON.stringify(packages));
console.error("SCANNER_RAN");
return packages
.filter(p => ${JSON.stringify(fatal)}.includes(p))
.map(p => ({ package: p.slice(0, p.lastIndexOf("@")), level: "fatal", description: "blocked " + p, url: "https://example.invalid/scanner/" + p }));
},
};`,
);
await writeBunfig(dir, server, undefined, { security: { scanner: "./scanner.ts" } });
}
function scanned(dir: string): Promise<string[]> {
return file(join(dir, "scanned.json")).json();
}
// [email protected] stays installed after the range is widened because the still-satisfied edge is not re-resolved.
async function setupVulnerableADep(server: Registry) {
const dir = await setup(server, { name: "foo", dependencies: { "a-dep": "1.0.2" } });
await reinstall(dir, { name: "foo", dependencies: { "a-dep": "^1.0.2" } });
expect(await lock(dir)).toContain('"[email protected]"');
return dir;
}
describe("`bun audit`", () => {
doAuditTest("should fail with no package.json", {
exitCode: 1,
files: {
"README.md": "This place sure is empty...",
},
fn: async ({ stderr }) => {
expect(await stderr).toContain("No package.json was found for directory");
},
});
doAuditTest("should fail with package.json but no lockfile", {
exitCode: 1,
files: {
"package.json": JSON.stringify({
name: "test",
version: "1.0.0",
dependencies: {
"express": "3",
},
}),
},
fn: async ({ stdout, stderr }) => {
expect(normalizeBunSnapshot(await stderr)).toBe(MISSING_LOCKFILE);
expect(normalizeBunSnapshot(await stdout)).toBe("bun audit <version> (<revision>)");
},
});
doAuditTest("should exit 0 when there are no dependencies in package.json", {
exitCode: 0,
files: {
// i deemed this small enough to justify not needing a fixture
"package.json": JSON.stringify({
name: "empty-package",
version: "1.0.0",
}),
"bun.lock": JSON.stringify({
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "empty-package",
},
},
"packages": {},
}),
},
fn: async ({ stdout }) => {
expect(normalizeBunSnapshot(await stdout)).toBe(AUDIT_HEADER + noVulnerabilities(0));
},
});
doAuditTest("should exit 0 when there are no vulnerabilities", {
exitCode: 0,
files: fixture("safe-is-number@7"),
fn: async ({ stdout, stderr }) => {
expect(normalizeBunSnapshot(await stdout)).toBe(AUDIT_HEADER + noVulnerabilities(1));
expect(await stdout).toMatch(DURATION);
expect(await stderr).toBe("");
},
});
doAuditTest("should exit code 1 when there are vulnerabilities", {
exitCode: 1,
files: fixture("express@3"),
fn: async ({ stdout, stderr }) => {
const out = normalizeBunSnapshot(await stdout);
expect(out).toStartWith(AUDIT_HEADER);
expect(out).toEndWith("21 vulnerabilities (2 critical, 9 high, 4 moderate, 6 low)\n\n" + REPORT_FOOTER);
expect(out).not.toContain("");
expect(out).not.toContain("bun update");
expect(out).toMatchSnapshot("bun-audit-expect-vulnerabilities-found");
expect(await stderr).toBe("");
},
});
doAuditTest("--audit-level that drops every advisory says how many it dropped", {
exitCode: 0,
files: fixture("vuln-with-only-dev-dependencies"),
args: ["--audit-level", "critical"],
fn: async ({ stdout }) => {
expect(normalizeBunSnapshot(await stdout)).toBe(
AUDIT_HEADER + noVulnerabilities(1, "2 below --audit-level=critical"),
);
expect(await stdout).toMatch(DURATION);
},
});
doAuditTest("--ignore that drops every advisory says how many it ignored", {
exitCode: 0,
files: fixture("vuln-with-only-dev-dependencies"),
args: ["--ignore", "GHSA-w9mr-4mfr-499f", "--ignore", "GHSA-3fx5-fwvr-xrjg"],
fn: async ({ stdout }) => {
expect(normalizeBunSnapshot(await stdout)).toBe(AUDIT_HEADER + noVulnerabilities(1, "2 ignored"));
},
});
doAuditTest("--audit-level and --ignore drops are listed separately", {
exitCode: 0,
files: fixture("vuln-with-only-dev-dependencies"),
args: ["--audit-level", "high", "--ignore", "GHSA-3fx5-fwvr-xrjg"],
fn: async ({ stdout }) => {
expect(normalizeBunSnapshot(await stdout)).toBe(
AUDIT_HEADER + noVulnerabilities(1, "1 below --audit-level=high", "1 ignored"),
);
},
});
doAuditTest("should print valid JSON and exit 0 when --json is passed and there are no vulnerabilities", {
exitCode: 0,
files: fixture("safe-is-number@7"),
args: ["--json"],
fn: async ({ stdout }) => {
const out = await stdout;
const json = JSON.parse(out); // this would throw making the test fail if the JSON was invalid
expect(json).toMatchSnapshot("bun-audit-expect-valid-json-stdout-report-no-vulnerabilities");
},
});
doAuditTest("should print valid JSON and exit 1 when --json is passed and there are vulnerabilities", {
exitCode: 1,
files: fixture("express@3"),
args: ["--json"],
fn: async ({ stdout }) => {
const out = await stdout;
const json = JSON.parse(out); // this would throw making the test fail if the JSON was invalid
expect(json).toMatchSnapshot("bun-audit-expect-valid-json-stdout-report-vulnerabilities");
},
});
doAuditTest("--json exits 0 when --audit-level filters out every advisory, but still prints them all", {
exitCode: 0,
files: fixture("vuln-with-only-dev-dependencies"),
args: ["--json", "--audit-level", "critical"],
fn: async ({ stdout }) => {
const json = JSON.parse(await stdout);
expect(json.ms.map((a: { severity: string }) => a.severity).sort()).toStrictEqual(["high", "moderate"]);
},
});
doAuditTest("--json exits 0 when --ignore covers every advisory, but still prints them all", {
exitCode: 0,
files: fixture("vuln-with-only-dev-dependencies"),
args: ["--json", "--ignore", "GHSA-w9mr-4mfr-499f", "--ignore", "GHSA-3fx5-fwvr-xrjg"],
fn: async ({ stdout }) => {
const json = JSON.parse(await stdout);
expect(json.ms).toHaveLength(2);
},
});
doAuditTest("--json still exits 1 when an advisory survives --audit-level and --ignore", {
exitCode: 1,
files: fixture("vuln-with-only-dev-dependencies"),
args: ["--json", "--audit-level", "high", "--ignore", "GHSA-w9mr-4mfr-499f"],
fn: async ({ stdout }) => {
expect(JSON.parse(await stdout).ms).toHaveLength(2);
},
});
doAuditTest(
"should exit 1 and behave exactly the same when there are vulnerabilities when only devDependencies are specified",
{
exitCode: 1,
files: fixture("vuln-with-only-dev-dependencies"),
fn: async ({ stdout }) => {
expect(normalizeBunSnapshot(await stdout)).toMatchSnapshot("bun-audit-expect-vulnerabilities-found");
},
},
);
doAuditTest(
"when a project has some safe dependencies and some vulnerable dependencies, we should not print the safe dependencies",
{
exitCode: 1,
files: fixture("mix-of-safe-and-vulnerable-dependencies"),
fn: async ({ stdout }) => {
// The fixture installs a safe is-number and a vulnerable ms.
const out = await stdout;
expect(out).toContain("ms");
expect(out).not.toContain("is-number");
expect(normalizeBunSnapshot(out)).toMatchSnapshot("bun-audit-expect-vulnerabilities-found");
},
},
);
const fakeIntegrity = // this is just random/fake data as the integrity check is not important for this test
"sha512-V8E0l1jyyeSSS9R+J9oljx5eq2rqzClInuwaPcyuv0Mm3ViI/3/rcc4rCEO8i4eQ4I0O0FAGYDA2i5xWHHPhzg==";
function scopedRegistryProject(scoped: Registry) {
return {
"package.json": JSON.stringify({
name: "test",
version: "1.0.0",
dependencies: {
"@foo/bar": "1.0.0",
"@foo/baz": "1.0.0",
},
}),
"bun.lock": JSON.stringify({
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "test",
},
},
"packages": {
"@foo/bar": ["@foo/[email protected]", "", {}, fakeIntegrity],
"@foo/baz": ["@foo/[email protected]", "", {}, fakeIntegrity],
},
}),
".npmrc": `@foo:registry=${scoped.url.href}`,
};
}
async function auditWithDefaultRegistry(dir: string) {
await using proc = spawn({
cmd: [bunExe(), "audit"],
stdout: "pipe",
stderr: "pipe",
cwd: dir,
env: { ...bunEnv, NPM_CONFIG_REGISTRY: registryHref(server) },
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode };
}
test("packages served by a scoped registry are audited against that registry", async () => {
await using scoped = startRegistry({}, { bulkResponse: { "@foo/bar": [adv("<2.0.0")] } });
using dir = tempDir("bun-test-audit-scoped-registry", scopedRegistryProject(scoped));
const { stdout, stderr, exitCode } = await auditWithDefaultRegistry(String(dir));
expect(stdout).toContain("@foo/[email protected]\n");
expect(stdout).toContain("1 vulnerability (1 high)");
expect(stderr).toBe("");
expect(exitCode).toBe(1);
});
test("packages whose scoped registry does not answer the audit request are listed as skipped", async () => {
await using scoped = startRegistry({}, { bulkStatus: 404 });
using dir = tempDir("bun-test-audit-scoped-registry-down", scopedRegistryProject(scoped));
const { stdout, stderr, exitCode } = await auditWithDefaultRegistry(String(dir));
expect(normalizeBunSnapshot(stderr)).toBe(skippedWarning(registryHref(scoped), "404", "@foo/bar", "@foo/baz"));
expect(normalizeBunSnapshot(stdout)).toBe(AUDIT_HEADER + noVulnerabilities(0, "2 skipped"));
expect(exitCode).toBe(0);
});
doAuditTest("workspaces print the path to the vulnerable package and include workspace:pkg in the name", {
exitCode: 1,
files: {
"package.json": JSON.stringify({
name: "test",
version: "1.0.0",
workspaces: ["a"],
}),
"a/package.json": JSON.stringify({
"name": "a",
"dependencies": {
"ms": "0.7.0",
},
}),
"bun.lock": JSON.stringify({
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "bun-audit-playground",
},
"a": {
"name": "a",
"dependencies": {
"ms": "0.7.0",
},
},
},
"packages": {
"a": ["a@workspace:a"],
"ms": ["[email protected]", "", {}, fakeIntegrity],
},
}),
},
fn: async ({ stdout }) => {
const out = await stdout;
expect(out).toContain("\[email protected]\n workspace:a > ms\n");
expect(out).not.toContain("");
},
});
doAuditTest("--audit-level critical only shows critical vulnerabilities", {
exitCode: 1,
files: fixture("express@3"),
args: ["--audit-level", "critical"],
fn: async ({ stdout, stderr }) => {
expect(await stderr).not.toContain("invalid `--audit-level` value");
const output = await stdout;
expect(output).toContain("critical:");
expect(output).not.toContain("moderate:");
expect(output).not.toContain("high:");
expect(output).not.toContain("low:");
},
});
doAuditTest("--audit-level validates input and rejects invalid levels", {
exitCode: 1,
files: fixture("safe-is-number@7"),
args: ["--audit-level", "invalid"],
fn: async ({ stderr }) => {
expect(await stderr).toContain("invalid `--audit-level` value");
expect(await stderr).toContain("Valid values are: low, moderate, high, critical");
},
});
doAuditTest("--audit-level accepts all valid severity levels", {
exitCode: 0,
files: fixture("safe-is-number@7"),
args: ["--audit-level", "moderate"],
fn: async ({ stdout, stderr }) => {
expect(await stderr).toBe("");
expect(normalizeBunSnapshot(await stdout)).toBe(AUDIT_HEADER + noVulnerabilities(1));
},
});
doAuditTest("--prod flag is recognized and doesn't cause errors", {
exitCode: 1,
files: fixture("mix-of-safe-and-vulnerable-dependencies"),
args: ["--prod"],
fn: async ({ stdout, stderr }) => {
expect(await stderr).not.toContain("error");
expect(await stdout).toContain("vulnerabilities");
},
});
doAuditTest("--ignore flag filters out specific CVE IDs", {
exitCode: 1,
files: fixture("express@3"),
args: ["--ignore", "GHSA-gwg9-rgvj-4h5j"],
fn: async ({ stdout, stderr }) => {
expect(await stderr).not.toContain("error");
const output = await stdout;
expect(output).not.toContain("GHSA-gwg9-rgvj-4h5j");
expect(output).toContain("vulnerabilities");
},
});
test("sends a well-formed JSON request body when a package name contains a double quote", async () => {
const packageName = 'a"b';
using dirHandle = tempDir("bun-test-audit-name-with-quote", {
"package.json": JSON.stringify({
name: "test",
version: "1.0.0",
dependencies: {
[packageName]: "1.0.0",
},
}),
"bun.lock": JSON.stringify({
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "test",
"dependencies": {
[packageName]: "1.0.0",
},
},
},
"packages": {
[packageName]: [`${packageName}@1.0.0`, "", {}, fakeIntegrity],
},
}),
});
const dir = String(dirHandle);
let receivedBody = "";
await using auditServer = Bun.serve({
port: 0,
fetch: async req => {
receivedBody = Buffer.from(Bun.gunzipSync(await req.arrayBuffer())).toString("utf-8");
return Response.json({});
},
});
await using proc = spawn({
cmd: [bunExe(), "audit"],
stdout: "pipe",
stderr: "pipe",
cwd: dir,
env: {
...bunEnv,
NPM_CONFIG_REGISTRY: auditServer.url.href,
},
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(JSON.parse(receivedBody)).toStrictEqual({ [packageName]: ["1.0.0"] });
expect(normalizeBunSnapshot(stdout)).toBe(AUDIT_HEADER + noVulnerabilities(1));
expect(exitCode).toBe(0);
});
});
// Every audit line that names a registry prints it through the same redaction as the install error lines, which
// replaces an npm token or UUID anywhere in the URL with `***`; the skipped-registry record (the warning and the
// `unaudited` entries of `audit fix --json`) additionally leaves out credentials written into the URL itself. Most
// of these tests put the token in the registry path because that reaches the audit command from every config
// source, while `user:password@` is split out of the URL by some of them (`.npmrc`, bunfig registry strings) and
// kept by others (the bunfig object form used below, the env vars today).
describe("`bun audit` with a secret in the registry URL", () => {
const SECRET = "npm_" + "secret".padEnd(36, "0");
const BULK_PATH = "/-/npm/v1/security/advisories/bulk";
const NON_JSON = (registry: string) => `error: ${registry} returned a non-JSON audit response`;
// `url` is what the project is configured with, `printed` is how every audit line must render it.
function secretRegistry(registry: Registry) {
return { url: `${registry.url}${SECRET}/`, printed: `${registry.url}***` };
}
// The bulk endpoint lives under the token path, so the registry answers every request the same way.
function registryAnswering(body: string, init?: ResponseInit) {
return Bun.serve({ port: 0, fetch: () => new Response(body, init) });
}
// `bun audit` only reads bun.lock, so the project never needs an install.
function project(dependencies: Record<string, string>, extraFiles: Record<string, string> = {}) {
return tempDir("audit-registry-secret-", {
"package.json": JSON.stringify({ name: "app", dependencies }),
"bun.lock": JSON.stringify({
lockfileVersion: 1,
workspaces: { "": { name: "app", dependencies } },
packages: Object.fromEntries(
Object.entries(dependencies).map(([name, version]) => [name, [`${name}@${version}`, "", {}, ""]]),
),
}),
...extraFiles,
});
}
async function auditAgainst(dir: string, defaultRegistry: string, ...args: string[]) {
await using proc = spawn({
cmd: [bunExe(), "audit", ...args],
cwd: String(dir),
env: { ...bunEnv, NPM_CONFIG_REGISTRY: defaultRegistry },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode };
}
test.concurrent("the failed POST line masks the secret", async () => {
await using registry = registryAnswering("not found", { status: 404 });
const { url, printed } = secretRegistry(registry);
using dir = project({ "no-deps": "1.0.0" });
const { stdout, stderr, exitCode } = await auditAgainst(dir, url);
expect(normalizeBunSnapshot(stderr)).toBe(`error: POST ${printed}${BULK_PATH} - 404`);
expect(normalizeBunSnapshot(stdout)).toBe("bun audit <version> (<revision>)");
expect(exitCode).toBe(1);
});
test.concurrent("the non-JSON response line masks the secret", async () => {
await using registry = registryAnswering("<html><body>sign in</body></html>");
const { url, printed } = secretRegistry(registry);
using dir = project({ "no-deps": "1.0.0" });
const { stdout, stderr, exitCode } = await auditAgainst(dir, url);
expect(normalizeBunSnapshot(stderr)).toBe(NON_JSON(printed));
expect(normalizeBunSnapshot(stdout)).toBe("bun audit <version> (<revision>)");
expect(exitCode).toBe(1);
});
// A body starting with `{` gets past the response check and is rejected when it is parsed instead; the report,
// --json and fix code paths each report that themselves.
test.concurrent("the unparsable response line masks the secret in every mode", async () => {
const body = "{ not json";
await using registry = registryAnswering(body);
const { url, printed } = secretRegistry(registry);
using dir = project({ "no-deps": "1.0.0" });
const report = await auditAgainst(dir, url);
expect(normalizeBunSnapshot(report.stderr)).toBe(NON_JSON(printed));
expect(normalizeBunSnapshot(report.stdout)).toBe("bun audit <version> (<revision>)");
expect(report.exitCode).toBe(1);
const json = await auditAgainst(dir, url, "--json");
expect(normalizeBunSnapshot(json.stderr)).toBe(NON_JSON(printed));
expect(json.stdout).toBe(body + "\n");
expect(json.exitCode).toBe(1);
const fix = await auditAgainst(dir, url, "fix");
expect(normalizeBunSnapshot(fix.stderr)).toBe(NON_JSON(printed));
expect(normalizeBunSnapshot(fix.stdout)).toBe("bun audit fix <version> (<revision>)");
expect(fix.exitCode).toBe(1);
});
// `bun audit` and `bun audit fix --json` against a project whose only package comes from a scoped registry that
// answers 404, so both commands report that registry as skipped; `printed` is how it must be named.
async function expectSkippedRegistry(dir: string, printed: string) {
const skipped = skippedWarning(printed, "404", "@foo/bar");
const report = await auditAgainst(dir, registryHref(server));
expect(normalizeBunSnapshot(report.stderr)).toBe(skipped);
expect(normalizeBunSnapshot(report.stdout)).toBe(AUDIT_HEADER + noVulnerabilities(0, "1 skipped"));
expect(report.exitCode).toBe(0);
const fix = await auditAgainst(dir, registryHref(server), "fix", "--json");
expect(normalizeBunSnapshot(fix.stderr)).toBe(skipped);
expect(JSON.parse(fix.stdout)).toStrictEqual({
dryRun: false,
fixed: 0,
remaining: 0,
fixes: [],
blocked: [],
unfixable: [],
manifestUnavailable: [],
unmatched: [],
unaudited: [{ registry: printed, packages: ["@foo/bar"], reason: "404" }],
vulnerableAfterInstall: [],
});
expect(fix.exitCode).toBe(0);
}
test.concurrent("the skipped registry warning and the --json unaudited entry mask the secret", async () => {
await using scoped = registryAnswering("not found", { status: 404 });
const { url, printed } = secretRegistry(scoped);
using dir = project({ "@foo/bar": "1.0.0" }, { ".npmrc": `@foo:registry=${url}\n` });
await expectSkippedRegistry(dir, printed);
});
test.concurrent("the skipped registry warning and the --json unaudited entry leave out URL credentials", async () => {
await using scoped = registryAnswering("not found", { status: 404 });
const url = `${scoped.url.protocol}//alice:s3cret@${scoped.url.host}/`;
using dir = project({ "@foo/bar": "1.0.0" }, { "bunfig.toml": `[install.scopes]\nfoo = { url = "${url}" }\n` });
await expectSkippedRegistry(dir, registryHref(scoped));
});
});
describe("`bun audit --prod`", () => {
// pnpm#13605: an optional peer that only a devDependency brought in is not a production dependency.
test.concurrent("bun audit --prod skips a dev-only optional peer of a production package", async () => {
await using server = startRegistry({ "no-deps": [adv("<1.0.1")] });
using dir = await setup(server, {
name: "foo",
dependencies: { "one-optional-peer-dep": "1.0.2" },
devDependencies: { "no-deps": "1.0.0" },
});
expect(await lock(dir)).toContain('"[email protected]"');
const all = await audit(dir);
expect(all.stdout).toContain("no-deps");
expect(all.exitCode).toBe(1);
expectClean(await audit(dir, "--prod"), 1);
});
// pnpm#13605: production status is per installed version, not per name.
test.concurrent(
"bun audit --prod skips a dev-only version of a name that is also a production dependency",
async () => {
await using server = startRegistry({ "no-deps": [adv("<1.0.1")] });
using dir = await setup(server, {
name: "foo",
dependencies: { "one-dep": "1.0.0" },
devDependencies: { "no-deps": "1.0.0" },
});
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).toContain('"[email protected]"');
const all = await audit(dir);
expect(all.stdout).toContain("no-deps");
expect(all.exitCode).toBe(1);
expectClean(await audit(dir, "--prod"), 2);
},
);
test.concurrent(
"bun audit --prod still reports the production version of a name that also has a dev version",
async () => {
await using server = startRegistry({ "no-deps": [adv("1.0.1")] });
using dir = await setup(server, {
name: "foo",
dependencies: { "one-dep": "1.0.0" },
devDependencies: { "no-deps": "1.0.0" },
});
const prod = await audit(dir, "--prod");
expect(prod.stdout).toContain("no-deps");
expect(prod.stdout).toContain("1 vulnerability (1 high)");
expect(prod.exitCode).toBe(1);
},
);
});
describe("`bun audit --omit`", () => {
test.concurrent.each(["dev", "optional", "peer"] as const)(
"--omit=%s skips packages only reached that way",
async kind => {
await using server = startRegistry({ "no-deps": [adv("<1.0.1")] });
const field = { dev: "devDependencies", optional: "optionalDependencies", peer: "peerDependencies" }[kind];
using dir = await setup(server, { name: "foo", [field]: { "no-deps": "1.0.0" } });
expect(await lock(dir)).toContain('"[email protected]"');
const all = await audit(dir);
expect(all.stdout).toContain("1 vulnerability (1 high)");
expect(all.exitCode).toBe(1);
expectClean(await audit(dir, `--omit=${kind}`), 0);
},
);
test.concurrent("--omit=optional keeps auditing dev dependencies", async () => {
await using server = startRegistry({ "no-deps": [adv("<1.0.1")] });
using dir = await setup(server, { name: "foo", devDependencies: { "no-deps": "1.0.0" } });
const { stdout, exitCode } = await audit(dir, "--omit=optional");
expect(stdout).toContain("1 vulnerability (1 high)");
expect(exitCode).toBe(1);
});
test.concurrent("--prod in a workspace is scoped to the workspace the command runs in", async () => {
await using server = startRegistry({ "no-deps": [adv("<1.0.1")], "a-dep": [adv("<1.0.4", 2)] });
using dir = await setup(
server,
{ name: "root", workspaces: ["packages/*"] },
{
"packages/a/package.json": JSON.stringify({ name: "a", devDependencies: { "no-deps": "1.0.0" } }),
"packages/b/package.json": JSON.stringify({ name: "b", dependencies: { "a-dep": "1.0.2" } }),
},
);
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).toContain('"[email protected]"');
const fromRoot = await audit(dir, "--prod");
expect(fromRoot.stdout).toContain("a-dep");
expect(fromRoot.stdout).not.toContain("no-deps");
expect(fromRoot.stdout).toContain("1 vulnerability (1 high)");
expect(fromRoot.exitCode).toBe(1);
const fromRootOmit = await run(dir, ["audit", "--omit=dev"]);
expect(fromRootOmit.stdout).toBe(fromRoot.stdout);
expect(fromRootOmit.exitCode).toBe(1);
expectClean(await run(join(dir, "packages", "a"), ["audit", "--prod"], dir), 0);
const fromB = await run(join(dir, "packages", "b"), ["audit", "--prod"], dir);
expect(fromB.stdout).toContain("a-dep");
expect(fromB.stdout).not.toContain("no-deps");
expect(fromB.stdout).toContain("1 vulnerability (1 high)");
expect(fromB.exitCode).toBe(1);
const fromAAll = await run(join(dir, "packages", "a"), ["audit"], dir);
expect(fromAAll.stdout).toContain("a-dep");
expect(fromAAll.stdout).toContain("no-deps");
expect(fromAAll.stdout).toContain("2 vulnerabilities (2 high)");
expect(fromAAll.exitCode).toBe(1);
});
test.concurrent("--json --prod sends only production packages and prints the empty response", async () => {
await using server = startRegistry({ "no-deps": [adv("<1.0.1")] });
using dir = await setup(server, { name: "foo", devDependencies: { "no-deps": "1.0.0" } });
const all = await audit(dir, "--json");
expect(Object.keys(JSON.parse(all.stdout))).toStrictEqual(["no-deps"]);
expect(all.exitCode).toBe(1);
const prod = await audit(dir, "--json", "--prod");
expect(prod.stdout).toBe("{}\n");
expect(prod.exitCode).toBe(0);
});
test.concurrent("-p and -P are accepted as --prod", async () => {
await using server = startRegistry({ "no-deps": [adv("<1.0.1")] });
using dir = await setup(server, { name: "foo", devDependencies: { "no-deps": "1.0.0" } });
const all = await audit(dir);
expect(all.stdout).toContain("1 vulnerability (1 high)");
expect(all.exitCode).toBe(1);
for (const flag of ["-p", "-P", "--production"]) {
expectClean(await audit(dir, flag), 0);
}
});
});
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" } });
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);
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);
const moderate = await audit(dir, "--audit-level=moderate");
expect(moderate.stdout).toContain("1 vulnerability (1 moderate)");
expect(moderate.exitCode).toBe(1);
});
test.concurrent("names the installed version, one range per advisory, and ends on the fix commands", async () => {
await using server = startRegistry({
"no-deps": [
{ ...adv("<1.1.0", 1), severity: "moderate", title: "ReDoS in no-deps" },
{ ...adv("<1.0.2", 2), title: "escaping in no-deps" },
],
"a-dep": [adv("<1.0.4", 3)],
});
using dir = await setup(server, { name: "foo", dependencies: { "one-dep": "1.0.0", "a-dep": "1.0.2" } });
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, stderr, exitCode } = await audit(dir);
expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`
"bun audit <version> (<revision>)
[email protected]
(direct dependency)
high: test advisory (<1.0.4) - https://example.invalid/advisory/3
[email protected]
one-dep > no-deps
moderate: ReDoS in no-deps (<1.1.0) - https://example.invalid/advisory/1
high: escaping in no-deps (<1.0.2) - https://example.invalid/advisory/2
3 vulnerabilities (2 high, 1 moderate)
bun audit fix upgrade the vulnerable packages within their ranges
bun audit fix --latest also cross major versions"
`);
expect(stdout).toEndWith("also cross major versions\n");
expect(stderr).toBe("");
expect(exitCode).toBe(1);
});
test.concurrent("writes nothing", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setup(server, { name: "foo", dependencies: { "a-dep": "1.0.2" } });
const lockBefore = await lock(dir);
const pkgJsonBefore = await pkgJsonText(dir);
const { stdout, exitCode } = await audit(dir);
expect(stdout).toContain("1 vulnerability (1 high)");
expect(exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
expect(await pkgJsonText(dir)).toBe(pkgJsonBefore);
});
test.concurrent(
"`bun audit --fix` is not `bun audit fix`: the unknown flag is ignored and a plain audit runs",
async () => {
const bulkHits = { count: 0 };
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] }, { bulkHits });
using dir = await setup(server, { name: "foo", dependencies: { "a-dep": "1.0.2" } });
const lockBefore = await lock(dir);
const pkgJsonBefore = await pkgJsonText(dir);
const plain = await audit(dir);
expect(plain.stdout).toContain("1 vulnerability (1 high)");
expect(plain.exitCode).toBe(1);
expect(bulkHits.count).toBe(1);
const withFix = await audit(dir, "--fix");
expect(withFix.stdout).toBe(plain.stdout);
expect(withFix.stderr).not.toContain("--fix");
expect(withFix.exitCode).toBe(1);
expect(bulkHits.count).toBe(2);
expect(await lock(dir)).toBe(lockBefore);
expect(await pkgJsonText(dir)).toBe(pkgJsonBefore);
},
);
test.concurrent("`bun audit -L` is rejected before the registry is contacted", async () => {
const bulkHits = { count: 0 };
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] }, { bulkHits });
using dir = await setup(server, { name: "foo", dependencies: { "a-dep": "1.0.2" } });
const { stdout, stderr, exitCode } = await audit(dir, "-L");
expect(stderr).toContain("--latest only applies to bun audit fix");
expect(stdout).toBe("");
expect(exitCode).toBe(1);
expect(bulkHits.count).toBe(0);
});
test.concurrent("advisories from the default and a scoped registry are merged into one report", async () => {
await using scoped = startRegistry({ "@types/is-number": [adv("<2.0.0", 2)] });
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setup(
server,
{ name: "foo", dependencies: { "@types/is-number": "1.0.0", "a-dep": "1.0.2" } },
{},
{ types: scoped.url.href },
);
const report = await audit(dir);
expect(report.stdout).toContain("@types/is-number");
expect(report.stdout).toContain("a-dep");
expect(report.stdout).toContain("2 vulnerabilities (2 high)");
expect(report.stderr).toBe("");
expect(report.exitCode).toBe(1);
const json = await audit(dir, "--json");
expect(json.stdout.trim().split("\n")).toHaveLength(1);
expect(JSON.parse(json.stdout)).toStrictEqual({
"a-dep": [adv("<1.0.4")],
"@types/is-number": [adv("<2.0.0", 2)],
});
expect(json.exitCode).toBe(1);
});
test.concurrent("--json prints an unparsable response and reports the parse failure", async () => {
const body = "<html><body>registry is down</body></html>";
await using server = startRegistry({}, { bulkBody: body });
using dir = await setup(server, { name: "foo", dependencies: { "a-dep": "1.0.2" } });
const { stdout, stderr, exitCode } = await audit(dir, "--json");
expect(stdout).toBe(body + "\n");
expect(normalizeBunSnapshot(stderr)).toBe(`error: ${registryHref(server)} returned a non-JSON audit response`);
expect(exitCode).toBe(1);
const text = await audit(dir);
expect(normalizeBunSnapshot(text.stdout)).toBe("bun audit <version> (<revision>)");
expect(normalizeBunSnapshot(text.stderr)).toBe(`error: ${registryHref(server)} returned a non-JSON audit response`);
expect(text.exitCode).toBe(1);
});
test.concurrent("a response whose root is not an object is a parse failure", async () => {
await using server = startRegistry({}, { bulkBody: "[]" });
using dir = await setup(server, { name: "foo", dependencies: { "a-dep": "1.0.2" } });
const lockBefore = await lock(dir);
const json = await audit(dir, "--json");
expect(json.stdout).toBe("[]\n");
expect(json.stderr).toContain(`error: ${registryHref(server)} returned a non-JSON audit response`);
expect(json.exitCode).toBe(1);
const fix = await auditFix(dir);
expect(normalizeBunSnapshot(fix.stderr)).toBe(`error: ${registryHref(server)} returned a non-JSON audit response`);
expect(normalizeBunSnapshot(fix.stdout)).toBe("bun audit fix <version> (<revision>)");
expect(fix.exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("a 5xx from the default registry fails the audit", async () => {
await using server = startRegistry({}, { bulkStatus: 500 });
using dir = await setup(server, { name: "foo", dependencies: { "a-dep": "1.0.2" } });
const { stdout, stderr, exitCode } = await audit(dir);
expect(normalizeBunSnapshot(stderr)).toBe(
`error: POST ${registryHref(server)}/-/npm/v1/security/advisories/bulk - 500`,
);
expect(normalizeBunSnapshot(stdout)).toBe("bun audit <version> (<revision>)");
expect(exitCode).toBe(1);
});
test.concurrent("a default registry that refuses the connection fails both commands", async () => {
await using server = startRegistry({});
using dir = await setup(server, { name: "foo", dependencies: { "a-dep": "1.0.2" } });
const lockBefore = await lock(dir);
await writeBunfig(dir, await deadRegistryHref());
const report = await audit(dir);
expect(report.stderr).toStartWith("error: ");
expect(normalizeBunSnapshot(report.stdout)).toBe("bun audit <version> (<revision>)");
expect(report.exitCode).toBe(1);
const fix = await auditFix(dir);
expect(fix.stderr).toStartWith("error: ");
expect(normalizeBunSnapshot(fix.stdout)).toBe("bun audit fix <version> (<revision>)");
expect(fix.exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("a scoped registry that refuses the connection is skipped", async () => {
await using scoped = startRegistry({});
await using server = startRegistry({});
using dir = await setup(
server,
{ name: "foo", dependencies: { "@types/is-number": "1.0.0" } },
{},
{ types: scoped.url.href },
);
const dead = await deadRegistryHref();
await writeBunfig(dir, server, { types: dead });
const report = await audit(dir);
expect(normalizeBunSnapshot(report.stdout)).toBe(AUDIT_HEADER + noVulnerabilities(0, "1 skipped"));
expect(normalizeBunSnapshot(report.stderr)).toMatch(
new RegExp(`^warn: ${dead.slice(0, -1)} did not answer the audit request \\([^)]+\\); skipped @types/is-number$`),
);
expect(report.exitCode).toBe(0);
const fix = await auditFix(dir, "--json");
expect(JSON.parse(fix.stdout)).toStrictEqual({
dryRun: false,
fixed: 0,
remaining: 0,
fixes: [],
blocked: [],
unfixable: [],
manifestUnavailable: [],
unmatched: [],
unaudited: [{ registry: dead.slice(0, -1), packages: ["@types/is-number"], reason: expect.any(String) }],
vulnerableAfterInstall: [],
});
expect(fix.exitCode).toBe(0);
});
test.concurrent("a scoped registry that answers 200 with a non-JSON body is skipped", async () => {
await using scoped = startRegistry({}, { bulkBody: "<html><body>sign in</body></html>" });
await using server = startRegistry({});
using dir = await setup(
server,
{ name: "foo", dependencies: { "@types/is-number": "1.0.0" } },
{},
{ types: scoped.url.href },
);
const report = await audit(dir);
expect(normalizeBunSnapshot(report.stdout)).toBe(AUDIT_HEADER + noVulnerabilities(0, "1 skipped"));
expect(normalizeBunSnapshot(report.stderr)).toBe(
skippedWarning(registryHref(scoped), "non-JSON response", "@types/is-number"),
);
expect(report.exitCode).toBe(0);
const fixText = await auditFix(dir);
expect(normalizeBunSnapshot(fixText.stdout)).toBe(FIX_HEADER + noVulnerabilities(0, "1 skipped"));
expect(normalizeBunSnapshot(fixText.stderr)).toBe(
skippedWarning(registryHref(scoped), "non-JSON response", "@types/is-number"),
);
expect(fixText.exitCode).toBe(0);
const fix = await auditFix(dir, "--json");
expect(JSON.parse(fix.stdout)).toMatchObject({
fixed: 0,
remaining: 0,
unaudited: [{ registry: registryHref(scoped), packages: ["@types/is-number"], reason: "non-JSON response" }],
});
expect(fix.exitCode).toBe(0);
});
});
describe("`bun audit fix`", () => {
test.concurrent("fixes a direct dependency to the lowest safe version, not the newest", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setupVulnerableADep(server);
const pkgJsonBefore = await file(join(dir, "package.json")).text();
const { stdout, stderr, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`
"bun audit fix <version> (<revision>)
fixing:
^ a-dep 1.0.2 -> 1.0.4
Fixed 1 vulnerability in 1 package (checked 1)"
`);
expect(stdout).toMatch(DURATION);
expect(stderr).toContain("Saved lockfile");
expect(stderr).not.toContain("Resolving...");
expect(stderr).not.toContain("error:");
expect(exitCode).toBe(0);
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
expect(await installedVersion(dir, "a-dep")).toBe("1.0.4");
expect(await file(join(dir, "package.json")).text()).toBe(pkgJsonBefore);
const recheck = await audit(dir);
expectClean(recheck, 1);
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent("fixes a transitive dependency and leaves its dependent alone", async () => {
await using server = startRegistry({ "no-deps": [adv("<1.0.1")] });
using dir = await setup(server, { name: "foo", dependencies: { "one-range-dep": "1.0.0", "no-deps": "1.0.0" } });
await reinstall(dir, { name: "foo", dependencies: { "one-range-dep": "1.0.0" } });
let lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain(" ^ no-deps 1.0.0 -> 1.0.1");
expect(stdout).toContain("Fixed 1 vulnerability in 1 package");
expect(exitCode).toBe(0);
lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
expect(await installedVersion(dir, "no-deps")).toBe("1.0.1");
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent("reports a fix that would violate a dependent's range and changes nothing", async () => {
const bulkHits = { count: 0 };
await using server = startRegistry({ "no-deps": [adv("<1.1.0")] }, { bulkHits });
using dir = await setup(server, { name: "foo", dependencies: { "one-dep": "1.0.0" } });
const lockBefore = await lock(dir);
expect(lockBefore).toContain('"[email protected]"');
const rootBefore = await pkgJsonText(dir);
const dependentBefore = await pkgJsonText(dir, "node_modules", "one-dep");
const { stdout, stderr, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`
"bun audit fix <version> (<revision>)
blocked by a dependent's range:
^ no-deps 1.0.1 -> 1.1.0
[email protected] depends on [email protected]
Fixed 0 of 1 vulnerability (checked 2)
1 vulnerability remaining"
`);
expect(stderr).toBe("");
expect(exitCode).toBe(1);
expect(bulkHits.count).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
expect(await pkgJsonText(dir)).toBe(rootBefore);
expect(await pkgJsonText(dir, "node_modules", "one-dep")).toBe(dependentBefore);
});
test.concurrent("a safe older release outside the dependent's range is not a downgrade candidate", async () => {
await using server = startRegistry({ "no-deps": [adv(">=1.0.1 <2.0.0")] });
using dir = await setup(server, { name: "foo", dependencies: { "one-dep": "1.0.0" } });
const lockBefore = await lock(dir);
expect(lockBefore).toContain('"[email protected]"');
const { stdout, stderr, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`
"bun audit fix <version> (<revision>)
blocked by a dependent's range:
^ no-deps 1.0.1 -> 2.0.0
[email protected] depends on [email protected]
Fixed 0 of 1 vulnerability (checked 2)
1 vulnerability remaining"
`);
expect(stderr).not.toContain("Saved lockfile");
expect(exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("--dry-run prints the plan and writes nothing", async () => {
const bulkHits = { count: 0 };
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] }, { bulkHits });
using dir = await setupVulnerableADep(server);
const lockBefore = await lock(dir);
const { stdout, stderr, exitCode } = await auditFix(dir, "--dry-run");
expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`
"bun audit fix <version> (<revision>)
fixing:
^ a-dep 1.0.2 -> 1.0.4
Would fix 1 vulnerability in 1 package (checked 1)"
`);
expect(stdout).toMatch(/ \[\d+\.\d\dm?s\]\n$/);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
expect(bulkHits.count).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
expect(await installedVersion(dir, "a-dep")).toBe("1.0.2");
});
test.concurrent(
"the configured security scanner is sent every package, with the fix at its new version",
async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setup(server, { name: "foo", dependencies: { "a-dep": "1.0.2", "no-deps": "1.0.0" } });
await reinstall(dir, { name: "foo", dependencies: { "a-dep": "^1.0.2", "no-deps": "1.0.0" } });
expect(await lock(dir)).toContain('"[email protected]"');
await configureScanner(dir, server);
const { stdout, stderr, exitCode } = await auditFix(dir);
expect(stderr).toContain("SCANNER_RAN");
expect(stdout).toContain(" ^ a-dep 1.0.2 -> 1.0.4");
expect(stdout).toContain("Fixed 1 vulnerability in 1 package");
expect(exitCode).toBe(0);
expect(await scanned(dir)).toStrictEqual(["[email protected]", "[email protected]"]);
expect(await lock(dir)).toContain('"[email protected]"');
expect(await installedVersion(dir, "a-dep")).toBe("1.0.4");
},
);
test.concurrent(
"a fatal advisory from the security scanner for the fixed version aborts before anything is written",
async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setup(server, { name: "foo", dependencies: { "a-dep": "1.0.2" } });
const pkgJsonBefore = await pkgJsonText(dir);
const lockBefore = await lock(dir);
expect(lockBefore).toContain('"[email protected]"');
await configureScanner(dir, server, ["[email protected]"]);
const { stdout, stderr, exitCode } = await auditFix(dir);
expect(stderr).toContain("SCANNER_RAN");
expect(stdout).toContain("FATAL: a-dep");
expect(stdout).toContain("blocked [email protected]");
expect(stdout).toContain("https://example.invalid/scanner/[email protected]");
expect(stdout).toContain("Installation aborted due to fatal security advisories");
expect(stdout).not.toContain("Fixed 1 vulnerability");
expect(stderr).not.toContain("Saved lockfile");
expect(exitCode).toBe(1);
expect(await scanned(dir)).toStrictEqual(["[email protected]"]);
expect(await pkgJsonText(dir)).toBe(pkgJsonBefore);
expect(await lock(dir)).toBe(lockBefore);
expect(await installedVersion(dir, "a-dep")).toBe("1.0.2");
},
);
test.concurrent("--dry-run does not run the security scanner", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setupVulnerableADep(server);
const lockBefore = await lock(dir);
await configureScanner(dir, server, ["[email protected]"]);
const { stdout, stderr, exitCode } = await auditFix(dir, "--dry-run");
expect(stderr).not.toContain("SCANNER_RAN");
expect(stdout).toContain("Would fix 1 vulnerability in 1 package");
expect(stdout).not.toContain("FATAL");
expect(exitCode).toBe(0);
expect(await exists(join(dir, "scanned.json"))).toBe(false);
expect(await lock(dir)).toBe(lockBefore);
expect(await installedVersion(dir, "a-dep")).toBe("1.0.2");
});
test.concurrent("--json stays a single document when the security scanner runs clean", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setupVulnerableADep(server);
await configureScanner(dir, server);
const { stdout, stderr, exitCode } = await auditFix(dir, "--json");
expect(stderr).toContain("SCANNER_RAN");
expect(stdout.trim().split("\n")).toHaveLength(1);
expect(JSON.parse(stdout)).toStrictEqual({
dryRun: false,
fixed: 1,
remaining: 0,
fixes: [
{
name: "a-dep",
from: "1.0.2",
to: "1.0.4",
downgrade: false,
newerThanMinimumReleaseAge: false,
packageJson: [],
},
],
blocked: [],
unfixable: [],
manifestUnavailable: [],
unmatched: [],
unaudited: [],
vulnerableAfterInstall: [],
});
expect(exitCode).toBe(0);
expect(await scanned(dir)).toStrictEqual(["[email protected]"]);
expect(await installedVersion(dir, "a-dep")).toBe("1.0.4");
});
test.concurrent("a range that rejects every safe release is blocked on the highest safe downgrade", async () => {
await using server = startRegistry({ "no-deps": [adv(">=2.0.0")] });
using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "^2.0.0" } });
const lockBefore = await lock(dir);
expect(lockBefore).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`
"bun audit fix <version> (<revision>)
blocked by a dependent's range:
v no-deps 2.0.0 -> 1.1.0 (downgrade)
package.json depends on no-deps@^2.0.0
bun audit fix --latest
Fixed 0 of 1 vulnerability (checked 1)
1 vulnerability remaining"
`);
expect(exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("no vulnerabilities", async () => {
await using server = startRegistry({});
using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "1.0.0" } });
const lockBefore = await lock(dir);
const { stdout, stderr, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toBe(FIX_HEADER + noVulnerabilities(1));
expect(stdout).toMatch(DURATION);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
expect(await lock(dir)).toBe(lockBefore);
const report = await audit(dir);
expect(normalizeBunSnapshot(report.stdout)).toBe(AUDIT_HEADER + noVulnerabilities(1));
expect(report.stdout).toMatch(DURATION);
expect(report.stderr).toBe("");
expect(report.exitCode).toBe(0);
});
test.concurrent("the hint sits under the blocked entry --latest can fix, not the last one", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")], "no-deps": [adv("<1.1.0", 2)] });
using dir = await setup(server, { name: "foo", dependencies: { "a-dep": "<1.0.4", "one-dep": "1.0.0" } });
const lockBefore = await lock(dir);
expect(lockBefore).toContain('"[email protected]"');
expect(lockBefore).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`
"bun audit fix <version> (<revision>)
blocked by a dependent's range:
^ a-dep 1.0.3 -> 1.0.4
package.json depends on a-dep@<1.0.4
bun audit fix --latest
^ no-deps 1.0.1 -> 1.1.0
[email protected] depends on [email protected]
Fixed 0 of 2 vulnerabilities (checked 3)
2 vulnerabilities remaining"
`);
expect(exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
const json = await auditFix(dir, "--json");
expect(JSON.parse(json.stdout).blocked).toStrictEqual([
{
name: "a-dep",
from: "1.0.3",
to: "1.0.4",
downgrade: false,
latestFixes: true,
blockers: [{ dependent: "package.json", range: "<1.0.4", bundled: false }],
},
{
name: "no-deps",
from: "1.0.1",
to: "1.1.0",
downgrade: false,
latestFixes: false,
blockers: [{ dependent: "[email protected]", range: "1.0.1", bundled: false }],
},
]);
expect(json.exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("a workspace dependent is named by its package.json path", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setup(
server,
{ name: "root", workspaces: ["packages/*"] },
{ "packages/pkg-a/package.json": JSON.stringify({ name: "pkg-a", dependencies: { "a-dep": "<1.0.4" } }) },
);
const lockBefore = await lock(dir);
expect(lockBefore).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`
"bun audit fix <version> (<revision>)
blocked by a dependent's range:
^ a-dep 1.0.3 -> 1.0.4
packages/pkg-a/package.json depends on a-dep@<1.0.4
bun audit fix --latest
Fixed 0 of 1 vulnerability (checked 1)
1 vulnerability remaining"
`);
expect(exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
const json = await auditFix(dir, "--json");
expect(JSON.parse(json.stdout).blocked[0].blockers).toStrictEqual([
{ dependent: "packages/pkg-a/package.json", range: "<1.0.4", bundled: false },
]);
expect(json.exitCode).toBe(1);
});
test.concurrent("--silent prints nothing and the exit code carries the result", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")], "no-deps": [adv("<1.1.0", 2)] });
using dir = await setup(server, { name: "foo", dependencies: { "a-dep": "1.0.2", "one-dep": "1.0.0" } });
await reinstall(dir, { name: "foo", dependencies: { "a-dep": "^1.0.2", "one-dep": "1.0.0" } });
const lockBefore = await lock(dir);
expect(lockBefore).toContain('"[email protected]"');
const dryRun = await auditFix(dir, "--dry-run", "--silent");
expect(dryRun.stdout).toBe("");
expect(dryRun.stderr).toBe("");
expect(dryRun.exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
const { stdout, stderr, exitCode } = await auditFix(dir, "--silent");
expect(stdout).toBe("");
expect(stderr).toBe("");
expect(exitCode).toBe(1);
expect(await lock(dir)).toContain('"[email protected]"');
expect(await installedVersion(dir, "a-dep")).toBe("1.0.4");
const json = await auditFix(dir, "--json", "--silent");
expect(JSON.parse(json.stdout)).toMatchObject({ fixed: 0, remaining: 1, fixes: [] });
expect(json.stderr).toBe("");
expect(json.exitCode).toBe(1);
});
test.concurrent("--silent with a fix that clears everything exits 0 without printing", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setupVulnerableADep(server);
const { stdout, stderr, exitCode } = await auditFix(dir, "--silent");
expect(stdout).toBe("");
expect(stderr).toBe("");
expect(exitCode).toBe(0);
expect(await lock(dir)).toContain('"[email protected]"');
expect(await installedVersion(dir, "a-dep")).toBe("1.0.4");
});
test.concurrent("refuses when package.json has changed since bun.lock was written", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setupVulnerableADep(server);
const lockBefore = await lock(dir);
const stale = JSON.stringify({ name: "foo", dependencies: { "a-dep": "^1.0.2", "no-deps": "1.0.0" } });
await write(join(dir, "package.json"), stale);
const { stdout, stderr, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stderr)).toMatchInlineSnapshot(`
"error: bun.lock does not match package.json, nothing to fix
note: run 'bun install' first"
`);
expect(normalizeBunSnapshot(stdout)).toBe("bun audit fix <version> (<revision>)");
expect(exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
expect(await pkgJsonText(dir)).toBe(stale);
expect(await installedVersion(dir, "a-dep")).toBe("1.0.2");
expect(await exists(join(dir, "node_modules", "no-deps"))).toBe(false);
const dryRun = await auditFix(dir, "--dry-run");
expect(dryRun.stderr).toContain("error: bun.lock does not match package.json, nothing to fix");
expect(dryRun.exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("peer dependency edges constrain the fix and are re-pointed", async () => {
await using server = startRegistry({ "no-deps": [adv("<1.0.1")] });
using dir = await setup(server, { name: "foo", dependencies: { "peer-deps-fixed": "1.0.0", "no-deps": "1.0.0" } });
await reinstall(dir, { name: "foo", dependencies: { "peer-deps-fixed": "1.0.0", "no-deps": "^1.0.0" } });
let lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).toContain('"[email protected]"');
const { stdout, stderr, exitCode } = await auditFix(dir);
expect(stdout).toContain(" ^ no-deps 1.0.0 -> 1.0.1");
expect(stderr).not.toContain("incorrect peer dependency");
expect(exitCode).toBe(0);
lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent("instances of the same package are planned independently", async () => {
await using server = startRegistry({ "no-deps": [adv("<1.1.0")] });
using dir = await setup(server, { name: "foo", dependencies: { "one-dep": "1.0.0", "no-deps": "1.0.0" } });
await reinstall(dir, { name: "foo", dependencies: { "one-dep": "1.0.0", "no-deps": "^1.0.0" } });
let lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain("fixing:");
expect(stdout).toContain(" ^ no-deps 1.0.0 -> 1.1.0");
expect(stdout).toContain("blocked by a dependent's range:");
expect(stdout).toContain(" ^ no-deps 1.0.1 -> 1.1.0");
expect(stdout).toContain("[email protected] depends on [email protected]");
// pnpm#10646: the advisory still applies to [email protected], so it is remaining, not fixed, and `bun audit` agrees.
expect(stdout).toContain("Fixed 0 vulnerabilities in 1 package");
expect(stdout).toContain("1 vulnerability remaining");
expect(exitCode).toBe(1);
lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
expect(await installedVersion(dir, "no-deps")).toBe("1.1.0");
const recheck = await audit(dir);
expect(recheck.stdout).toContain("1 vulnerability (1 high)");
expect(recheck.exitCode).toBe(1);
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
// pnpm#10646: one advisory hitting two installed versions is one vulnerability, as `bun audit` counts it.
test.concurrent("one advisory across two fixable versions counts once", async () => {
await using server = startRegistry({ "no-deps": [adv("<1.1.0")] });
const workspace = (name: string, range: string) => JSON.stringify({ name, dependencies: { "no-deps": range } });
const root = { name: "root", workspaces: ["packages/*"] };
using dir = await setup(server, root, {
"packages/a/package.json": workspace("a", "1.0.0"),
"packages/b/package.json": workspace("b", "1.0.1"),
});
await write(join(dir, "packages", "a", "package.json"), workspace("a", "1.0.0 || >=1.1.0"));
await write(join(dir, "packages", "b", "package.json"), workspace("b", "^1.0.1"));
await runBunInstall(installEnv(dir), dir);
let lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain("fixing:\n ^ no-deps 1.0.0 -> 1.1.0\n ^ no-deps 1.0.1 -> 1.1.0\n");
expect(stdout).toContain("Fixed 1 vulnerability in 1 package");
expect(stdout).not.toContain("remaining");
expect(exitCode).toBe(0);
lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
const recheck = await audit(dir);
expectClean(recheck, 1);
});
// pnpm#8943: a patch release on the current line wins over the next major that the range would also allow.
test.concurrent("prefers an in-line patch over a major that the range also allows", async () => {
await using server = startRegistry({ "no-deps": [adv("<1.0.1")] });
using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "1.0.0" } });
await reinstall(dir, { name: "foo", dependencies: { "no-deps": ">=1.0.0" } });
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain(" ^ no-deps 1.0.0 -> 1.0.1");
expect(exitCode).toBe(0);
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
});
// pnpm#12651 / #13824: an advisory with no released fix must not invent a version or leave bun.lock unusable.
test.concurrent("an advisory covering the newest release leaves a lockfile that still installs frozen", async () => {
await using server = startRegistry({ "a-dep": [adv("<=1.0.10")] });
using dir = await setup(server, { name: "foo", dependencies: { "a-dep": "^1.0.0" } });
const lockBefore = await lock(dir);
expect(lockBefore).toContain('"[email protected]"');
const { stdout, stderr, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`
"bun audit fix <version> (<revision>)
no published version fixes:
[email protected] 1
bun audit fix --ignore 1
Fixed 0 of 1 vulnerability (checked 1)
1 vulnerability remaining"
`);
expect(stderr).not.toContain("Saved lockfile");
expect(exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
// pnpm#11101: a workspace package sharing a name with an advised npm package is not audited.
test.concurrent("a workspace package is never matched against an advisory for its name", async () => {
await using server = startRegistry({ "no-deps": [adv("<1.0.1")] });
using dir = await setup(
server,
{ name: "root", workspaces: ["packages/*"] },
{ "packages/no-deps/package.json": JSON.stringify({ name: "no-deps", version: "1.0.0" }) },
);
const lockBefore = await lock(dir);
expect(lockBefore).toContain('"no-deps@workspace:packages/no-deps"');
const { stdout, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toBe(FIX_HEADER + noVulnerabilities(0));
expect(exitCode).toBe(0);
expect(await lock(dir)).toBe(lockBefore);
});
// pnpm#10486 / #12487: a package kept alive only by a peer edge is still upgraded.
test.concurrent("fixes a package reachable only through a peer dependency edge", async () => {
await using server = startRegistry({ "no-deps": [adv("<1.0.1")] });
using dir = await setup(server, { name: "foo", dependencies: { "peer-deps-fixed": "1.0.0", "no-deps": "1.0.0" } });
await reinstall(dir, { name: "foo", dependencies: { "peer-deps-fixed": "1.0.0" } });
let lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain(" ^ no-deps 1.0.0 -> 1.0.1");
expect(exitCode).toBe(0);
lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent("honours catalog ranges", async () => {
await using server = startRegistry({ "no-deps": [adv("<1.0.1")] });
using dir = await setup(
server,
{ name: "root", workspaces: ["packages/*"], catalog: { "no-deps": "1.0.0" } },
{ "packages/a/package.json": JSON.stringify({ name: "a", dependencies: { "no-deps": "catalog:" } }) },
);
await reinstall(dir, { name: "root", workspaces: ["packages/*"], catalog: { "no-deps": "^1.0.0" } });
let lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
const rootBefore = await pkgJsonText(dir);
const memberBefore = await pkgJsonText(dir, "packages", "a");
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain(" ^ no-deps 1.0.0 -> 1.0.1");
expect(stdout).not.toContain("package.json");
expect(exitCode).toBe(0);
lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
expect(await pkgJsonText(dir)).toBe(rootBefore);
expect(await pkgJsonText(dir, "packages", "a")).toBe(memberBefore);
});
test.concurrent("--ignore and --audit-level filter what gets fixed", async () => {
await using server = startRegistry({ "a-dep": [{ ...adv("<1.0.4", 7), severity: "low" }] });
using dir = await setupVulnerableADep(server);
const lockBefore = await lock(dir);
const ignored = await auditFix(dir, "--ignore", "7");
expect(normalizeBunSnapshot(ignored.stdout)).toBe(FIX_HEADER + noVulnerabilities(1, "1 ignored"));
expect(ignored.exitCode).toBe(0);
expect(await lock(dir)).toBe(lockBefore);
const belowLevel = await auditFix(dir, "--audit-level", "high");
expect(normalizeBunSnapshot(belowLevel.stdout)).toBe(
FIX_HEADER + noVulnerabilities(1, "1 below --audit-level=high"),
);
expect(belowLevel.exitCode).toBe(0);
expect(await lock(dir)).toBe(lockBefore);
const fixed = await auditFix(dir);
expect(fixed.stdout).toContain("Fixed 1 vulnerability in 1 package");
expect(fixed.exitCode).toBe(0);
expect(await lock(dir)).toContain('"[email protected]"');
});
test.concurrent("rejects extra arguments", async () => {
await using server = startRegistry({});
using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "1.0.0" } });
const lockBefore = await lock(dir);
const extra = await auditFix(dir, "extra");
expect(normalizeBunSnapshot(extra.stderr)).toMatchInlineSnapshot(`
"error: bun audit fix does not take arguments, it always fixes the whole lockfile
note: run 'bun audit --help' for more information"
`);
expect(extra.stdout).toBe("");
expect(extra.exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("an unknown subcommand is rejected before the registry is contacted", async () => {
const bulkHits = { count: 0 };
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] }, { bulkHits });
using dir = await setupVulnerableADep(server);
const lockBefore = await lock(dir);
const { stdout, stderr, exitCode } = await audit(dir, "fixx");
expect(stderr).toContain('error: unknown subcommand "fixx"');
expect(stderr).toContain("did you mean");
expect(stdout).toBe("");
expect(exitCode).toBe(1);
expect(bulkHits.count).toBe(0);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("refuses to run against a frozen lockfile", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setupVulnerableADep(server);
const lockBefore = await lock(dir);
for (const flag of ["--frozen-lockfile", "--production"]) {
const { stderr, exitCode } = await auditFix(dir, flag);
expect(stderr).toContain("error: bun audit fix needs to write bun.lock, but the lockfile is frozen");
expect(stderr).toContain(FROZEN_NOTE);
expect(stderr).not.toContain(NO_SAVE_NOTE);
expect(exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
}
const dryRun = await auditFix(dir, "--frozen-lockfile", "--dry-run");
expect(dryRun.stdout).toContain("Would fix 1 vulnerability in 1 package");
expect(dryRun.exitCode).toBe(0);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("an optional peer edge does not keep the vulnerable version alive", async () => {
await using server = startRegistry({ "no-deps": [adv("<1.0.1")] });
using dir = await setup(server, {
name: "foo",
dependencies: { "optional-peer-deps": "1.0.0", "no-deps": "1.0.0" },
});
await reinstall(dir, { name: "foo", dependencies: { "optional-peer-deps": "1.0.0", "no-deps": "^1.0.0" } });
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain(" ^ no-deps 1.0.0 -> 1.0.1");
expect(exitCode).toBe(0);
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
const recheck = await audit(dir);
expectClean(recheck, 2);
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
// The peer holder hoists before the dependent, so a slot still bound to the old version takes the root folder.
test.concurrent(
"an optional peer edge hoisted before the dependent does not keep the vulnerable version",
async () => {
await using server = startRegistry({ "no-deps": [adv("<1.0.1")] });
using dir = await setup(server, {
name: "foo",
dependencies: { "one-optional-peer-dep": "1.0.2", "one-range-dep": "1.0.0", "no-deps": "1.0.0" },
});
await reinstall(dir, {
name: "foo",
dependencies: { "one-optional-peer-dep": "1.0.2", "one-range-dep": "1.0.0" },
});
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain(" ^ no-deps 1.0.0 -> 1.0.1");
expect(stdout).toContain("Fixed 1 vulnerability in 1 package");
expect(exitCode).toBe(0);
const lockfile = await lock(dir);
expect(lockfile).toContain('"no-deps": ["[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
expect(lockfile).not.toContain('"one-range-dep/no-deps"');
expect(await installedVersion(dir, "no-deps")).toBe("1.0.1");
const recheck = await audit(dir);
expectClean(recheck, 3);
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
},
);
test.concurrent("an advisory for an installed prerelease is matched and the pin is rewritten", async () => {
const bulkHits = { count: 0 };
await using server = startRegistry(
{},
{ bulkResponses: [{ "no-deps-backward-tags": [adv("<1.1.0")] }, {}], bulkHits },
);
using dir = await setup(server, { name: "foo", dependencies: { "no-deps-backward-tags": "1.0.0-rc.1" } });
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain(" ^ no-deps-backward-tags 1.0.0-rc.1 -> 1.1.0");
expect(stdout).toContain("package.json: 1.0.0-rc.1 -> 1.1.0");
expect(stdout).toContain("Fixed 1 vulnerability in 1 package");
expect(exitCode).toBe(0);
expect(bulkHits.count).toBe(2);
expect((await pkgJson(dir)).dependencies["no-deps-backward-tags"]).toBe("1.1.0");
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain("1.0.0-rc.1");
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent("advisories that match no installed version are listed, not just counted", async () => {
await using server = startRegistry(
{},
{ bulkResponse: { "no-deps": [adv(">=5.0.0"), adv(">=5.0.0", 2), adv("not a range", 3)] } },
);
using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "1.0.0" } });
const lockBefore = await lock(dir);
const { stdout, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`
"bun audit fix <version> (<revision>)
not matched to an installed version:
no-deps@>=5.0.0
no-deps@not a range
Fixed 0 of 3 vulnerabilities (checked 1)
3 vulnerabilities remaining"
`);
expect(exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("an unparsable advisory does not hide a real fix for the same package", async () => {
const bulkHits = { count: 0 };
await using server = startRegistry(
{},
{
bulkResponses: [{ "a-dep": [adv("<1.0.4"), adv("not a range", 2)] }, { "a-dep": [adv("not a range", 2)] }],
bulkHits,
},
);
using dir = await setupVulnerableADep(server);
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain(" ^ a-dep 1.0.2 -> 1.0.4");
expect(stdout).toContain("a-dep@not a range");
expect(stdout).toContain("Fixed 1 vulnerability in 1 package");
expect(stdout).toContain("1 vulnerability remaining");
expect(exitCode).toBe(1);
expect(bulkHits.count).toBe(2);
expect(await lock(dir)).toContain('"[email protected]"');
});
test.concurrent("a bundled dependency is never claimed as fixed", async () => {
await using server = startRegistry({ "no-deps": [adv("<1.0.1")] });
using dir = await setup(server, { name: "foo", dependencies: { "bundled-1": "1.0.0" } });
const lockBefore = await lock(dir);
expect(lockBefore).toContain('"[email protected]"');
const { stdout, stderr, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`
"bun audit fix <version> (<revision>)
blocked by a dependent's range:
^ no-deps 1.0.0 -> 1.0.1
[email protected] bundles [email protected]
Fixed 0 of 1 vulnerability (checked 2)
1 vulnerability remaining"
`);
expect(stderr).not.toContain("Saved lockfile");
expect(exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
expect(await installedVersion(dir, "bundled-1", "node_modules", "no-deps")).toBe("1.0.0");
});
test.concurrent("multiple advisories on one instance are cleared together", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4", 1), adv("<1.0.6", 2)] });
using dir = await setupVulnerableADep(server);
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain(" ^ a-dep 1.0.2 -> 1.0.6");
expect(stdout).toContain("Fixed 2 vulnerabilities in 1 package");
expect(stdout).not.toContain("remaining");
expect(exitCode).toBe(0);
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
const recheck = await audit(dir);
expectClean(recheck, 1);
});
// pnpm fixtures/update-multiple: two advisories for one name with disjoint ranges.
test.concurrent("disjoint advisory ranges for one package are all avoided", async () => {
const bulkBodies: Record<string, string[]>[] = [];
await using server = startRegistry(
{ "no-deps": [adv(">=1.0.0 <1.0.1", 1), adv(">=1.1.0 <2.0.0", 2)] },
{ bulkBodies },
);
using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "1.0.0" } });
await reinstall(dir, { name: "foo", dependencies: { "no-deps": ">=1.0.0" } });
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain(" ^ no-deps 1.0.0 -> 1.0.1");
expect(stdout).toContain("Fixed 1 vulnerability in 1 package");
expect(stdout).not.toContain("remaining");
expect(exitCode).toBe(0);
expect(bulkBodies).toStrictEqual([{ "no-deps": ["1.0.0"] }, { "no-deps": ["1.0.1"] }]);
expect(await lock(dir)).toContain('"[email protected]"');
const recheck = await audit(dir);
expect(recheck.exitCode).toBe(0);
});
test.concurrent("an advisory that only covers the version the fix moves to is reported by the re-audit", async () => {
const bulkBodies: Record<string, string[]>[] = [];
await using server = startRegistry(
{ "no-deps": [adv(">=1.0.0 <1.0.1", 1), adv(">=1.0.1 <2.0.0", 2)] },
{ bulkBodies },
);
using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "1.0.0" } });
await reinstall(dir, { name: "foo", dependencies: { "no-deps": ">=1.0.0" } });
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`
"bun audit fix <version> (<revision>)
fixing:
^ no-deps 1.0.0 -> 1.0.1
vulnerable after install:
[email protected] 2
Fixed 1 vulnerability in 1 package (checked 1)
1 vulnerability remaining"
`);
expect(exitCode).toBe(1);
expect(bulkBodies).toStrictEqual([{ "no-deps": ["1.0.0"] }, { "no-deps": ["1.0.1"] }]);
expect(await lock(dir)).toContain('"[email protected]"');
const recheck = await audit(dir);
expect(recheck.stdout).toContain("no-deps");
expect(recheck.exitCode).toBe(1);
});
test.concurrent("a registry failure on the re-audit fails the run after the fix was installed", async () => {
const bulkHits = { count: 0 };
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] }, { bulkFailAfter: 1, bulkHits });
using dir = await setupVulnerableADep(server);
const { stdout, stderr, exitCode } = await auditFix(dir);
expect(stdout).toContain(" ^ a-dep 1.0.2 -> 1.0.4");
expect(stdout).not.toContain("Fixed");
expect(normalizeBunSnapshot(stderr).split("\n")).toContain(
`error: POST ${registryHref(server)}/-/npm/v1/security/advisories/bulk - 503`,
);
expect(exitCode).toBe(1);
expect(bulkHits.count).toBe(2);
expect(await lock(dir)).toContain('"[email protected]"');
expect(await installedVersion(dir, "a-dep")).toBe("1.0.4");
});
// npm-style advisory objects carry extra fields (findings, patched_versions, ...) that must be ignored.
test.concurrent("ignores unknown advisory fields and treats >=0.0.0 as unfixable", async () => {
await using server = startRegistry(
{},
{
bulkResponse: {
"no-deps": [
{
...adv(">=0.0.0", 1234),
findings: [{ version: "1.0.0", paths: ["no-deps"] }],
patched_versions: "<0.0.0",
recommendation: "None",
cwe: ["CWE-1"],
cvss: { score: 0, vectorString: null },
},
],
},
},
);
using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "^1.0.0" } });
const lockBefore = await lock(dir);
const { stdout, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`
"bun audit fix <version> (<revision>)
no published version fixes:
[email protected] 1234
bun audit fix --ignore 1234
Fixed 0 of 1 vulnerability (checked 1)
1 vulnerability remaining"
`);
expect(exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("parses a large bulk response and only plans installed packages", async () => {
const bulkResponse = await file(
join(import.meta.dirname, "registry", "fixtures", "audit", "pnpm-all-vulnerabilities-response.json"),
).json();
await using server = startRegistry({}, { bulkResponse });
using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "1.0.0" } });
const lockBefore = await lock(dir);
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).not.toContain("fixing:");
expect(stdout).not.toContain("no-deps");
expect(stdout).toContain("not matched to an installed version:");
expect(stdout).toContain(" axios@<0.21.2\n");
expect(stdout).toContain(" semver@>=2.0.0-alpha <5.7.2\n");
expect(stdout).toContain("111 vulnerabilities remaining");
expect(exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("fixes two packages in one run and leaves the rest of the lockfile alone", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")], "no-deps": [adv("<1.0.1", 2)] });
using dir = await setup(server, {
name: "foo",
dependencies: { "a-dep": "1.0.2", "one-range-dep": "1.0.0", "no-deps": "1.0.0", "@types/is-number": "1.0.0" },
});
await reinstall(dir, {
name: "foo",
dependencies: { "a-dep": "^1.0.2", "one-range-dep": "1.0.0", "@types/is-number": "1.0.0" },
});
const lockBefore = await lock(dir);
expect(lockBefore).toContain('"[email protected]"');
expect(lockBefore).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain("fixing:\n ^ a-dep 1.0.2 -> 1.0.4\n ^ no-deps 1.0.0 -> 1.0.1\n");
expect(stdout).toContain("Fixed 2 vulnerabilities in 2 packages");
expect(exitCode).toBe(0);
const lockAfter = await lock(dir);
const packageRows = lockBefore.split("\n").filter(line => /^ "[^"]+": \["/.test(line));
const untouched = packageRows.filter(line => !line.includes('"a-dep@') && !line.includes('"no-deps@'));
expect(untouched.map(line => line.split('"')[1]).sort()).toStrictEqual(["@types/is-number", "one-range-dep"]);
for (const line of untouched) expect(lockAfter).toContain(line);
expect(lockAfter).toContain('"[email protected]"');
expect(lockAfter).toContain('"[email protected]"');
});
test.concurrent("fixes a scoped package", async () => {
await using server = startRegistry({ "@types/is-number": [adv("<2.0.0")] });
using dir = await setup(server, { name: "foo", dependencies: { "@types/is-number": "1.0.0" } });
await reinstall(dir, { name: "foo", dependencies: { "@types/is-number": ">=1.0.0" } });
expect(await lock(dir)).toContain('"@types/[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain(" ^ @types/is-number 1.0.0 -> 2.0.0");
expect(exitCode).toBe(0);
const lockfile = await lock(dir);
expect(lockfile).toContain('"@types/[email protected]"');
expect(lockfile).not.toContain('"@types/[email protected]"');
expect(await installedVersion(dir, "@types", "is-number")).toBe("2.0.0");
});
test.concurrent("fixes an npm: alias pointing at a vulnerable package", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setup(server, { name: "foo", dependencies: { nd: "npm:[email protected]" } });
await reinstall(dir, { name: "foo", dependencies: { nd: "npm:a-dep@^1.0.2" } });
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain(" ^ a-dep 1.0.2 -> 1.0.4");
expect(exitCode).toBe(0);
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
expect(await installedVersion(dir, "nd")).toBe("1.0.4");
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent("a version held by an overrides entry is blocked and the override is not rewritten", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setup(server, {
name: "foo",
dependencies: { "a-dep": "^1.0.2" },
overrides: { "a-dep": "1.0.2" },
});
const lockBefore = await lock(dir);
expect(lockBefore).toContain('"[email protected]"');
const pkgJsonBefore = await pkgJsonText(dir);
const { stdout, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`
"bun audit fix <version> (<revision>)
blocked by a dependent's range:
^ a-dep 1.0.2 -> 1.0.4
package.json depends on [email protected]
Fixed 0 of 1 vulnerability (checked 1)
1 vulnerability remaining"
`);
expect(exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
expect(await pkgJsonText(dir)).toBe(pkgJsonBefore);
});
test.concurrent("a pinned catalog entry is rewritten and the member keeps `catalog:`", async () => {
await using server = startRegistry({ "no-deps": [adv("<1.0.1")] });
const member = JSON.stringify({ name: "a", dependencies: { "no-deps": "catalog:" } });
using dir = await setup(
server,
{ name: "root", workspaces: ["packages/*"], catalog: { "no-deps": "1.0.0" } },
{ "packages/a/package.json": member },
);
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain(" ^ no-deps 1.0.0 -> 1.0.1");
expect(stdout).toContain("package.json (catalog): 1.0.0 -> 1.0.1");
expect(stdout).toContain("Fixed 1 vulnerability in 1 package");
expect(exitCode).toBe(0);
expect((await pkgJson(dir)).catalog).toStrictEqual({ "no-deps": "1.0.1" });
expect(await pkgJsonText(dir, "packages", "a")).toBe(member);
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
expect(lockfile).toContain('"no-deps": "1.0.1"');
expect(lockfile).toContain('"no-deps": "catalog:"');
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent("rewrites a named catalog entry and leaves unused catalogs alone", async () => {
await using server = startRegistry({ "no-deps": [adv("<1.0.1")] });
const member = JSON.stringify({ name: "a", dependencies: { "no-deps": "catalog:build" } });
using dir = await setup(
server,
{
name: "root",
workspaces: ["packages/*"],
catalogs: { build: { "no-deps": "1.0.0" }, other: { "no-deps": "1.0.0" } },
},
{ "packages/a/package.json": member },
);
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain(" ^ no-deps 1.0.0 -> 1.0.1");
expect(stdout).toContain("package.json (catalog build): 1.0.0 -> 1.0.1");
expect(exitCode).toBe(0);
expect((await pkgJson(dir)).catalogs).toStrictEqual({
build: { "no-deps": "1.0.1" },
other: { "no-deps": "1.0.0" },
});
expect(await pkgJsonText(dir, "packages", "a")).toBe(member);
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
// mismatched-peer-deps-lvl1's own dependency declares a peer the install warns about, so runBunInstall cannot be used.
test.concurrent("a peer edge that rejects the fix is split off and labelled with the peer dependent", async () => {
await using server = startRegistry({ "no-deps": [adv("<=1.0.1")] });
const rootPkgJson = (noDeps: string) =>
JSON.stringify({ name: "foo", dependencies: { "mismatched-peer-deps-lvl1": "1.0.0", "no-deps": noDeps } });
using dir = tempDir("audit-fix-", { "package.json": rootPkgJson("1.0.1") });
await writeBunfig(dir, server);
await expectInstall(dir);
await write(join(dir, "package.json"), rootPkgJson("^1.0.0"));
await expectInstall(dir);
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain("fixing:\n ^ no-deps 1.0.1 -> 1.1.0");
expect(stdout).toContain("blocked by a dependent's range:");
expect(stdout).toContain("[email protected] depends on no-deps@<=1.0.1");
expect(stdout).not.toContain("package.json depends on");
expect(stdout).toContain("1 vulnerability remaining");
expect(exitCode).toBe(1);
expect(await lock(dir)).toContain('"[email protected]"');
});
test.concurrent("a depth-3 blocker names the immediate dependent", async () => {
await using server = startRegistry({ "no-deps": [adv("<1.1.0")] });
using dir = await setup(server, { name: "foo", dependencies: { "one-one-dep": "1.0.0" } });
const lockBefore = await lock(dir);
expect(lockBefore).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`
"bun audit fix <version> (<revision>)
blocked by a dependent's range:
^ no-deps 1.0.1 -> 1.1.0
[email protected] depends on [email protected]
Fixed 0 of 1 vulnerability (checked 3)
1 vulnerability remaining"
`);
expect(exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
});
// Workspaces default to the isolated linker, so the linker is pinned to keep node_modules paths predictable.
test.concurrent("fixes a workspace member's dependency when run from the member directory", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
const member = (range: string) => JSON.stringify({ name: "a", dependencies: { "a-dep": range } });
const rootPkgJson = JSON.stringify({ name: "root", workspaces: ["packages/*"] });
using dir = tempDir("audit-fix-", { "package.json": rootPkgJson, "packages/a/package.json": member("1.0.2") });
await writeBunfig(dir, server);
await expectInstall(dir, "--linker", "hoisted");
await write(join(dir, "packages", "a", "package.json"), member("^1.0.2"));
await expectInstall(dir, "--linker", "hoisted");
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await run(join(dir, "packages", "a"), ["audit", "fix", "--linker", "hoisted"], dir);
expect(stdout).toContain(" ^ a-dep 1.0.2 -> 1.0.4");
expect(exitCode).toBe(0);
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
expect(await exists(join(dir, "packages", "a", "bun.lock"))).toBeFalse();
expect(await file(join(dir, "package.json")).text()).toBe(rootPkgJson);
expect(await file(join(dir, "packages", "a", "package.json")).text()).toBe(member("^1.0.2"));
expect(await installedVersion(dir, "a-dep")).toBe("1.0.4");
const frozen = await run(dir, ["install", "--frozen-lockfile", "--linker", "hoisted"]);
expect(frozen.stderr).not.toContain("error:");
expect(frozen.exitCode).toBe(0);
});
test.concurrent("isolated linker layout", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = tempDir("audit-fix-", {
"package.json": JSON.stringify({ name: "foo", dependencies: { "a-dep": "1.0.2" } }),
});
await writeBunfig(dir, server);
await expectInstall(dir, "--linker", "isolated");
await write(join(dir, "package.json"), JSON.stringify({ name: "foo", dependencies: { "a-dep": "^1.0.2" } }));
await expectInstall(dir, "--linker", "isolated");
expect(await lock(dir)).toContain('"[email protected]"');
expect(await readlink(join(dir, "node_modules", "a-dep"))).toContain("[email protected]");
const { stdout, exitCode } = await auditFix(dir, "--linker", "isolated");
expect(stdout).toContain("Fixed 1 vulnerability in 1 package");
expect(exitCode).toBe(0);
expect(await lock(dir)).toContain('"[email protected]"');
expect(await readlink(join(dir, "node_modules", "a-dep"))).toContain("[email protected]");
expect(await installedVersion(dir, "a-dep")).toBe("1.0.4");
const frozen = await run(dir, ["install", "--frozen-lockfile", "--linker", "isolated"]);
expect(frozen.stderr).not.toContain("error:");
expect(frozen.exitCode).toBe(0);
});
// Every a-dep release was published in 2023, so a 100-year minimum age gates all of them.
test.concurrent("a fix newer than --minimum-release-age is installed anyway", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setupVulnerableADep(server);
const lockBefore = await lock(dir);
const dryRun = await auditFix(dir, "--dry-run", "--minimum-release-age", "3153600000");
expect(dryRun.stdout).toContain(" ^ a-dep 1.0.2 -> 1.0.4 (newer than --minimum-release-age)");
expect(dryRun.stdout).toContain("Would fix 1 vulnerability in 1 package");
expect(dryRun.exitCode).toBe(0);
expect(await lock(dir)).toBe(lockBefore);
const { stdout, stderr, exitCode } = await auditFix(dir, "--minimum-release-age", "3153600000");
expect(stdout).toContain(" ^ a-dep 1.0.2 -> 1.0.4 (newer than --minimum-release-age)");
expect(stdout).toContain("Fixed 1 vulnerability in 1 package");
expect(stderr).not.toContain("error:");
expect(exitCode).toBe(0);
expect(await lock(dir)).toContain('"[email protected]"');
expect(await installedVersion(dir, "a-dep")).toBe("1.0.4");
await expectInstall(dir, "--frozen-lockfile", "--minimum-release-age", "3153600000");
});
test.concurrent(
"the lowest safe release is taken even when only it is newer than --minimum-release-age",
async () => {
await using server = startRegistry(
{ "a-dep": [adv("<1.0.4")] },
{ rewriteTime: { "a-dep": { "1.0.4": new Date().toISOString() } } },
);
using dir = await setupVulnerableADep(server);
const { stdout, exitCode } = await auditFix(dir, "--minimum-release-age", "86400");
expect(stdout).toContain(" ^ a-dep 1.0.2 -> 1.0.4 (newer than --minimum-release-age)");
expect(stdout).not.toContain("1.0.5");
expect(exitCode).toBe(0);
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
},
);
test.concurrent("a failing bulk endpoint changes nothing", async () => {
await using server = startRegistry({}, { bulkStatus: 500 });
using dir = await setupVulnerableADep(server);
const lockBefore = await lock(dir);
const { stdout, stderr, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toBe("bun audit fix <version> (<revision>)");
expect(normalizeBunSnapshot(stderr)).toBe(
`error: POST ${registryHref(server)}/-/npm/v1/security/advisories/bulk - 500`,
);
expect(exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("a bulk response that is not JSON is reported on stderr and changes nothing", async () => {
await using server = startRegistry({}, { bulkBody: "<html><body>registry is down</body></html>" });
using dir = await setupVulnerableADep(server);
const lockBefore = await lock(dir);
const { stdout, stderr, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stderr)).toBe(`error: ${registryHref(server)} returned a non-JSON audit response`);
expect(normalizeBunSnapshot(stdout)).toBe("bun audit fix <version> (<revision>)");
expect(exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("a manifest that fails to download is reported, not fixed", async () => {
const denyManifests = new Set<string>();
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] }, { denyManifests });
using dir = await setupVulnerableADep(server);
const lockBefore = await lock(dir);
denyManifests.add("a-dep");
const { stdout, stderr, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`
"bun audit fix <version> (<revision>)
Fixed 0 of 1 vulnerability (checked 1)
1 vulnerability remaining"
`);
expect(normalizeBunSnapshot(stderr)).toBe(
`warn: [email protected] was not checked for updates: GET ${registryHref(server)}/a-dep - 404`,
);
expect(exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("a manifest that fails to download does not stop the other fixes", async () => {
const denyManifests = new Set<string>();
await using server = startRegistry({ "a-dep": [adv("<1.0.4")], "no-deps": [adv("<1.0.1", 2)] }, { denyManifests });
using dir = await setup(server, { name: "foo", dependencies: { "a-dep": "1.0.2", "no-deps": "1.0.0" } });
await reinstall(dir, { name: "foo", dependencies: { "a-dep": "^1.0.2", "no-deps": "^1.0.0" } });
denyManifests.add("a-dep");
const { stdout, stderr, exitCode } = await auditFix(dir);
expect(stdout).toContain("fixing:\n ^ no-deps 1.0.0 -> 1.0.1\n");
expect(stdout).not.toContain("a-dep");
expect(stdout).not.toContain("no published version fixes:");
expect(stdout).toContain("Fixed 1 vulnerability in 1 package (checked 2)");
expect(stdout).toContain("1 vulnerability remaining");
expect(stdout).not.toContain("--ignore");
expect(normalizeBunSnapshot(stderr).split("\n")).toContain(
`warn: [email protected] was not checked for updates: GET ${registryHref(server)}/a-dep - 404`,
);
expect(stderr).not.toContain("Resolving...");
expect(exitCode).toBe(1);
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).toContain('"[email protected]"');
});
test.concurrent("a fix whose tarball fails to download is not reported as fixed", async () => {
const denyTarballs = new Set<string>();
const bulkHits = { count: 0 };
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] }, { denyTarballs, bulkHits });
using dir = await setupVulnerableADep(server);
denyTarballs.add("a-dep-1.0.4.tgz");
const { stdout, stderr, exitCode } = await auditFix(dir);
expect(stdout).toContain(" ^ a-dep 1.0.2 -> 1.0.4");
expect(stdout).not.toContain("Fixed 1 vulnerability");
expect(stderr).toContain("a-dep");
expect(stderr).toContain("1.0.4");
expect(exitCode).toBe(1);
expect(bulkHits.count).toBe(1);
expect(await installedVersion(dir, "a-dep")).toBe("1.0.2");
});
test.concurrent("refuses to run without a lockfile", async () => {
await using server = startRegistry({});
using dir = tempDir("audit-fix-", {
"package.json": JSON.stringify({ name: "foo" }),
"bunfig.toml": Bun.TOML.stringify({ install: { registry: server.url.href } }),
});
const { stdout, stderr, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stderr)).toBe(MISSING_LOCKFILE);
expect(normalizeBunSnapshot(stdout)).toBe("bun audit fix <version> (<revision>)");
expect(exitCode).toBe(1);
expect(await exists(join(dir, "bun.lock"))).toBeFalse();
});
test.concurrent("refuses --no-save before contacting the registry", async () => {
await using server = startRegistry({}, { bulkStatus: 500 });
using dir = await setupVulnerableADep(server);
const lockBefore = await lock(dir);
const { stdout, stderr, exitCode } = await auditFix(dir, "--no-save");
expect(stderr).toContain("error: bun audit fix needs to write bun.lock, but saving the lockfile is disabled");
expect(stderr).toContain(NO_SAVE_NOTE);
expect(stderr).not.toContain(FROZEN_NOTE);
expect(stderr).not.toContain("advisories/bulk");
expect(stdout).not.toContain("Fixed");
expect(exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("rewrites an exact direct pin", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
const before = [
"{",
' "name": "foo",',
' "scripts": {',
' "check": "true"',
" },",
' "dependencies": {',
' "a-dep": "1.0.2"',
" }",
"}",
"",
].join("\n");
using dir = await setup(server, before);
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toStartWith(
FIX_HEADER + "fixing:\n ^ a-dep 1.0.2 -> 1.0.4\n package.json: 1.0.2 -> 1.0.4",
);
expect(stdout).toContain("Fixed 1 vulnerability in 1 package");
expect(exitCode).toBe(0);
expect(await pkgJsonText(dir)).toBe(before.replace('"a-dep": "1.0.2"', '"a-dep": "1.0.4"'));
const lockfile = await lock(dir);
expect(lockfile).toContain('"a-dep": "1.0.4"');
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain("[email protected]");
expect(await installedVersion(dir, "a-dep")).toBe("1.0.4");
const recheck = await audit(dir);
expectClean(recheck, 1);
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent("--dry-run does not rewrite a pin", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setup(server, { name: "foo", dependencies: { "a-dep": "1.0.2" } });
const pkgJsonBefore = await pkgJsonText(dir);
const lockBefore = await lock(dir);
const { stdout, exitCode } = await auditFix(dir, "--dry-run");
expect(stdout).toContain(" ^ a-dep 1.0.2 -> 1.0.4");
expect(stdout).toContain("package.json: 1.0.2 -> 1.0.4");
expect(stdout).toContain("Would fix 1 vulnerability in 1 package");
expect(exitCode).toBe(0);
expect(await pkgJsonText(dir)).toBe(pkgJsonBefore);
expect(await lock(dir)).toBe(lockBefore);
expect(await installedVersion(dir, "a-dep")).toBe("1.0.2");
});
test.concurrent("a pin is only widened within its major", async () => {
await using server = startRegistry({ "no-deps": [adv("<2.0.0")] });
using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "1.0.0" } });
const pkgJsonBefore = await pkgJsonText(dir);
const lockBefore = await lock(dir);
const { stdout, stderr, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`
"bun audit fix <version> (<revision>)
blocked by a dependent's range:
^ no-deps 1.0.0 -> 2.0.0
package.json depends on [email protected]
bun audit fix --latest
Fixed 0 of 1 vulnerability (checked 1)
1 vulnerability remaining"
`);
expect(stderr).not.toContain("Saved lockfile");
expect(exitCode).toBe(1);
expect(await pkgJsonText(dir)).toBe(pkgJsonBefore);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("rewrites an exact npm: alias pin", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setup(server, { name: "foo", dependencies: { nd: "npm:[email protected]" } });
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain(" ^ a-dep 1.0.2 -> 1.0.4");
expect(stdout).toContain("package.json: npm:[email protected] -> npm:[email protected]");
expect(exitCode).toBe(0);
expect((await pkgJson(dir)).dependencies).toStrictEqual({ nd: "npm:[email protected]" });
expect(await installedVersion(dir, "nd")).toBe("1.0.4");
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent("rewrites a pinned devDependency in its own group only", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")], "no-deps": [adv("<1.0.1", 2)] });
using dir = await setup(server, {
name: "foo",
dependencies: { "no-deps": "1.0.0" },
devDependencies: { "a-dep": "1.0.2" },
});
await reinstall(dir, { name: "foo", dependencies: { "no-deps": "^1.0.0" }, devDependencies: { "a-dep": "1.0.2" } });
let lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain(" ^ a-dep 1.0.2 -> 1.0.4\n package.json: 1.0.2 -> 1.0.4\n");
expect(stdout).toContain(" ^ no-deps 1.0.0 -> 1.0.1\n");
expect(stdout).toContain("Fixed 2 vulnerabilities in 2 packages");
expect(exitCode).toBe(0);
const { dependencies, devDependencies } = await pkgJson(dir);
expect({ dependencies, devDependencies }).toStrictEqual({
dependencies: { "no-deps": "^1.0.0" },
devDependencies: { "a-dep": "1.0.4" },
});
lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).toContain('"[email protected]"');
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent("rewrites a workspace member's pin and leaves the root alone", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
const rootPkgJson = JSON.stringify({ name: "root", workspaces: ["packages/*"] });
using dir = await setup(server, rootPkgJson, {
"packages/a/package.json": JSON.stringify({ name: "a", dependencies: { "a-dep": "1.0.2" } }),
});
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain(" ^ a-dep 1.0.2 -> 1.0.4\n packages/a/package.json: 1.0.2 -> 1.0.4\n");
expect(exitCode).toBe(0);
expect((await pkgJson(dir, "packages", "a")).dependencies).toStrictEqual({ "a-dep": "1.0.4" });
expect(await pkgJsonText(dir)).toBe(rootPkgJson);
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).toContain('"a-dep": "1.0.4"');
expect(lockfile).not.toContain("[email protected]");
expect(await exists(join(dir, "packages", "a", "bun.lock"))).toBeFalse();
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent("rewrites a workspace member's pin when run from the member directory", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
const rootPkgJson = JSON.stringify({ name: "root", workspaces: ["packages/*"] });
using dir = tempDir("audit-fix-", {
"package.json": rootPkgJson,
"packages/a/package.json": JSON.stringify({ name: "a", dependencies: { "a-dep": "1.0.2" } }),
});
await writeBunfig(dir, server);
await expectInstall(dir, "--linker", "hoisted");
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await run(join(dir, "packages", "a"), ["audit", "fix", "--linker", "hoisted"], dir);
expect(stdout).toContain(" ^ a-dep 1.0.2 -> 1.0.4\n packages/a/package.json: 1.0.2 -> 1.0.4\n");
expect(exitCode).toBe(0);
expect((await pkgJson(dir, "packages", "a")).dependencies).toStrictEqual({ "a-dep": "1.0.4" });
expect(await pkgJsonText(dir)).toBe(rootPkgJson);
expect(await exists(join(dir, "packages", "a", "bun.lock"))).toBeFalse();
expect(await lock(dir)).toContain('"[email protected]"');
expect(await installedVersion(dir, "a-dep")).toBe("1.0.4");
await expectInstall(dir, "--frozen-lockfile", "--linker", "hoisted");
});
test.concurrent("splits an instance when only some dependents accept the fix", async () => {
await using server = startRegistry({ "no-deps": [adv("<1.0.1")] });
using dir = await setup(server, { name: "foo", dependencies: { "one-fixed-dep": "1.0.0", "no-deps": "1.0.0" } });
await reinstall(dir, { name: "foo", dependencies: { "one-fixed-dep": "1.0.0", "no-deps": "^1.0.0" } });
let lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain("fixing:\n ^ no-deps 1.0.0 -> 1.0.1\n");
expect(stdout).toContain("blocked by a dependent's range:");
expect(stdout).toContain("[email protected] depends on [email protected]");
expect(stdout).toContain("Fixed 0 vulnerabilities in 1 package");
expect(stdout).toContain("1 vulnerability remaining");
expect(stdout).not.toContain("bun audit fix --latest");
expect(exitCode).toBe(1);
lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).toContain('"[email protected]"');
expect(await installedVersion(dir, "no-deps")).toBe("1.0.1");
expect(await installedVersion(dir, "one-fixed-dep", "node_modules", "no-deps")).toBe("1.0.0");
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent("splits an instance shared by two transitive dependents", async () => {
await using server = startRegistry({ "no-deps": [adv("<1.0.1")] });
using dir = await setup(server, {
name: "foo",
dependencies: { "one-range-dep": "1.0.0", "one-fixed-dep": "1.0.0", "no-deps": "1.0.0" },
});
await reinstall(dir, { name: "foo", dependencies: { "one-range-dep": "1.0.0", "one-fixed-dep": "1.0.0" } });
const lockBefore = await lock(dir);
expect(lockBefore).toContain('"[email protected]"');
expect(lockBefore).not.toContain('"[email protected]"');
const dryRun = await auditFix(dir, "--dry-run");
expect(dryRun.stdout).toContain("fixing:\n ^ no-deps 1.0.0 -> 1.0.1\n");
expect(dryRun.stdout).toContain("[email protected] depends on [email protected]");
expect(dryRun.stdout).not.toContain("[email protected] depends on");
expect(dryRun.exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
const { exitCode } = await auditFix(dir);
expect(exitCode).toBe(1);
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).toContain('"[email protected]"');
expect(await resolvedVersion(dir, "one-range-dep", "no-deps")).toBe("1.0.1");
expect(await resolvedVersion(dir, "one-fixed-dep", "no-deps")).toBe("1.0.0");
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent("hoisted: a vulnerable nested copy collapsed onto the fixed root copy is removed", async () => {
await using server = startRegistry({ "no-deps": [adv(">=1.1.0 <2.0.0")] });
using dir = await setup(server, { name: "foo", dependencies: { "one-range-dep": "1.0.0" } });
await reinstall(dir, { name: "foo", dependencies: { "one-range-dep": "1.0.0", "no-deps": "1.0.1" } });
const lockBefore = await lock(dir);
expect(lockBefore).toContain('"[email protected]"');
expect(lockBefore).toContain('"[email protected]"');
expect(await resolvedVersion(dir, "one-range-dep", "no-deps")).toBe("1.1.0");
expect(await installedVersion(dir, "no-deps")).toBe("1.0.1");
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain(" v no-deps 1.1.0 -> 1.0.1 (downgrade)");
expect(stdout).toContain("Fixed 1 vulnerability in 1 package");
expect(exitCode).toBe(0);
const lockfile = await lock(dir);
expect(lockfile).not.toContain('"[email protected]"');
expect(lockfile).not.toContain('"one-range-dep/no-deps"');
expect(await exists(join(dir, "node_modules", "one-range-dep", "node_modules", "no-deps"))).toBe(false);
expect(await resolvedVersion(dir, "one-range-dep", "no-deps")).toBe("1.0.1");
expect(await installedVersion(dir, "no-deps")).toBe("1.0.1");
const recheck = await audit(dir);
expectClean(recheck, 2);
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent("a split instance under the isolated linker keeps both versions in the store", async () => {
await using server = startRegistry({ "no-deps": [adv("<1.0.1")] });
const rootPkgJson = (deps: Record<string, string>) => JSON.stringify({ name: "foo", dependencies: deps });
using dir = tempDir("audit-fix-", {
"package.json": rootPkgJson({ "one-range-dep": "1.0.0", "one-fixed-dep": "1.0.0", "no-deps": "1.0.0" }),
});
await writeBunfig(dir, server);
await expectInstall(dir, "--linker", "isolated");
await write(join(dir, "package.json"), rootPkgJson({ "one-range-dep": "1.0.0", "one-fixed-dep": "1.0.0" }));
await expectInstall(dir, "--linker", "isolated");
const lockBefore = await lock(dir);
expect(lockBefore).toContain('"[email protected]"');
expect(lockBefore).not.toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir, "--linker", "isolated");
expect(stdout).toContain("fixing:\n ^ no-deps 1.0.0 -> 1.0.1\n");
expect(exitCode).toBe(1);
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).toContain('"[email protected]"');
expect(await installedVersion(dir, ".bun", "[email protected]", "node_modules", "no-deps")).toBe("1.0.1");
expect(await installedVersion(dir, ".bun", "[email protected]", "node_modules", "no-deps")).toBe("1.0.0");
await expectInstall(dir, "--frozen-lockfile", "--linker", "isolated");
});
test.concurrent(
"a rewritten root pin moves even though a transitive dependent still pins the old version",
async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setup(server, { name: "foo", dependencies: { "a-dep": "1.0.2", "uses-a-dep-2": "1.0.0" } });
let lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain("fixing:\n ^ a-dep 1.0.2 -> 1.0.4\n package.json: 1.0.2 -> 1.0.4\n");
expect(stdout).toContain("[email protected] depends on [email protected]");
expect(stdout).toContain("1 vulnerability remaining");
expect(exitCode).toBe(1);
expect((await pkgJson(dir)).dependencies).toStrictEqual({ "a-dep": "1.0.4", "uses-a-dep-2": "1.0.0" });
lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).toContain('"[email protected]"');
expect(await installedVersion(dir, "a-dep")).toBe("1.0.4");
expect(await installedVersion(dir, "uses-a-dep-2", "node_modules", "a-dep")).toBe("1.0.2");
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
},
);
test.concurrent("downgrades when no newer release is safe", async () => {
await using server = startRegistry({ "a-dep": [adv(">=1.0.3")] });
using dir = await setup(server, { name: "foo", dependencies: { "a-dep": "^1.0.0" } });
const lockBefore = await lock(dir);
expect(lockBefore).toContain('"[email protected]"');
const pkgJsonBefore = await pkgJsonText(dir);
const dryRun = await auditFix(dir, "--dry-run");
expect(normalizeBunSnapshot(dryRun.stdout)).toMatchInlineSnapshot(`
"bun audit fix <version> (<revision>)
fixing:
v a-dep 1.0.10 -> 1.0.2 (downgrade)
Would fix 1 vulnerability in 1 package (checked 1)"
`);
expect(dryRun.exitCode).toBe(0);
expect(await lock(dir)).toBe(lockBefore);
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain(" v a-dep 1.0.10 -> 1.0.2 (downgrade)");
expect(stdout).toContain("Fixed 1 vulnerability in 1 package");
expect(exitCode).toBe(0);
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
expect(await installedVersion(dir, "a-dep")).toBe("1.0.2");
expect(await pkgJsonText(dir)).toBe(pkgJsonBefore);
const recheck = await audit(dir);
expectClean(recheck, 1);
});
test.concurrent("downgrades a transitive dependency within its dependent's range", async () => {
await using server = startRegistry({ "no-deps": [adv(">=1.0.1 <2.0.0")] });
using dir = await setup(server, { name: "foo", dependencies: { "one-range-dep": "1.0.0" } });
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain(" v no-deps 1.1.0 -> 1.0.0 (downgrade)");
expect(stdout).not.toContain("depends on");
expect(stdout).toContain("Fixed 1 vulnerability in 1 package");
expect(exitCode).toBe(0);
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
expect(await installedVersion(dir, "no-deps")).toBe("1.0.0");
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent("prefers the lowest safe upgrade over any downgrade", async () => {
await using server = startRegistry({ "a-dep": [adv(">=1.0.2 <1.0.4")] });
using dir = await setup(server, { name: "foo", dependencies: { "a-dep": "1.0.2" } });
await reinstall(dir, { name: "foo", dependencies: { "a-dep": "^1.0.1" } });
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain(" ^ a-dep 1.0.2 -> 1.0.4");
expect(stdout).not.toContain("downgrade");
expect(exitCode).toBe(0);
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
});
test.concurrent("counts a vulnerability removed by another fix from the written lockfile", async () => {
await using server = startRegistry({ "one-fixed-dep": [adv("<2.0.0")], "no-deps": [adv("<1.0.1", 2)] });
using dir = await setup(server, { name: "foo", dependencies: { "one-fixed-dep": "1.0.0" } });
await reinstall(dir, { name: "foo", dependencies: { "one-fixed-dep": ">=1.0.0" } });
let lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain(" ^ one-fixed-dep 1.0.0 -> 2.0.0");
expect(stdout).toContain("[email protected] depends on [email protected]");
expect(stdout).toContain("Fixed 2 vulnerabilities in 1 package");
expect(stdout).not.toContain("remaining");
expect(exitCode).toBe(0);
lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
const recheck = await audit(dir);
expectClean(recheck, 2);
});
test.concurrent("reports a vulnerable version the fix pulled in from the re-audit", async () => {
const bulkBodies: Record<string, string[]>[] = [];
await using server = startRegistry(
{ "one-fixed-dep": [adv("<2.0.0")], "no-deps": [adv(">=2.0.0", 2)] },
{ bulkBodies },
);
using dir = await setup(server, { name: "foo", dependencies: { "one-fixed-dep": "1.0.0" } });
await reinstall(dir, { name: "foo", dependencies: { "one-fixed-dep": "^1.0.0 || ^2.0.0" } });
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain(" ^ one-fixed-dep 1.0.0 -> 2.0.0");
expect(stdout).not.toContain("not matched to an installed version");
expect(stdout).toContain("\n\nvulnerable after install:\n [email protected] 2\n\n");
expect(stdout).not.toContain("+ ");
expect(stdout).not.toContain("installed");
expect(stdout).toContain("Fixed 1 vulnerability in 1 package (checked 2)");
expect(stdout).toContain("1 vulnerability remaining");
expect(exitCode).toBe(1);
expect(bulkBodies).toStrictEqual([
{ "one-fixed-dep": ["1.0.0"], "no-deps": ["1.0.0"] },
{ "one-fixed-dep": ["2.0.0"], "no-deps": ["2.0.0"] },
]);
expect(await lock(dir)).toContain('"[email protected]"');
const recheck = await audit(dir);
expect(recheck.exitCode).toBe(1);
});
test.concurrent("--json prints a plan document with --dry-run", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setup(server, { name: "foo", dependencies: { "a-dep": "1.0.2" } });
const pkgJsonBefore = await pkgJsonText(dir);
const lockBefore = await lock(dir);
const { stdout, exitCode } = await auditFix(dir, "--json", "--dry-run");
expect(JSON.parse(stdout)).toStrictEqual({
dryRun: true,
fixed: 1,
remaining: 0,
fixes: [
{
name: "a-dep",
from: "1.0.2",
to: "1.0.4",
downgrade: false,
newerThanMinimumReleaseAge: false,
packageJson: [{ file: "package.json", catalog: null, key: "a-dep", from: "1.0.2", to: "1.0.4" }],
},
],
blocked: [],
unfixable: [],
manifestUnavailable: [],
unmatched: [],
unaudited: [],
vulnerableAfterInstall: [],
});
expect(exitCode).toBe(0);
expect(await pkgJsonText(dir)).toBe(pkgJsonBefore);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("--json prints the result after installing", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setupVulnerableADep(server);
const { stdout, exitCode } = await auditFix(dir, "--json");
expect(stdout.trim().split("\n")).toHaveLength(1);
const doc = JSON.parse(stdout);
expect(doc).toMatchObject({ dryRun: false, fixed: 1, remaining: 0 });
expect(doc.fixes).toStrictEqual([
{
name: "a-dep",
from: "1.0.2",
to: "1.0.4",
downgrade: false,
newerThanMinimumReleaseAge: false,
packageJson: [],
},
]);
expect(exitCode).toBe(0);
expect(await lock(dir)).toContain('"[email protected]"');
const clean = await auditFix(dir, "--json");
expect(JSON.parse(clean.stdout)).toMatchObject({ dryRun: false, fixed: 0, remaining: 0, fixes: [] });
expect(clean.exitCode).toBe(0);
});
test.concurrent("--json takes remaining and vulnerableAfterInstall from the re-audit", async () => {
const bulkBodies: Record<string, string[]>[] = [];
await using server = startRegistry(
{ "no-deps": [adv(">=1.0.0 <1.0.1", 1), adv(">=1.0.1 <2.0.0", 2)] },
{ bulkBodies },
);
using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "1.0.0" } });
await reinstall(dir, { name: "foo", dependencies: { "no-deps": ">=1.0.0" } });
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir, "--json");
expect(JSON.parse(stdout)).toStrictEqual({
dryRun: false,
fixed: 1,
remaining: 1,
fixes: [
{
name: "no-deps",
from: "1.0.0",
to: "1.0.1",
downgrade: false,
newerThanMinimumReleaseAge: false,
packageJson: [],
},
],
blocked: [],
unfixable: [],
manifestUnavailable: [],
unmatched: [],
unaudited: [],
vulnerableAfterInstall: [{ name: "no-deps", version: "1.0.1", advisories: ["2"] }],
});
expect(exitCode).toBe(1);
expect(bulkBodies.length).toBe(2);
});
test.concurrent("--json with a blocked and an unmatched advisory", async () => {
await using server = startRegistry({}, { bulkResponse: { "no-deps": [adv("<1.1.0"), adv(">=9.0.0", 2)] } });
using dir = await setup(server, { name: "foo", dependencies: { "one-dep": "1.0.0" } });
const lockBefore = await lock(dir);
expect(lockBefore).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir, "--json");
const doc = JSON.parse(stdout);
expect(doc.blocked).toStrictEqual([
{
name: "no-deps",
from: "1.0.1",
to: "1.1.0",
downgrade: false,
latestFixes: false,
blockers: [{ dependent: "[email protected]", range: "1.0.1", bundled: false }],
},
]);
expect(doc.unmatched).toStrictEqual([{ name: "no-deps", range: ">=9.0.0" }]);
expect(doc).toMatchObject({ dryRun: false, fixed: 0, remaining: 2, fixes: [] });
expect(exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("--json after installing carries the fixed and the blocked entries", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")], "no-deps": [adv("<1.1.0", 2)] });
using dir = await setup(server, { name: "foo", dependencies: { "a-dep": "1.0.2", "one-dep": "1.0.0" } });
await reinstall(dir, { name: "foo", dependencies: { "a-dep": "^1.0.2", "one-dep": "1.0.0" } });
let lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir, "--json");
expect(JSON.parse(stdout)).toMatchObject({
dryRun: false,
fixed: 1,
remaining: 1,
fixes: [{ name: "a-dep", from: "1.0.2", to: "1.0.4" }],
blocked: [{ name: "no-deps", from: "1.0.1", to: "1.1.0", blockers: [{ dependent: "[email protected]" }] }],
vulnerableAfterInstall: [],
});
expect(exitCode).toBe(1);
lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(await installedVersion(dir, "a-dep")).toBe("1.0.4");
});
test.concurrent("audits and fixes a package served by a scoped registry", async () => {
await using scoped = startRegistry({ "@types/is-number": [adv("<2.0.0")] });
await using server = startRegistry({});
using dir = await setup(
server,
{ name: "foo", dependencies: { "@types/is-number": "1.0.0" } },
{},
{
types: scoped.url.href,
},
);
await reinstall(dir, { name: "foo", dependencies: { "@types/is-number": ">=1.0.0" } });
expect(await lock(dir)).toContain('"@types/[email protected]"');
const before = await audit(dir);
expect(before.stdout).toContain("@types/[email protected]\n");
expect(before.stderr).toBe("");
expect(before.exitCode).toBe(1);
const { stdout, stderr, exitCode } = await auditFix(dir);
expect(stdout).toContain(" ^ @types/is-number 1.0.0 -> 2.0.0");
expect(stdout).toContain("Fixed 1 vulnerability in 1 package (checked 1)");
expect(stderr).not.toContain("warn:");
expect(exitCode).toBe(0);
expect(await installedVersion(dir, "@types", "is-number")).toBe("2.0.0");
const recheck = await audit(dir);
expectClean(recheck, 1);
});
test.concurrent("a scoped registry that does not answer the audit request is reported", async () => {
await using scoped = startRegistry({}, { bulkStatus: 404 });
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setup(
server,
{ name: "foo", dependencies: { "@types/is-number": "1.0.0", "a-dep": "1.0.2" } },
{},
{
types: scoped.url.href,
},
);
const skipped = skippedWarning(registryHref(scoped), "404", "@types/is-number");
const report = await audit(dir);
expect(normalizeBunSnapshot(report.stderr)).toBe(skipped);
expect(report.stdout).not.toContain("skipped");
expect(report.stdout).toContain("\[email protected]\n");
expect(report.stdout).toContain("1 vulnerability (1 high)");
expect(report.exitCode).toBe(1);
const dryRun = await auditFix(dir, "--dry-run");
expect(normalizeBunSnapshot(dryRun.stderr)).toBe(skipped);
expect(dryRun.stdout).toContain(" ^ a-dep 1.0.2 -> 1.0.4\n");
expect(dryRun.stdout).toContain("Would fix 1 vulnerability in 1 package (checked 1");
expect(dryRun.exitCode).toBe(0);
const { stdout, stderr, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stderr).split("\n")[0]).toBe(skipped);
expect(stdout).toContain("Fixed 1 vulnerability in 1 package (checked 1");
expect(exitCode).toBe(0);
expect(await lock(dir)).toContain('"[email protected]"');
});
test.concurrent("`bun audit` and `bun audit fix` print the skipped registry at the same position", async () => {
await using scoped = startRegistry({}, { bulkStatus: 404 });
await using server = startRegistry({});
using dir = await setup(
server,
{ name: "foo", dependencies: { "@types/is-number": "1.0.0", "no-deps": "1.0.0" } },
{},
{ types: scoped.url.href },
);
const skipped = skippedWarning(registryHref(scoped), "404", "@types/is-number");
const report = await audit(dir);
expect(normalizeBunSnapshot(report.stderr)).toBe(skipped);
expect(normalizeBunSnapshot(report.stdout)).toBe(AUDIT_HEADER + noVulnerabilities(1, "1 skipped"));
expect(report.stdout).toMatch(DURATION);
expect(report.exitCode).toBe(0);
const fix = await auditFix(dir);
expect(normalizeBunSnapshot(fix.stderr)).toBe(skipped);
expect(normalizeBunSnapshot(fix.stdout)).toBe(FIX_HEADER + noVulnerabilities(1, "1 skipped"));
expect(fix.stdout).toMatch(DURATION);
expect(fix.exitCode).toBe(0);
});
test.concurrent("--json lists a scoped registry that did not answer under unaudited", async () => {
await using scoped = startRegistry({}, { bulkStatus: 404 });
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setup(
server,
{ name: "foo", dependencies: { "@types/is-number": "1.0.0", "a-dep": "1.0.2" } },
{},
{ types: scoped.url.href },
);
const { stdout, exitCode } = await auditFix(dir, "--json", "--dry-run");
expect(stdout.trim().split("\n")).toHaveLength(1);
expect(JSON.parse(stdout)).toStrictEqual({
dryRun: true,
fixed: 1,
remaining: 0,
fixes: [
{
name: "a-dep",
from: "1.0.2",
to: "1.0.4",
downgrade: false,
newerThanMinimumReleaseAge: false,
packageJson: [{ file: "package.json", catalog: null, key: "a-dep", from: "1.0.2", to: "1.0.4" }],
},
],
blocked: [],
unfixable: [],
manifestUnavailable: [],
unmatched: [],
unaudited: [{ registry: registryHref(scoped), packages: ["@types/is-number"], reason: "404" }],
vulnerableAfterInstall: [],
});
expect(exitCode).toBe(0);
});
test.concurrent("fixes advisories from the default and a scoped registry in one run", async () => {
await using scoped = startRegistry({ "@types/is-number": [adv("<2.0.0", 2)] });
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setup(
server,
{ name: "foo", dependencies: { "@types/is-number": "1.0.0", "a-dep": "1.0.2" } },
{},
{ types: scoped.url.href },
);
await reinstall(dir, { name: "foo", dependencies: { "@types/is-number": ">=1.0.0", "a-dep": "^1.0.2" } });
let lockfile = await lock(dir);
expect(lockfile).toContain('"@types/[email protected]"');
expect(lockfile).toContain('"[email protected]"');
const { stdout, stderr, exitCode } = await auditFix(dir);
expect(stdout).toContain("fixing:\n ^ @types/is-number 1.0.0 -> 2.0.0\n ^ a-dep 1.0.2 -> 1.0.4\n");
expect(stdout).toContain("Fixed 2 vulnerabilities in 2 packages (checked 2)");
expect(stderr).not.toContain("warn:");
expect(exitCode).toBe(0);
lockfile = await lock(dir);
expect(lockfile).toContain('"@types/[email protected]"');
expect(lockfile).toContain('"[email protected]"');
expect(await installedVersion(dir, "@types", "is-number")).toBe("2.0.0");
expect(await installedVersion(dir, "a-dep")).toBe("1.0.4");
const recheck = await audit(dir);
expectClean(recheck, 2);
});
test.concurrent("dependents whose lowest acceptable fixes differ are moved to one common version", async () => {
await using server = startRegistry({ "no-deps": [adv("<1.0.1")] });
using dir = await setup(server, { name: "foo", dependencies: { "one-range-dep": "1.0.0", "no-deps": "1.0.0" } });
await reinstall(dir, { name: "foo", dependencies: { "one-range-dep": "1.0.0", "no-deps": "1.0.0 || >=1.1.0" } });
let lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
const pkgJsonBefore = await pkgJsonText(dir);
const { stdout, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toStartWith(FIX_HEADER + "fixing:\n ^ no-deps 1.0.0 -> 1.1.0\n\n");
expect(stdout).not.toContain("1.0.1");
expect(stdout).toContain("Fixed 1 vulnerability in 1 package");
expect(stdout).not.toContain("remaining");
expect(exitCode).toBe(0);
lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
expect(lockfile).not.toContain('"one-range-dep/no-deps"');
expect(await installedVersion(dir, "no-deps")).toBe("1.1.0");
expect(await pkgJsonText(dir)).toBe(pkgJsonBefore);
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent("rewrites pins in the root and in a workspace member in one run", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")], "no-deps": [adv("<1.0.1", 2)] });
using dir = await setup(
server,
{ name: "root", workspaces: ["packages/*"], dependencies: { "a-dep": "1.0.2" } },
{ "packages/a/package.json": JSON.stringify({ name: "a", dependencies: { "no-deps": "1.0.0" } }) },
);
let lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toStartWith(
FIX_HEADER +
"fixing:\n ^ a-dep 1.0.2 -> 1.0.4\n package.json: 1.0.2 -> 1.0.4\n ^ no-deps 1.0.0 -> 1.0.1\n packages/a/package.json: 1.0.0 -> 1.0.1\n\n",
);
expect(stdout).toContain("Fixed 2 vulnerabilities in 2 packages");
expect(stdout).not.toContain("remaining");
expect(exitCode).toBe(0);
expect(await pkgJson(dir)).toStrictEqual({
name: "root",
workspaces: ["packages/*"],
dependencies: { "a-dep": "1.0.4" },
});
expect(await pkgJson(dir, "packages", "a")).toStrictEqual({ name: "a", dependencies: { "no-deps": "1.0.1" } });
lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain("[email protected]");
expect(lockfile).not.toContain("[email protected]");
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent("rewrites two pins in one package.json and keeps its formatting", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")], "no-deps": [adv("<1.0.1", 2)] });
const before = [
"{",
'\t"name": "foo",',
'\t"dependencies": {',
'\t\t"a-dep": "1.0.2",',
'\t\t"no-deps": "1.0.0"',
"\t},",
'\t"scripts": {',
'\t\t"check": "true"',
"\t}",
"}",
"",
].join("\n");
using dir = await setup(server, before);
const { stdout, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toStartWith(
FIX_HEADER +
"fixing:\n ^ a-dep 1.0.2 -> 1.0.4\n package.json: 1.0.2 -> 1.0.4\n ^ no-deps 1.0.0 -> 1.0.1\n package.json: 1.0.0 -> 1.0.1\n\n",
);
expect(stdout).toContain("Fixed 2 vulnerabilities in 2 packages");
expect(stdout).not.toContain("remaining");
expect(exitCode).toBe(0);
expect(await pkgJsonText(dir)).toBe(
before.replace('"a-dep": "1.0.2"', '"a-dep": "1.0.4"').replace('"no-deps": "1.0.0"', '"no-deps": "1.0.1"'),
);
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).toContain('"[email protected]"');
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent.each(["optionalDependencies", "peerDependencies"])("rewrites a pin declared in %s", async group => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setup(server, { name: "foo", [group]: { "a-dep": "1.0.2" } });
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain(" ^ a-dep 1.0.2 -> 1.0.4\n package.json: 1.0.2 -> 1.0.4\n");
expect(stdout).toContain("Fixed 1 vulnerability in 1 package");
expect(exitCode).toBe(0);
expect(await pkgJson(dir)).toStrictEqual({ name: "foo", [group]: { "a-dep": "1.0.4" } });
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain("[email protected]");
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent("only the group whose literal is the pin is rewritten", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setup(server, {
name: "foo",
dependencies: { "a-dep": "1.0.2" },
peerDependencies: { "a-dep": "^1.0.0" },
});
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(stdout).toContain(" ^ a-dep 1.0.2 -> 1.0.4\n package.json: 1.0.2 -> 1.0.4\n");
expect(stdout.split("package.json: ")).toHaveLength(2);
expect(exitCode).toBe(0);
expect(await pkgJson(dir)).toStrictEqual({
name: "foo",
dependencies: { "a-dep": "1.0.4" },
peerDependencies: { "a-dep": "^1.0.0" },
});
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain("[email protected]");
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent("the same pin in two groups of one package.json is one edit", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setup(server, {
name: "foo",
dependencies: { "a-dep": "1.0.2" },
peerDependencies: { "a-dep": "1.0.2" },
});
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toStartWith(
FIX_HEADER + "fixing:\n ^ a-dep 1.0.2 -> 1.0.4\n package.json: 1.0.2 -> 1.0.4\n\n",
);
expect(stdout).toContain("Fixed 1 vulnerability in 1 package");
expect(stdout).not.toContain("remaining");
expect(exitCode).toBe(0);
expect(await pkgJson(dir)).toStrictEqual({
name: "foo",
dependencies: { "a-dep": "1.0.4" },
peerDependencies: { "a-dep": "1.0.4" },
});
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain("[email protected]");
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent("an npm: alias pin is rewritten even when its target name has an override", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setup(server, {
name: "foo",
dependencies: { nd: "npm:[email protected]" },
overrides: { "a-dep": "1.0.2" },
});
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toStartWith(
FIX_HEADER + "fixing:\n ^ a-dep 1.0.2 -> 1.0.4\n package.json: npm:[email protected] -> npm:[email protected]\n\n",
);
expect(stdout).toContain("Fixed 1 vulnerability in 1 package");
expect(stdout).not.toContain("remaining");
expect(exitCode).toBe(0);
expect(await pkgJson(dir)).toStrictEqual({
name: "foo",
dependencies: { nd: "npm:[email protected]" },
overrides: { "a-dep": "1.0.2" },
});
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
expect(await installedVersion(dir, "nd")).toBe("1.0.4");
const recheck = await audit(dir);
expectClean(recheck, 1);
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent.each(["--dry-run", ""])(
"--omit=dev still fixes a package only devDependencies reach (%s)",
async flag => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setup(server, { name: "foo", devDependencies: { "a-dep": "1.0.2" } });
expect(await lock(dir)).toContain('"[email protected]"');
expectClean(await audit(dir, "--omit=dev"), 0);
const args = flag ? ["--omit=dev", flag] : ["--omit=dev"];
const { stdout, exitCode } = await auditFix(dir, ...args);
expect(stdout).toContain(" ^ a-dep 1.0.2 -> 1.0.4\n package.json: 1.0.2 -> 1.0.4\n");
expect(stdout).toContain(`${flag ? "Would fix" : "Fixed"} 1 vulnerability in 1 package`);
expect(exitCode).toBe(0);
const lockfile = await lock(dir);
if (flag) {
expect(lockfile).toContain('"[email protected]"');
expect((await pkgJson(dir)).devDependencies).toStrictEqual({ "a-dep": "1.0.2" });
} else {
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain("[email protected]");
expect((await pkgJson(dir)).devDependencies).toStrictEqual({ "a-dep": "1.0.4" });
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
}
},
);
test.concurrent("a dist-tag dependency blocks the fix and is reported with its literal", async () => {
await using server = startRegistry({ "dep-with-tags": [adv("<2.0.0")] });
using dir = await setup(server, { name: "foo", dependencies: { "dep-with-tags": "pre-1" } });
const lockBefore = await lock(dir);
expect(lockBefore).toContain('"[email protected]"');
const pkgJsonBefore = await pkgJsonText(dir);
const blocked = await auditFix(dir);
expect(normalizeBunSnapshot(blocked.stdout)).toMatchInlineSnapshot(`
"bun audit fix <version> (<revision>)
blocked by a dependent's range:
^ dep-with-tags 1.0.1 -> 2.0.0
package.json depends on dep-with-tags@pre-1
Fixed 0 of 1 vulnerability (checked 1)
1 vulnerability remaining"
`);
expect(blocked.stderr).toBe("");
expect(blocked.exitCode).toBe(1);
const latest = await auditFix(dir, "--latest");
expect(normalizeBunSnapshot(latest.stdout)).toBe(normalizeBunSnapshot(blocked.stdout));
expect(latest.exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
expect(await pkgJsonText(dir)).toBe(pkgJsonBefore);
const { stdout, exitCode } = await auditFix(dir, "--json");
expect(JSON.parse(stdout).blocked).toStrictEqual([
{
name: "dep-with-tags",
from: "1.0.1",
to: "2.0.0",
downgrade: false,
latestFixes: false,
blockers: [{ dependent: "package.json", range: "pre-1", bundled: false }],
},
]);
expect(exitCode).toBe(1);
});
test.concurrent("an advisory with empty vulnerable_versions covers every version", async () => {
await using server = startRegistry({}, { bulkResponse: { "no-deps": [adv("")] } });
using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "1.0.0" } });
const lockBefore = await lock(dir);
const { stdout, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`
"bun audit fix <version> (<revision>)
no published version fixes:
[email protected] 1
bun audit fix --ignore 1
Fixed 0 of 1 vulnerability (checked 1)
1 vulnerability remaining"
`);
expect(exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("the unfixable section prints one --ignore line for every advisory", async () => {
const ghsa = (range: string, id: number, tag: string) => ({
...adv(range, id),
url: `https://github.com/advisories/GHSA-${tag}`,
});
await using server = startRegistry(
{},
{
bulkResponse: {
"a-dep": [ghsa(">=0.0.0", 1, "also-aaaa-aaaa"), ghsa(">=0.0.0", 2, "forever-bbbb-bbbb")],
"no-deps": [adv(">=0.0.0", 3)],
},
},
);
using dir = await setup(server, { name: "foo", dependencies: { "a-dep": "1.0.2", "no-deps": "1.0.0" } });
const lockBefore = await lock(dir);
const { stdout, exitCode } = await auditFix(dir);
expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`
"bun audit fix <version> (<revision>)
no published version fixes:
[email protected] GHSA-also-aaaa-aaaa, GHSA-forever-bbbb-bbbb
[email protected] 3
bun audit fix --ignore GHSA-also-aaaa-aaaa --ignore GHSA-forever-bbbb-bbbb --ignore 3
Fixed 0 of 3 vulnerabilities (checked 2)
3 vulnerabilities remaining"
`);
expect(exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
const hints = stdout.split("\n").filter(line => line.trimStart().startsWith("bun audit fix --ignore "));
expect(hints).toHaveLength(1);
const args = hints[0].trim().split(" ").slice(3);
const ignored = await audit(dir, ...args);
expect(normalizeBunSnapshot(ignored.stdout)).toBe(AUDIT_HEADER + noVulnerabilities(2, "3 ignored"));
expect(ignored.exitCode).toBe(0);
const ignoredFix = await auditFix(dir, ...args);
expect(normalizeBunSnapshot(ignoredFix.stdout)).toBe(FIX_HEADER + noVulnerabilities(2, "3 ignored"));
expect(ignoredFix.exitCode).toBe(0);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("--json carries an unfixable version", async () => {
await using server = startRegistry({}, { bulkResponse: { "no-deps": [adv(">=0.0.0")] } });
using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "^1.0.0" } });
const lockBefore = await lock(dir);
expect(lockBefore).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir, "--json");
expect(JSON.parse(stdout)).toStrictEqual({
dryRun: false,
fixed: 0,
remaining: 1,
fixes: [],
blocked: [],
unfixable: [{ name: "no-deps", from: "1.1.0", advisories: ["1"] }],
manifestUnavailable: [],
unmatched: [],
unaudited: [],
vulnerableAfterInstall: [],
});
expect(exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("--json carries a version whose manifest could not be fetched", async () => {
const denyManifests = new Set<string>();
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] }, { denyManifests });
using dir = await setupVulnerableADep(server);
const lockBefore = await lock(dir);
denyManifests.add("a-dep");
const { stdout, exitCode } = await auditFix(dir, "--json");
expect(JSON.parse(stdout)).toStrictEqual({
dryRun: false,
fixed: 0,
remaining: 1,
fixes: [],
blocked: [],
unfixable: [],
manifestUnavailable: [{ name: "a-dep", from: "1.0.2", error: "404" }],
unmatched: [],
unaudited: [],
vulnerableAfterInstall: [],
});
expect(exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("--json carries versions that are vulnerable after the install", async () => {
const bulkHits = { count: 0 };
await using server = startRegistry(
{ "one-fixed-dep": [adv("<2.0.0")], "no-deps": [adv(">=2.0.0", 2)] },
{ bulkHits },
);
using dir = await setup(server, { name: "foo", dependencies: { "one-fixed-dep": "1.0.0" } });
await reinstall(dir, { name: "foo", dependencies: { "one-fixed-dep": "^1.0.0 || ^2.0.0" } });
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir, "--json");
expect(JSON.parse(stdout)).toStrictEqual({
dryRun: false,
fixed: 1,
remaining: 1,
fixes: [
{
name: "one-fixed-dep",
from: "1.0.0",
to: "2.0.0",
downgrade: false,
newerThanMinimumReleaseAge: false,
packageJson: [],
},
],
blocked: [],
unfixable: [],
manifestUnavailable: [],
unmatched: [],
unaudited: [],
vulnerableAfterInstall: [{ name: "no-deps", version: "2.0.0", advisories: ["2"] }],
});
expect(exitCode).toBe(1);
expect(bulkHits.count).toBe(2);
expect(await lock(dir)).toContain('"[email protected]"');
});
test.concurrent("--json marks a downgrade", async () => {
await using server = startRegistry({ "a-dep": [adv(">=1.0.3")] });
using dir = await setup(server, { name: "foo", dependencies: { "a-dep": "^1.0.0" } });
const lockBefore = await lock(dir);
expect(lockBefore).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir, "--json", "--dry-run");
const doc = JSON.parse(stdout);
expect(doc.fixes).toStrictEqual([
{
name: "a-dep",
from: "1.0.10",
to: "1.0.2",
downgrade: true,
newerThanMinimumReleaseAge: false,
packageJson: [],
},
]);
expect(doc).toMatchObject({ dryRun: true, fixed: 1, remaining: 0 });
expect(exitCode).toBe(0);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("--json marks a fix newer than --minimum-release-age", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setupVulnerableADep(server);
const lockBefore = await lock(dir);
const { stdout, exitCode } = await auditFix(dir, "--json", "--dry-run", "--minimum-release-age", "3153600000");
const doc = JSON.parse(stdout);
expect(doc.fixes).toStrictEqual([
{
name: "a-dep",
from: "1.0.2",
to: "1.0.4",
downgrade: false,
newerThanMinimumReleaseAge: true,
packageJson: [],
},
]);
expect(doc).toMatchObject({ dryRun: true, fixed: 1, remaining: 0 });
expect(exitCode).toBe(0);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("--json marks a bundled blocker", async () => {
await using server = startRegistry({ "no-deps": [adv("<1.0.1")] });
using dir = await setup(server, { name: "foo", dependencies: { "bundled-1": "1.0.0" } });
const lockBefore = await lock(dir);
const { stdout, exitCode } = await auditFix(dir, "--json");
const doc = JSON.parse(stdout);
expect(doc.blocked).toStrictEqual([
{
name: "no-deps",
from: "1.0.0",
to: "1.0.1",
downgrade: false,
latestFixes: false,
blockers: [{ dependent: "[email protected]", range: "1.0.0", bundled: true }],
},
]);
expect(doc).toMatchObject({ dryRun: false, fixed: 0, remaining: 1, fixes: [] });
expect(exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("--json marks a blocked fix that would be a downgrade", async () => {
await using server = startRegistry({ "no-deps": [adv(">=2.0.0")] });
using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "^2.0.0" } });
const lockBefore = await lock(dir);
const { stdout, exitCode } = await auditFix(dir, "--json");
const doc = JSON.parse(stdout);
expect(doc.blocked).toStrictEqual([
{
name: "no-deps",
from: "2.0.0",
to: "1.1.0",
downgrade: true,
latestFixes: true,
blockers: [{ dependent: "package.json", range: "^2.0.0", bundled: false }],
},
]);
expect(doc).toMatchObject({ dryRun: false, fixed: 0, remaining: 1, fixes: [] });
expect(exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent.each([
["catalog", { catalog: { "no-deps": "1.0.0" } }, "catalog:", "default"],
["catalogs.build", { catalogs: { build: { "no-deps": "1.0.0" } } }, "catalog:build", "build"],
])("--json names the catalog of a rewritten %s entry", async (_, rootFields, reference, catalog) => {
await using server = startRegistry({ "no-deps": [adv("<1.0.1")] });
using dir = await setup(
server,
{ name: "root", workspaces: ["packages/*"], ...rootFields },
{ "packages/a/package.json": JSON.stringify({ name: "a", dependencies: { "no-deps": reference } }) },
);
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir, "--json");
const doc = JSON.parse(stdout);
expect(doc.fixes).toStrictEqual([
{
name: "no-deps",
from: "1.0.0",
to: "1.0.1",
downgrade: false,
newerThanMinimumReleaseAge: false,
packageJson: [{ file: "package.json", catalog, key: "no-deps", from: "1.0.0", to: "1.0.1" }],
},
]);
expect(doc).toMatchObject({ dryRun: false, fixed: 1, remaining: 0 });
expect(exitCode).toBe(0);
expect(await lock(dir)).toContain('"[email protected]"');
});
test.concurrent("a bunfig.toml that freezes or stops saving the lockfile is explained in the note", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setupVulnerableADep(server);
const lockBefore = await lock(dir);
await writeBunfig(dir, server, undefined, { frozenLockfile: true });
const frozen = await auditFix(dir);
expect(frozen.stderr).toContain("error: bun audit fix needs to write bun.lock, but the lockfile is frozen");
expect(frozen.stderr).toContain(FROZEN_NOTE);
expect(frozen.stderr).not.toContain(NO_SAVE_NOTE);
expect(frozen.exitCode).toBe(1);
await writeBunfig(dir, server, undefined, { lockfile: { save: false } });
const noSave = await auditFix(dir);
expect(noSave.stderr).toContain(
"error: bun audit fix needs to write bun.lock, but saving the lockfile is disabled",
);
expect(noSave.stderr).toContain(NO_SAVE_NOTE);
expect(noSave.stderr).not.toContain(FROZEN_NOTE);
expect(noSave.exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("--frozen-lockfile --silent exits 1 without printing", async () => {
const bulkHits = { count: 0 };
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] }, { bulkHits });
using dir = await setupVulnerableADep(server);
const lockBefore = await lock(dir);
const { stdout, stderr, exitCode } = await auditFix(dir, "--frozen-lockfile", "--silent");
expect(stderr).toBe("");
expect(stdout).toBe("");
expect(exitCode).toBe(1);
expect(bulkHits.count).toBe(0);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("-p and -P are rejected before the registry is contacted", async () => {
const bulkHits = { count: 0 };
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] }, { bulkHits });
using dir = await setupVulnerableADep(server);
for (const flag of ["-p", "-P", "--prod"]) {
const { stderr, exitCode } = await auditFix(dir, flag);
expect(stderr).toContain("but the lockfile is frozen");
expect(exitCode).toBe(1);
}
expect(bulkHits.count).toBe(0);
});
});
describe("`bun audit fix --latest`", () => {
// Every no-deps 1.x release is vulnerable, so the only fix is 2.0.0, which no 1.x range accepts.
const noDeps1x = () => startRegistry({ "no-deps": [adv("<2.0.0")] });
test.concurrent("rewrites a root range that excludes the fix", async () => {
await using server = noDeps1x();
using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "^1.0.0" } });
const lockBefore = await lock(dir);
expect(lockBefore).toContain('"[email protected]"');
const pkgJsonBefore = await pkgJsonText(dir);
const blocked = await auditFix(dir);
expect(normalizeBunSnapshot(blocked.stdout)).toMatchInlineSnapshot(`
"bun audit fix <version> (<revision>)
blocked by a dependent's range:
^ no-deps 1.1.0 -> 2.0.0
package.json depends on no-deps@^1.0.0
bun audit fix --latest
Fixed 0 of 1 vulnerability (checked 1)
1 vulnerability remaining"
`);
expect(blocked.exitCode).toBe(1);
expect(await pkgJsonText(dir)).toBe(pkgJsonBefore);
expect(await lock(dir)).toBe(lockBefore);
const { stdout, stderr, exitCode } = await auditFix(dir, "--latest");
expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`
"bun audit fix <version> (<revision>)
fixing:
^ no-deps 1.1.0 -> 2.0.0
package.json: ^1.0.0 -> ^2.0.0
Fixed 1 vulnerability in 1 package (checked 1)"
`);
expect(stdout).toMatch(DURATION);
expect(stderr).not.toContain("Resolving...");
expect(stderr).not.toContain("error:");
expect(exitCode).toBe(0);
expect((await pkgJson(dir)).dependencies).toStrictEqual({ "no-deps": "^2.0.0" });
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).toContain('"no-deps": "^2.0.0"');
expect(lockfile).not.toContain('"[email protected]"');
expect(await installedVersion(dir, "no-deps")).toBe("2.0.0");
const recheck = await audit(dir);
expectClean(recheck, 1);
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent("a ~ range keeps its ~", async () => {
await using server = noDeps1x();
using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "~1.0.0" } });
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir, "--latest");
expect(stdout).toContain(" ^ no-deps 1.0.1 -> 2.0.0\n package.json: ~1.0.0 -> ~2.0.0\n");
expect(stdout).toContain("Fixed 1 vulnerability in 1 package");
expect(exitCode).toBe(0);
expect((await pkgJson(dir)).dependencies).toStrictEqual({ "no-deps": "~2.0.0" });
expect(await lock(dir)).toContain('"[email protected]"');
expect(await installedVersion(dir, "no-deps")).toBe("2.0.0");
});
test.concurrent.each([
[">=1.0.0 <2.0.0", "1.1.0", "^2.0.0"],
["1.x", "1.1.0", "^2.0.0"],
["1", "1.1.0", "^2.0.0"],
["1.0", "1.0.1", "~2.0.0"],
])("a range written as %s (resolving to %s) is rewritten as %s", async (declared, installed, rewritten) => {
await using server = noDeps1x();
using dir = await setup(server, { name: "foo", dependencies: { "no-deps": declared } });
expect(await lock(dir)).toContain(`"no-deps@${installed}"`);
const { stdout, exitCode } = await auditFix(dir, "--latest");
expect(stdout).toContain(` ^ no-deps ${installed} -> 2.0.0\n package.json: ${declared} -> ${rewritten}\n`);
expect(stdout).toContain("Fixed 1 vulnerability in 1 package");
expect(exitCode).toBe(0);
expect((await pkgJson(dir)).dependencies).toStrictEqual({ "no-deps": rewritten });
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).toContain(`"no-deps": "${rewritten}"`);
expect(await installedVersion(dir, "no-deps")).toBe("2.0.0");
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent("-L is accepted as --latest", async () => {
await using server = noDeps1x();
using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "^1.0.0" } });
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir, "-L");
expect(stdout).toContain(" ^ no-deps 1.1.0 -> 2.0.0\n package.json: ^1.0.0 -> ^2.0.0\n");
expect(stdout).toContain("Fixed 1 vulnerability in 1 package");
expect(exitCode).toBe(0);
expect((await pkgJson(dir)).dependencies).toStrictEqual({ "no-deps": "^2.0.0" });
expect(await lock(dir)).toContain('"[email protected]"');
});
test.concurrent("an exact pin stays exact", async () => {
await using server = noDeps1x();
using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "1.0.0" } });
const { stdout, exitCode } = await auditFix(dir, "--latest");
expect(stdout).toContain(" ^ no-deps 1.0.0 -> 2.0.0\n package.json: 1.0.0 -> 2.0.0\n");
expect(stdout).toContain("Fixed 1 vulnerability in 1 package");
expect(exitCode).toBe(0);
expect((await pkgJson(dir)).dependencies).toStrictEqual({ "no-deps": "2.0.0" });
expect(await lock(dir)).toContain('"[email protected]"');
expect(await installedVersion(dir, "no-deps")).toBe("2.0.0");
});
test.concurrent("an = pin keeps its =", async () => {
await using server = noDeps1x();
using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "=1.0.0" } });
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir, "--latest");
expect(stdout).toContain("package.json: =1.0.0 -> =2.0.0\n");
expect(exitCode).toBe(0);
expect((await pkgJson(dir)).dependencies).toStrictEqual({ "no-deps": "=2.0.0" });
expect(await lock(dir)).toContain('"[email protected]"');
});
test.concurrent("keeps an npm: alias prefix", async () => {
await using server = noDeps1x();
using dir = await setup(server, { name: "foo", dependencies: { nd: "npm:no-deps@^1.0.0" } });
expect(await lock(dir)).toContain('"[email protected]"');
const { stdout, exitCode } = await auditFix(dir, "--latest");
expect(stdout).toContain(
" ^ no-deps 1.1.0 -> 2.0.0\n package.json: npm:no-deps@^1.0.0 -> npm:no-deps@^2.0.0\n",
);
expect(exitCode).toBe(0);
expect((await pkgJson(dir)).dependencies).toStrictEqual({ nd: "npm:no-deps@^2.0.0" });
expect(await lock(dir)).toContain('"[email protected]"');
expect(await installedVersion(dir, "nd")).toBe("2.0.0");
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent("install.exact writes the bare version", async () => {
await using server = noDeps1x();
using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "^1.0.0" } });
await write(
join(dir, "bunfig.toml"),
Bun.TOML.stringify({
install: { cache: join(dir, ".bun-cache"), registry: server.url.href, saveTextLockfile: true, exact: true },
}),
);
const { stdout, exitCode } = await auditFix(dir, "--latest");
expect(stdout).toContain("package.json: ^1.0.0 -> 2.0.0\n");
expect(exitCode).toBe(0);
expect((await pkgJson(dir)).dependencies).toStrictEqual({ "no-deps": "2.0.0" });
expect(await lock(dir)).toContain('"[email protected]"');
});
test.concurrent("rewrites a workspace member's own range and leaves the root alone", async () => {
await using server = noDeps1x();
const rootPkgJson = JSON.stringify({ name: "root", workspaces: ["packages/*"] });
using dir = await setup(server, rootPkgJson, {
"packages/a/package.json": JSON.stringify({ name: "a", dependencies: { "no-deps": "^1.0.0" } }),
});
expect(await lock(dir)).toContain('"[email protected]"');
const blocked = await auditFix(dir);
expect(blocked.stdout).toContain(
" ^ no-deps 1.1.0 -> 2.0.0\n packages/a/package.json depends on no-deps@^1.0.0\n bun audit fix --latest\n",
);
expect(blocked.exitCode).toBe(1);
const { stdout, exitCode } = await auditFix(dir, "--latest");
expect(stdout).toContain(" ^ no-deps 1.1.0 -> 2.0.0\n packages/a/package.json: ^1.0.0 -> ^2.0.0\n");
expect(stdout).toContain("Fixed 1 vulnerability in 1 package");
expect(exitCode).toBe(0);
expect((await pkgJson(dir, "packages", "a")).dependencies).toStrictEqual({ "no-deps": "^2.0.0" });
expect(await pkgJsonText(dir)).toBe(rootPkgJson);
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).toContain('"no-deps": "^2.0.0"');
expect(lockfile).not.toContain('"[email protected]"');
expect(await exists(join(dir, "packages", "a", "bun.lock"))).toBeFalse();
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent("rewrites a ranged catalog entry and the member keeps `catalog:`", async () => {
await using server = noDeps1x();
const member = JSON.stringify({ name: "a", dependencies: { "no-deps": "catalog:" } });
using dir = await setup(
server,
{ name: "root", workspaces: ["packages/*"], catalog: { "no-deps": "^1.0.0" } },
{ "packages/a/package.json": member },
);
expect(await lock(dir)).toContain('"[email protected]"');
const rootBefore = await pkgJsonText(dir);
const blocked = await auditFix(dir);
expect(blocked.stdout).toContain("blocked by a dependent's range:");
expect(blocked.stdout).toContain("\n bun audit fix --latest\n");
expect(blocked.exitCode).toBe(1);
expect(await pkgJsonText(dir)).toBe(rootBefore);
const { stdout, exitCode } = await auditFix(dir, "--latest");
expect(stdout).toContain(" ^ no-deps 1.1.0 -> 2.0.0\n package.json (catalog): ^1.0.0 -> ^2.0.0\n");
expect(stdout).toContain("Fixed 1 vulnerability in 1 package");
expect(exitCode).toBe(0);
expect((await pkgJson(dir)).catalog).toStrictEqual({ "no-deps": "^2.0.0" });
expect(await pkgJsonText(dir, "packages", "a")).toBe(member);
const lockfile = await lock(dir);
expect(lockfile).toContain('"[email protected]"');
expect(lockfile).not.toContain('"[email protected]"');
expect(lockfile).toContain('"no-deps": "^2.0.0"');
expect(lockfile).toContain('"no-deps": "catalog:"');
await runBunInstall(installEnv(dir), dir, { frozenLockfile: true });
});
test.concurrent("--dry-run prints the package.json edit and writes nothing", async () => {
await using server = noDeps1x();
using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "^1.0.0" } });
const pkgJsonBefore = await pkgJsonText(dir);
const lockBefore = await lock(dir);
const { stdout, stderr, exitCode } = await auditFix(dir, "--latest", "--dry-run");
expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`
"bun audit fix <version> (<revision>)
fixing:
^ no-deps 1.1.0 -> 2.0.0
package.json: ^1.0.0 -> ^2.0.0
Would fix 1 vulnerability in 1 package (checked 1)"
`);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
expect(await pkgJsonText(dir)).toBe(pkgJsonBefore);
expect(await lock(dir)).toBe(lockBefore);
expect(await installedVersion(dir, "no-deps")).toBe("1.1.0");
});
test.concurrent("--json carries the package.json edit", async () => {
await using server = noDeps1x();
using dir = await setup(server, { name: "foo", dependencies: { "no-deps": "^1.0.0" } });
const { stdout, exitCode } = await auditFix(dir, "--latest", "--json");
const doc = JSON.parse(stdout);
expect(doc.fixes).toStrictEqual([
{
name: "no-deps",
from: "1.1.0",
to: "2.0.0",
downgrade: false,
newerThanMinimumReleaseAge: false,
packageJson: [{ file: "package.json", catalog: null, key: "no-deps", from: "^1.0.0", to: "^2.0.0" }],
},
]);
expect(doc).toMatchObject({ dryRun: false, fixed: 1, remaining: 0, blocked: [] });
expect(exitCode).toBe(0);
expect((await pkgJson(dir)).dependencies).toStrictEqual({ "no-deps": "^2.0.0" });
expect(await lock(dir)).toContain('"[email protected]"');
});
test.concurrent("a package blocked only by a transitive dependent stays blocked", async () => {
await using server = startRegistry({ "no-deps": [adv("<1.1.0")] });
using dir = await setup(server, { name: "foo", dependencies: { "one-dep": "1.0.0" } });
const lockBefore = await lock(dir);
expect(lockBefore).toContain('"[email protected]"');
const pkgJsonBefore = await pkgJsonText(dir);
const { stdout, stderr, exitCode } = await auditFix(dir, "--latest");
expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`
"bun audit fix <version> (<revision>)
blocked by a dependent's range:
^ no-deps 1.0.1 -> 1.1.0
[email protected] depends on [email protected]
Fixed 0 of 1 vulnerability (checked 2)
1 vulnerability remaining"
`);
expect(stderr).toBe("");
expect(exitCode).toBe(1);
expect(await pkgJsonText(dir)).toBe(pkgJsonBefore);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("a version held by an overrides entry stays blocked", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setup(server, {
name: "foo",
dependencies: { "a-dep": "^1.0.2" },
overrides: { "a-dep": "1.0.2" },
});
const lockBefore = await lock(dir);
const pkgJsonBefore = await pkgJsonText(dir);
const { stdout, exitCode } = await auditFix(dir, "--latest");
expect(stdout).toContain("blocked by a dependent's range:");
expect(stdout).toContain("package.json depends on [email protected]");
expect(stdout).not.toContain("fixing:");
expect(stdout).not.toContain("bun audit fix --latest");
expect(exitCode).toBe(1);
expect(await pkgJsonText(dir)).toBe(pkgJsonBefore);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("a bundled dependency stays blocked without a --latest hint", async () => {
await using server = startRegistry({ "no-deps": [adv("<1.0.1")] });
using dir = await setup(server, { name: "foo", dependencies: { "bundled-1": "1.0.0" } });
const lockBefore = await lock(dir);
const { stdout, exitCode } = await auditFix(dir, "--latest");
expect(stdout).toContain("[email protected] bundles [email protected]");
expect(stdout).not.toContain("fixing:");
expect(stdout).not.toContain("bun audit fix --latest");
expect(exitCode).toBe(1);
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("a fix inside the declared range does not touch package.json", async () => {
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] });
using dir = await setupVulnerableADep(server);
const pkgJsonBefore = await pkgJsonText(dir);
const { stdout, exitCode } = await auditFix(dir, "--latest");
expect(stdout).toContain(" ^ a-dep 1.0.2 -> 1.0.4\n");
expect(stdout).not.toContain("package.json");
expect(stdout).toContain("Fixed 1 vulnerability in 1 package");
expect(exitCode).toBe(0);
expect(await pkgJsonText(dir)).toBe(pkgJsonBefore);
expect(await lock(dir)).toContain('"[email protected]"');
});
test.concurrent("`bun audit --latest` without fix is rejected before the registry is contacted", async () => {
const bulkHits = { count: 0 };
await using server = startRegistry({ "a-dep": [adv("<1.0.4")] }, { bulkHits });
using dir = await setupVulnerableADep(server);
const lockBefore = await lock(dir);
const { stdout, stderr, exitCode } = await audit(dir, "--latest");
expect(stderr).toContain("--latest only applies to bun audit fix");
expect(stdout).toBe("");
expect(exitCode).toBe(1);
expect(bulkHits.count).toBe(0);
expect(await lock(dir)).toBe(lockBefore);
});
});