mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
### What does this PR do? Fixes #22014 todo: - [x] not spawn sync - [x] better comm to subprocess (not stderr) - [x] tty - [x] more tests (also include some tests for the actual implementation of a provider) - [x] disable autoinstall? Scanner template: https://github.com/oven-sh/security-scanner-template <!-- **Please explain what your changes do**, example: --> <!-- This adds a new flag --bail to bun test. When set, it will stop running tests after the first failure. This is useful for CI environments where you want to fail fast. --> --- - [x] Documentation or TypeScript types (it's okay to leave the rest blank in this case) - [x] Code changes ### How did you verify your code works? <!-- **For code changes, please include automated tests**. Feel free to uncomment the line below --> <!-- I wrote automated tests --> <!-- If JavaScript/TypeScript modules or builtins changed: - [ ] I included a test for the new code, or existing tests cover it - [ ] I ran my tests locally and they pass (`bun-debug test test-file-name.test`) --> <!-- If Zig files changed: - [ ] I checked the lifetime of memory allocated to verify it's (1) freed and (2) only freed when it should be - [ ] I included a test for the new code, or an existing test covers it - [ ] JSValue used outside of the stack is either wrapped in a JSC.Strong or is JSValueProtect'ed - [ ] I wrote TypeScript/JavaScript tests and they pass locally (`bun-debug test test-file-name.test`) --> <!-- If new methods, getters, or setters were added to a publicly exposed class: - [ ] I added TypeScript types for the new methods, getters, or setters --> <!-- If dependencies in tests changed: - [ ] I made sure that specific versions of dependencies are used instead of ranged or tagged versions --> <!-- If a new builtin ESM/CJS module was added: - [ ] I updated Aliases in `module_loader.zig` to include the new module - [ ] I added a test that imports the module - [ ] I added a test that require() the module --> tests (bad currently) --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Dylan Conway <dylan-conway@users.noreply.github.com> Co-authored-by: Dylan Conway <dylan.conway567@gmail.com> Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
680 lines
17 KiB
TypeScript
680 lines
17 KiB
TypeScript
import { bunEnv, runBunInstall } from "harness";
|
|
import {
|
|
dummyAfterAll,
|
|
dummyAfterEach,
|
|
dummyBeforeAll,
|
|
dummyBeforeEach,
|
|
dummyRegistry,
|
|
package_dir,
|
|
read,
|
|
root_url,
|
|
setHandler,
|
|
write,
|
|
} from "./dummy.registry.js";
|
|
|
|
beforeAll(dummyBeforeAll);
|
|
afterAll(dummyAfterAll);
|
|
beforeEach(dummyBeforeEach);
|
|
afterEach(dummyAfterEach);
|
|
|
|
function test(
|
|
name: string,
|
|
options: {
|
|
testTimeout?: number;
|
|
scanner: Bun.Security.Scanner["scan"] | string;
|
|
fails?: boolean;
|
|
expect?: (std: { out: string; err: string }) => void | Promise<void>;
|
|
expectedExitCode?: number;
|
|
bunfigScanner?: string | false;
|
|
packages?: string[];
|
|
scannerFile?: string;
|
|
},
|
|
) {
|
|
it(
|
|
name,
|
|
async () => {
|
|
const urls: string[] = [];
|
|
setHandler(dummyRegistry(urls));
|
|
|
|
const scannerPath = options.scannerFile || "./scanner.ts";
|
|
if (typeof options.scanner === "string") {
|
|
await write(scannerPath, options.scanner);
|
|
} else {
|
|
const s = `export const scanner = {
|
|
version: "1",
|
|
scan: ${options.scanner.toString()},
|
|
};`;
|
|
await write(scannerPath, s);
|
|
}
|
|
|
|
const bunfig = await read("./bunfig.toml").text();
|
|
if (options.bunfigScanner !== false) {
|
|
const scannerPath = options.bunfigScanner ?? "./scanner.ts";
|
|
await write("./bunfig.toml", `${bunfig}\n[install.security]\nscanner = "${scannerPath}"`);
|
|
}
|
|
|
|
await write("package.json", {
|
|
name: "my-app",
|
|
version: "1.0.0",
|
|
dependencies: {},
|
|
});
|
|
|
|
const expectedExitCode = options.expectedExitCode ?? (options.fails ? 1 : 0);
|
|
const packages = options.packages ?? ["bar"];
|
|
|
|
const { out, err } = await runBunInstall(bunEnv, package_dir, {
|
|
packages,
|
|
allowErrors: true,
|
|
allowWarnings: false,
|
|
savesLockfile: false,
|
|
expectedExitCode,
|
|
});
|
|
|
|
if (options.fails) {
|
|
expect(out).toContain("bun install aborted due to fatal security advisories");
|
|
}
|
|
|
|
await options.expect?.({ out, err });
|
|
},
|
|
{
|
|
timeout: options.testTimeout ?? 5_000,
|
|
},
|
|
);
|
|
}
|
|
|
|
test("basic", {
|
|
fails: true,
|
|
scanner: async ({ packages }) => [
|
|
{
|
|
package: packages[0].name,
|
|
description: "Advisory 1 description",
|
|
level: "fatal",
|
|
url: "https://example.com/advisory-1",
|
|
},
|
|
],
|
|
});
|
|
|
|
test("shows progress message when scanner takes more than 1 second", {
|
|
scanner: async () => {
|
|
await Bun.sleep(2000);
|
|
return [];
|
|
},
|
|
expect: async ({ err }) => {
|
|
expect(err).toMatch(/\[\.\/scanner\.ts\] Scanning \d+ packages? took \d+ms/);
|
|
},
|
|
});
|
|
|
|
test("expect output to contain the advisory", {
|
|
fails: true,
|
|
scanner: async ({ packages }) => [
|
|
{
|
|
package: packages[0].name,
|
|
description: "Advisory 1 description",
|
|
level: "fatal",
|
|
url: "https://example.com/advisory-1",
|
|
},
|
|
],
|
|
expect: ({ out }) => {
|
|
expect(out).toContain("Advisory 1 description");
|
|
},
|
|
});
|
|
|
|
test("stdout contains all input package metadata", {
|
|
fails: false,
|
|
scanner: async ({ packages }) => {
|
|
console.log(JSON.stringify(packages));
|
|
return [];
|
|
},
|
|
expect: ({ out }) => {
|
|
expect(out).toContain('\"version\":\"0.0.2\"');
|
|
expect(out).toContain('\"name\":\"bar\"');
|
|
expect(out).toContain('\"requestedRange\":\"^0.0.2\"');
|
|
expect(out).toContain(`\"tarball\":\"${root_url}/bar-0.0.2.tgz\"`);
|
|
},
|
|
});
|
|
|
|
describe("Security Scanner Edge Cases", () => {
|
|
test("scanner module not found", {
|
|
scanner: "dummy", // We need a scanner but will override the path
|
|
bunfigScanner: "./non-existent-scanner.ts",
|
|
expectedExitCode: 1,
|
|
expect: ({ err }) => {
|
|
expect(err).toContain("Failed to import security scanner");
|
|
},
|
|
});
|
|
|
|
test("scanner module throws during import", {
|
|
scanner: `throw new Error("Module failed to load");`,
|
|
expectedExitCode: 1,
|
|
expect: ({ err }) => {
|
|
expect(err).toContain("Failed to import security scanner");
|
|
},
|
|
});
|
|
|
|
test("scanner missing version field", {
|
|
scanner: `export const scanner = {
|
|
scan: async () => []
|
|
};`,
|
|
expectedExitCode: 1,
|
|
expect: ({ err }) => {
|
|
expect(err).toContain("with a version property");
|
|
},
|
|
});
|
|
|
|
test("scanner wrong version", {
|
|
scanner: `export const scanner = {
|
|
version: "2",
|
|
scan: async () => []
|
|
};`,
|
|
expectedExitCode: 1,
|
|
expect: ({ err }) => {
|
|
expect(err).toContain("Security scanner must be version 1");
|
|
},
|
|
});
|
|
|
|
test("scanner missing scan", {
|
|
scanner: `export const scanner = {
|
|
version: "1"
|
|
};`,
|
|
expectedExitCode: 1,
|
|
expect: ({ err }) => {
|
|
expect(err).toContain("scanner.scan is not a function");
|
|
},
|
|
});
|
|
|
|
test("scanner scan not a function", {
|
|
scanner: `export const scanner = {
|
|
version: "1",
|
|
scan: "not a function"
|
|
};`,
|
|
expectedExitCode: 1,
|
|
expect: ({ err }) => {
|
|
expect(err).toContain("scanner.scan is not a function");
|
|
},
|
|
});
|
|
});
|
|
|
|
// Invalid return value tests
|
|
describe("Invalid Return Values", () => {
|
|
test("scanner returns non-array", {
|
|
scanner: async () => "not an array" as any,
|
|
expectedExitCode: 1,
|
|
expect: ({ err }) => {
|
|
expect(err).toContain("Security scanner must return an array of advisories");
|
|
},
|
|
});
|
|
|
|
test("scanner returns null", {
|
|
scanner: async () => null as any,
|
|
expectedExitCode: 1,
|
|
expect: ({ err }) => {
|
|
expect(err).toContain("Security scanner must return an array of advisories");
|
|
},
|
|
});
|
|
|
|
test("scanner returns undefined", {
|
|
scanner: async () => undefined as any,
|
|
expectedExitCode: 1,
|
|
expect: ({ err }) => {
|
|
expect(err).toContain("Security scanner must return an array of advisories");
|
|
},
|
|
});
|
|
|
|
test("scanner throws exception", {
|
|
scanner: async () => {
|
|
throw new Error("Scanner failed");
|
|
},
|
|
expectedExitCode: 1,
|
|
expect: ({ err }) => {
|
|
expect(err).toContain("Scanner failed");
|
|
},
|
|
});
|
|
|
|
test("scanner returns non-object in array", {
|
|
scanner: async () => ["not an object"] as any,
|
|
expectedExitCode: 1,
|
|
expect: ({ err }) => {
|
|
expect(err).toContain("Security advisory at index 0 must be an object");
|
|
},
|
|
});
|
|
});
|
|
|
|
// Invalid advisory format tests
|
|
describe("Invalid Advisory Formats", () => {
|
|
test("advisory missing package field", {
|
|
scanner: async () => [
|
|
{
|
|
description: "Missing package field",
|
|
level: "fatal",
|
|
url: "https://example.com",
|
|
} as any,
|
|
],
|
|
expectedExitCode: 1,
|
|
expect: ({ err }) => {
|
|
expect(err).toContain("Security advisory at index 0 missing required 'package' field");
|
|
},
|
|
});
|
|
|
|
test("advisory package field not string", {
|
|
scanner: async () => [
|
|
{
|
|
package: 123,
|
|
description: "Package is number",
|
|
level: "fatal",
|
|
url: "https://example.com",
|
|
} as any,
|
|
],
|
|
expectedExitCode: 1,
|
|
expect: ({ err }) => {
|
|
expect(err).toContain("Security advisory at index 0 'package' field must be a string");
|
|
},
|
|
});
|
|
|
|
test("advisory package field empty string", {
|
|
scanner: async () => [
|
|
{
|
|
package: "",
|
|
description: "Empty package name",
|
|
level: "fatal",
|
|
url: "https://example.com",
|
|
},
|
|
],
|
|
expectedExitCode: 1,
|
|
expect: ({ err }) => {
|
|
expect(err).toContain("Security advisory at index 0 'package' field cannot be empty");
|
|
},
|
|
});
|
|
|
|
test("advisory missing description field", {
|
|
scanner: async () => [
|
|
{
|
|
package: "bar",
|
|
// description field is completely missing
|
|
level: "fatal",
|
|
url: "https://example.com",
|
|
} as any,
|
|
],
|
|
fails: true,
|
|
expect: ({ out }) => {
|
|
// When field is missing, it's treated as null and installation proceeds
|
|
expect(out).toContain("bar");
|
|
expect(out).toContain("https://example.com");
|
|
},
|
|
});
|
|
|
|
test("advisory with null description field", {
|
|
scanner: async () => [
|
|
{
|
|
package: "bar",
|
|
description: null,
|
|
level: "fatal",
|
|
url: "https://example.com",
|
|
},
|
|
],
|
|
fails: true,
|
|
expect: ({ out }) => {
|
|
// Should not print null description
|
|
expect(out).not.toContain("null");
|
|
expect(out).toContain("https://example.com");
|
|
},
|
|
});
|
|
|
|
test("advisory with empty string description", {
|
|
scanner: async () => [
|
|
{
|
|
package: "bar",
|
|
description: "",
|
|
level: "fatal",
|
|
url: "https://example.com",
|
|
},
|
|
],
|
|
fails: true,
|
|
expect: ({ out }) => {
|
|
// Should not print empty description
|
|
expect(out).toContain("bar");
|
|
expect(out).toContain("https://example.com");
|
|
},
|
|
});
|
|
|
|
test("advisory description field not string or null", {
|
|
scanner: async () => [
|
|
{
|
|
package: "bar",
|
|
description: { text: "object description" },
|
|
level: "fatal",
|
|
url: "https://example.com",
|
|
} as any,
|
|
],
|
|
expectedExitCode: 1,
|
|
expect: ({ err }) => {
|
|
expect(err).toContain("Security advisory at index 0 'description' field must be a string or null");
|
|
},
|
|
});
|
|
|
|
test("advisory missing url field", {
|
|
scanner: async () => [
|
|
{
|
|
package: "bar",
|
|
description: "Test advisory",
|
|
// url field is completely missing
|
|
level: "fatal",
|
|
} as any,
|
|
],
|
|
fails: true,
|
|
expect: ({ out }) => {
|
|
// When field is missing, it's treated as null and installation proceeds
|
|
expect(out).toContain("Test advisory");
|
|
expect(out).toContain("bar");
|
|
},
|
|
});
|
|
|
|
test("advisory with null url field", {
|
|
scanner: async () => [
|
|
{
|
|
package: "bar",
|
|
description: "Test advisory",
|
|
level: "fatal",
|
|
url: null,
|
|
},
|
|
],
|
|
fails: true,
|
|
expect: ({ out }) => {
|
|
expect(out).toContain("Test advisory");
|
|
// Should not print a URL line when url is null
|
|
expect(out).not.toContain("https://");
|
|
expect(out).not.toContain("http://");
|
|
},
|
|
});
|
|
|
|
test("advisory with empty string url", {
|
|
scanner: async () => [
|
|
{
|
|
package: "bar",
|
|
description: "Has empty URL",
|
|
level: "fatal",
|
|
url: "",
|
|
},
|
|
],
|
|
fails: true,
|
|
expect: ({ out }) => {
|
|
expect(out).toContain("Has empty URL");
|
|
// Should not print empty URL line at all
|
|
expect(out).toContain("bar");
|
|
},
|
|
});
|
|
|
|
test("advisory missing level field", {
|
|
scanner: async () => [
|
|
{
|
|
package: "bar",
|
|
description: "Missing level",
|
|
url: "https://example.com",
|
|
} as any,
|
|
],
|
|
expectedExitCode: 1,
|
|
expect: ({ err }) => {
|
|
expect(err).toContain("Security advisory at index 0 missing required 'level' field");
|
|
},
|
|
});
|
|
|
|
test("advisory url field not string or null", {
|
|
scanner: async () => [
|
|
{
|
|
package: "bar",
|
|
description: "URL is boolean",
|
|
level: "fatal",
|
|
url: true,
|
|
} as any,
|
|
],
|
|
expectedExitCode: 1,
|
|
expect: ({ err }) => {
|
|
expect(err).toContain("Security advisory at index 0 'url' field must be a string or null");
|
|
},
|
|
});
|
|
|
|
test("advisory invalid level", {
|
|
scanner: async () => [
|
|
{
|
|
package: "bar",
|
|
description: "Invalid level",
|
|
level: "critical",
|
|
url: "https://example.com",
|
|
} as any,
|
|
],
|
|
expectedExitCode: 1,
|
|
expect: ({ err }) => {
|
|
expect(err).toContain("Security advisory at index 0 'level' field must be 'fatal' or 'warn'");
|
|
},
|
|
});
|
|
|
|
test("advisory level not string", {
|
|
scanner: async () => [
|
|
{
|
|
package: "bar",
|
|
description: "Level is number",
|
|
level: 1,
|
|
url: "https://example.com",
|
|
} as any,
|
|
],
|
|
expectedExitCode: 1,
|
|
expect: ({ err }) => {
|
|
expect(err).toContain("Security advisory at index 0 'level' field must be a string");
|
|
},
|
|
});
|
|
|
|
test("second advisory invalid", {
|
|
scanner: async () => [
|
|
{
|
|
package: "bar",
|
|
description: "Valid advisory",
|
|
level: "warn",
|
|
url: "https://example.com/1",
|
|
},
|
|
{
|
|
package: "baz",
|
|
description: 123, // not a string or null
|
|
level: "fatal",
|
|
url: "https://example.com/2",
|
|
} as any,
|
|
],
|
|
expectedExitCode: 1,
|
|
expect: ({ err }) => {
|
|
expect(err).toContain("Security advisory at index 1 'description' field must be a string or null");
|
|
},
|
|
});
|
|
});
|
|
|
|
describe("Process Behavior", () => {
|
|
test("scanner process exits early", {
|
|
scanner: `
|
|
console.log("Starting...");
|
|
process.exit(42);
|
|
`,
|
|
expectedExitCode: 1,
|
|
expect: ({ err }) => {
|
|
expect(err).toContain("Security scanner exited with code 42 without sending data");
|
|
},
|
|
});
|
|
});
|
|
|
|
describe("Large Data Handling", () => {
|
|
test("scanner returns many advisories", {
|
|
scanner: async ({ packages }) => {
|
|
const advisories: any[] = [];
|
|
|
|
for (let i = 0; i < 1000; i++) {
|
|
advisories.push({
|
|
package: packages[0].name,
|
|
description: `Advisory ${i} description with a very long text that might cause buffer issues`,
|
|
level: i % 10 === 0 ? "fatal" : "warn",
|
|
url: `https://example.com/advisory-${i}`,
|
|
});
|
|
}
|
|
|
|
return advisories;
|
|
},
|
|
fails: true,
|
|
expect: ({ out }) => {
|
|
expect(out).toContain("Advisory 0 description");
|
|
expect(out).toContain("Advisory 99 description");
|
|
expect(out).toContain("Advisory 999 description");
|
|
},
|
|
});
|
|
|
|
test("scanner with very large response", {
|
|
scanner: async ({ packages }) => {
|
|
const longString = Buffer.alloc(10000, 65).toString(); // 10k of 'A's
|
|
return [
|
|
{
|
|
package: packages[0].name,
|
|
description: longString,
|
|
level: "fatal",
|
|
url: "https://example.com",
|
|
},
|
|
];
|
|
},
|
|
fails: true,
|
|
expect: ({ out }) => {
|
|
expect(out).toContain("AAAA");
|
|
},
|
|
});
|
|
});
|
|
|
|
describe("Multiple Package Scanning", () => {
|
|
test("multiple packages scanned", {
|
|
packages: ["bar", "qux"],
|
|
scanner: async ({ packages }) => {
|
|
return packages.map(pkg => ({
|
|
package: pkg.name,
|
|
description: `Security issue in ${pkg.name}`,
|
|
level: "fatal",
|
|
url: `https://example.com/${pkg.name}`,
|
|
}));
|
|
},
|
|
fails: true,
|
|
expect: ({ out }) => {
|
|
expect(out).toContain("Security issue in bar");
|
|
expect(out).toContain("Security issue in qux");
|
|
},
|
|
});
|
|
});
|
|
|
|
describe("Edge Cases", () => {
|
|
test("advisory with both null description and url", {
|
|
scanner: async ({ packages }) => [
|
|
{
|
|
package: packages[0].name,
|
|
description: null,
|
|
level: "fatal",
|
|
url: null,
|
|
},
|
|
],
|
|
fails: true,
|
|
expect: ({ out }) => {
|
|
// Should show the package name and level but not null values
|
|
expect(out).toContain("bar");
|
|
expect(out).not.toContain("null");
|
|
},
|
|
});
|
|
|
|
test("empty advisories array", {
|
|
scanner: async () => [],
|
|
expectedExitCode: 0,
|
|
});
|
|
|
|
test("special characters in advisory", {
|
|
scanner: async ({ packages }) => [
|
|
{
|
|
package: packages[0].name,
|
|
description: "Advisory with \"quotes\" and 'single quotes' and \n newlines \t tabs",
|
|
level: "fatal",
|
|
url: "https://example.com/path?param=value&other=123#hash",
|
|
},
|
|
],
|
|
fails: true,
|
|
expect: ({ out }) => {
|
|
expect(out).toContain("quotes");
|
|
expect(out).toContain("single quotes");
|
|
},
|
|
});
|
|
|
|
test("unicode in advisory fields", {
|
|
scanner: async ({ packages }) => [
|
|
{
|
|
package: packages[0].name,
|
|
description: "Security issue with emoji 🔒 and unicode ñ é ü",
|
|
level: "fatal",
|
|
url: "https://example.com/unicode",
|
|
},
|
|
],
|
|
fails: true,
|
|
expect: ({ out }) => {
|
|
expect(out).toContain("🔒");
|
|
expect(out).toContain("ñ é ü");
|
|
},
|
|
});
|
|
|
|
test("advisory without level field", {
|
|
scanner: async ({ packages }) => [
|
|
{
|
|
package: packages[0].name,
|
|
description: "No level specified",
|
|
url: "https://example.com",
|
|
} as any,
|
|
],
|
|
expectedExitCode: 1,
|
|
expect: ({ err }) => {
|
|
expect(err).toContain("Security advisory at index 0 missing required 'level' field");
|
|
},
|
|
});
|
|
|
|
test("null values in level field", {
|
|
scanner: async ({ packages }) => [
|
|
{
|
|
package: packages[0].name,
|
|
description: "Advisory with null level",
|
|
level: null as any,
|
|
url: "https://example.com",
|
|
},
|
|
],
|
|
expectedExitCode: 1,
|
|
expect: ({ err }) => {
|
|
expect(err).toContain("Security advisory at index 0 'level' field must be a string");
|
|
},
|
|
});
|
|
});
|
|
|
|
describe("Package Resolution", () => {
|
|
test("scanner with version ranges", {
|
|
scanner: async ({ packages }) => {
|
|
console.log("Version ranges:");
|
|
for (const pkg of packages) {
|
|
console.log(`- ${pkg.name}: ${pkg.requestedRange} resolved to ${pkg.version}`);
|
|
}
|
|
return [];
|
|
},
|
|
packages: ["bar@~0.0.1", "qux@>=0.0.1 <1.0.0"],
|
|
expectedExitCode: 0,
|
|
expect: ({ out }) => {
|
|
expect(out).toContain("bar: ~0.0.1 resolved to");
|
|
expect(out).toContain("qux: >=0.0.1 <1.0.0 resolved to");
|
|
},
|
|
});
|
|
|
|
test("scanner with latest tags", {
|
|
scanner: async ({ packages }) => {
|
|
for (const pkg of packages) {
|
|
if (pkg.requestedRange === "latest" || pkg.requestedRange === "*") {
|
|
console.log(`Latest tag: ${pkg.name}@${pkg.requestedRange} -> ${pkg.version}`);
|
|
}
|
|
}
|
|
return [];
|
|
},
|
|
packages: ["bar@latest", "qux@*"],
|
|
expectedExitCode: 0,
|
|
expect: ({ out }) => {
|
|
expect(out).toContain("Latest tag:");
|
|
},
|
|
});
|
|
});
|