diff --git a/src/install/PackageManager/security_scanner.zig b/src/install/PackageManager/security_scanner.zig index fd702c3373..d9a9acb580 100644 --- a/src/install/PackageManager/security_scanner.zig +++ b/src/install/PackageManager/security_scanner.zig @@ -383,6 +383,7 @@ const PackageCollector = struct { const root_pkg_id: PackageID = 0; const root_deps = pkg_dependencies[root_pkg_id]; + // collect all npm deps from the root package for (root_deps.begin()..root_deps.end()) |_dep_id| { const dep_id: DependencyID = @intCast(_dep_id); const dep_pkg_id = this.manager.lockfile.buffers.resolutions.items[dep_id]; @@ -408,6 +409,39 @@ const PackageCollector = struct { .dep_path = dep_path_buf, }); } + + // and collect npm deps from workspace packages + for (0..pkgs.len) |pkg_idx| { + const pkg_id: PackageID = @intCast(pkg_idx); + if (pkg_resolutions[pkg_id].tag != .workspace) continue; + + const workspace_deps = pkg_dependencies[pkg_id]; + for (workspace_deps.begin()..workspace_deps.end()) |_dep_id| { + const dep_id: DependencyID = @intCast(_dep_id); + const dep_pkg_id = this.manager.lockfile.buffers.resolutions.items[dep_id]; + + if (dep_pkg_id == invalid_package_id) continue; + + const dep_res = pkg_resolutions[dep_pkg_id]; + if (dep_res.tag != .npm) continue; + + if ((try this.dedupe.getOrPut(dep_pkg_id)).found_existing) continue; + + var pkg_path_buf = std.array_list.Managed(PackageID).init(this.manager.allocator); + try pkg_path_buf.append(pkg_id); + try pkg_path_buf.append(dep_pkg_id); + + var dep_path_buf = std.array_list.Managed(DependencyID).init(this.manager.allocator); + try dep_path_buf.append(dep_id); + + try this.queue.writeItem(.{ + .pkg_id = dep_pkg_id, + .dep_id = dep_id, + .pkg_path = pkg_path_buf, + .dep_path = dep_path_buf, + }); + } + } } pub fn collectUpdatePackages(this: *PackageCollector) !void { @@ -427,7 +461,7 @@ const PackageCollector = struct { for (0..pkgs.len) |_pkg_id| update_dep_id: { const pkg_id: PackageID = @intCast(_pkg_id); const pkg_res = pkg_resolutions[pkg_id]; - if (pkg_res.tag != .root) continue; + if (pkg_res.tag != .root and pkg_res.tag != .workspace) continue; const pkg_deps = pkg_dependencies[pkg_id]; for (pkg_deps.begin()..pkg_deps.end()) |_dep_id| { diff --git a/test/cli/install/bun-security-scanner-workspaces.test.ts b/test/cli/install/bun-security-scanner-workspaces.test.ts new file mode 100644 index 0000000000..c9e02b92d6 --- /dev/null +++ b/test/cli/install/bun-security-scanner-workspaces.test.ts @@ -0,0 +1,276 @@ +import { bunEnv, bunExe, tempDirWithFiles } from "harness"; +import { join } from "node:path"; +import { getRegistry, startRegistry, stopRegistry } from "./simple-dummy-registry"; + +test("security scanner receives packages from workspace dependencies", async () => { + const registryUrl = await startRegistry(false); + + try { + const registry = getRegistry(); + if (!registry) { + throw new Error("Registry not found"); + } + + registry.clearRequestLog(); + registry.setScannerBehavior("none"); + + // Create a workspace setup with root package and multiple workspace packages + const files = { + "package.json": JSON.stringify( + { + name: "workspace-root", + private: true, + workspaces: ["packages/*"], + }, + null, + 2, + ), + "packages/app1/package.json": JSON.stringify( + { + name: "app1", + dependencies: { + "left-pad": "1.3.0", + }, + }, + null, + 2, + ), + "packages/app2/package.json": JSON.stringify( + { + name: "app2", + dependencies: { + "is-even": "1.0.0", + }, + }, + null, + 2, + ), + "packages/lib1/package.json": JSON.stringify( + { + name: "lib1", + dependencies: { + "is-odd": "1.0.0", + }, + }, + null, + 2, + ), + "scanner.js": `export const scanner = { + version: "1", + scan: async function(payload) { + console.error("SCANNER_RAN: " + payload.packages.length + " packages"); + return []; + } +}`, + }; + + const dir = tempDirWithFiles("scanner-workspaces", files); + + await Bun.write( + join(dir, "bunfig.toml"), + `[install] +cache.disable = true +registry = "${registryUrl}/" + +[install.security] +scanner = "./scanner.js"`, + ); + + const { stdout, stderr } = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: dir, + stdout: "pipe", + stderr: "pipe", + env: bunEnv, + }); + + const output = (await stdout.text()) + (await stderr.text()); + + // The scanner should receive packages from all workspace dependencies + expect(output).toContain("SCANNER_RAN:"); + + // Extract the number of packages from the output + const match = output.match(/SCANNER_RAN: (\d+) packages/); + expect(match).toBeTruthy(); + + const packagesScanned = parseInt(match![1], 10); + // Exact package count: left-pad, is-even, is-odd (is-even <-> is-odd have circular deps) + expect(packagesScanned).toBe(3); + } finally { + stopRegistry(); + } +}); + +test("security scanner receives packages from workspace dependencies with hoisted linker", async () => { + const registryUrl = await startRegistry(false); + + try { + const registry = getRegistry(); + if (!registry) { + throw new Error("Registry not found"); + } + + registry.clearRequestLog(); + registry.setScannerBehavior("none"); + + const files = { + "package.json": JSON.stringify( + { + name: "workspace-root", + private: true, + workspaces: ["packages/*"], + }, + null, + 2, + ), + "packages/app1/package.json": JSON.stringify( + { + name: "app1", + dependencies: { + "left-pad": "1.3.0", + }, + }, + null, + 2, + ), + "packages/app2/package.json": JSON.stringify( + { + name: "app2", + dependencies: { + "is-even": "1.0.0", + }, + }, + null, + 2, + ), + "scanner.js": `export const scanner = { + version: "1", + scan: async function(payload) { + console.error("SCANNER_RAN: " + payload.packages.length + " packages"); + return []; + } +}`, + }; + + const dir = tempDirWithFiles("scanner-workspaces-hoisted", files); + + await Bun.write( + join(dir, "bunfig.toml"), + `[install] +cache.disable = true +linker = "hoisted" +registry = "${registryUrl}/" + +[install.security] +scanner = "./scanner.js"`, + ); + + const { stdout, stderr } = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: dir, + stdout: "pipe", + stderr: "pipe", + env: bunEnv, + }); + + const output = (await stdout.text()) + (await stderr.text()); + + expect(output).toContain("SCANNER_RAN:"); + + const match = output.match(/SCANNER_RAN: (\d+) packages/); + expect(match).toBeTruthy(); + + const packagesScanned = parseInt(match![1], 10); + // Exact package count: left-pad, is-even, is-odd (is-even <-> is-odd have circular deps) + expect(packagesScanned).toBe(3); + } finally { + stopRegistry(); + } +}); + +test("security scanner receives packages from workspace dependencies with isolated linker", async () => { + const registryUrl = await startRegistry(false); + + try { + const registry = getRegistry(); + if (!registry) { + throw new Error("Registry not found"); + } + + registry.clearRequestLog(); + registry.setScannerBehavior("none"); + + const files = { + "package.json": JSON.stringify( + { + name: "workspace-root", + private: true, + workspaces: ["packages/*"], + }, + null, + 2, + ), + "packages/app1/package.json": JSON.stringify( + { + name: "app1", + dependencies: { + "left-pad": "1.3.0", + }, + }, + null, + 2, + ), + "packages/app2/package.json": JSON.stringify( + { + name: "app2", + dependencies: { + "is-even": "1.0.0", + }, + }, + null, + 2, + ), + "scanner.js": `export const scanner = { + version: "1", + scan: async function(payload) { + console.error("SCANNER_RAN: " + payload.packages.length + " packages"); + return []; + } +}`, + }; + + const dir = tempDirWithFiles("scanner-workspaces-isolated", files); + + await Bun.write( + join(dir, "bunfig.toml"), + `[install] +cache.disable = true +linker = "isolated" +registry = "${registryUrl}/" + +[install.security] +scanner = "./scanner.js"`, + ); + + const { stdout, stderr } = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: dir, + stdout: "pipe", + stderr: "pipe", + env: bunEnv, + }); + + const output = (await stdout.text()) + (await stderr.text()); + + expect(output).toContain("SCANNER_RAN:"); + + const match = output.match(/SCANNER_RAN: (\d+) packages/); + expect(match).toBeTruthy(); + + const packagesScanned = parseInt(match![1], 10); + // Exact package count: left-pad, is-even, is-odd (is-even <-> is-odd have circular deps) + expect(packagesScanned).toBe(3); + } finally { + stopRegistry(); + } +});