From efdbe3b54f2cf8668b2f867f5fda0ae3784cb060 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Thu, 21 Aug 2025 14:53:50 -0700 Subject: [PATCH] bun install Security Scanner API (#21183) ### 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 --- - [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? tests (bad currently) --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Dylan Conway Co-authored-by: Dylan Conway Co-authored-by: Jarred Sumner --- cmake/sources/ZigSources.txt | 1 + docs/install/security-scanner-api.md | 81 ++ docs/runtime/bunfig.md | 26 + packages/bun-types/index.d.ts | 1 + packages/bun-types/security.d.ts | 101 +++ src/api/schema.zig | 2 + src/bun.js/api/bun/process.zig | 10 +- src/bunfig.zig | 11 + .../PackageManager/PackageManagerOptions.zig | 8 + .../PackageManager/install_with_manager.zig | 12 +- .../PackageManager/security_scanner.zig | 749 ++++++++++++++++++ .../updatePackageJSONAndInstall.zig | 1 + src/install/install.zig | 1 + src/js/node/readline.ts | 4 + .../bun-install-security-provider.test.ts | 679 ++++++++++++++++ test/cli/install/depends-on-monkey-0.0.2.tgz | Bin 0 -> 485 bytes test/cli/install/dummy.registry.ts | 17 +- test/cli/install/monkey-0.0.2.tgz | Bin 0 -> 466 bytes test/harness.ts | 15 +- test/integration/bun-types/fixture/install.ts | 44 + .../bun-types/fixture/utilities.ts | 1 + test/js/bun/test/jest.d.ts | 11 +- test/js/node/readline/readline.node.test.ts | 48 ++ .../readline/readline_promises.node.test.ts | 46 ++ test/tsconfig.json | 1 + 25 files changed, 1844 insertions(+), 26 deletions(-) create mode 100644 docs/install/security-scanner-api.md create mode 100644 packages/bun-types/security.d.ts create mode 100644 src/install/PackageManager/security_scanner.zig create mode 100644 test/cli/install/bun-install-security-provider.test.ts create mode 100644 test/cli/install/depends-on-monkey-0.0.2.tgz create mode 100644 test/cli/install/monkey-0.0.2.tgz create mode 100644 test/integration/bun-types/fixture/install.ts diff --git a/cmake/sources/ZigSources.txt b/cmake/sources/ZigSources.txt index a0732011a5..f112c64494 100644 --- a/cmake/sources/ZigSources.txt +++ b/cmake/sources/ZigSources.txt @@ -730,6 +730,7 @@ src/install/PackageManager/patchPackage.zig src/install/PackageManager/processDependencyList.zig src/install/PackageManager/ProgressStrings.zig src/install/PackageManager/runTasks.zig +src/install/PackageManager/security_scanner.zig src/install/PackageManager/updatePackageJSONAndInstall.zig src/install/PackageManager/UpdateRequest.zig src/install/PackageManager/WorkspacePackageJSONCache.zig diff --git a/docs/install/security-scanner-api.md b/docs/install/security-scanner-api.md new file mode 100644 index 0000000000..f85be61986 --- /dev/null +++ b/docs/install/security-scanner-api.md @@ -0,0 +1,81 @@ +Bun's package manager can scan packages for security vulnerabilities before installation, helping protect your applications from supply chain attacks and known vulnerabilities. + +## Quick Start + +Configure a security scanner in your `bunfig.toml`: + +```toml +[install.security] +scanner = "@acme/bun-security-scanner" +``` + +When configured, Bun will: + +- Scan all packages before installation +- Display security warnings and advisories +- Cancel installation if critical vulnerabilities are found +- Automatically disable auto-install for security + +## How It Works + +Security scanners analyze packages during `bun install`, `bun add`, and other package operations. They can detect: + +- Known security vulnerabilities (CVEs) +- Malicious packages +- License compliance issues +- ...and more! + +### Security Levels + +Scanners report issues at two severity levels: + +- **`fatal`** - Installation stops immediately, exits with non-zero code +- **`warn`** - In interactive terminals, prompts to continue; in CI, exits immediately + +## Using Pre-built Scanners + +Many security companies publish Bun security scanners as npm packages that you can install and use immediately. + +### Installing a Scanner + +Install a security scanner from npm: + +```bash +$ bun add -d @acme/bun-security-scanner +``` + +> **Note:** Consult your security scanner's documentation for their specific package name and installation instructions. Most scanners will be installed with `bun add`. + +### Configuring the Scanner + +After installation, configure it in your `bunfig.toml`: + +```toml +[install.security] +scanner = "@acme/bun-security-scanner" +``` + +### Enterprise Configuration + +Some enterprise scanners might support authentication and/or configuration through environment variables: + +```bash +# This might go in ~/.bashrc, for example +export SECURITY_API_KEY="your-api-key" + +# The scanner will now use these credentials automatically +bun install +``` + +Consult your security scanner's documentation to learn which environment variables to set and if any additional configuration is required. + +### Authoring your own scanner + +For a complete example with tests and CI setup, see the official template: +[github.com/oven-sh/security-scanner-template](https://github.com/oven-sh/security-scanner-template) + +## Related + +- [Configuration (bunfig.toml)](/docs/runtime/bunfig#installsecurityscanner) +- [Package Manager](/docs/install) +- [Security Scanner Template](https://github.com/oven-sh/security-scanner-template) diff --git a/docs/runtime/bunfig.md b/docs/runtime/bunfig.md index dca1be6569..c4bce6c3db 100644 --- a/docs/runtime/bunfig.md +++ b/docs/runtime/bunfig.md @@ -496,6 +496,32 @@ Whether to generate a non-Bun lockfile alongside `bun.lock`. (A `bun.lock` will print = "yarn" ``` +### `install.security.scanner` + +Configure a security scanner to scan packages for vulnerabilities before installation. + +First, install a security scanner from npm: + +```bash +$ bun add -d @acme/bun-security-scanner +``` + +Then configure it in your `bunfig.toml`: + +```toml +[install.security] +scanner = "@acme/bun-security-scanner" +``` + +When a security scanner is configured: + +- Auto-install is automatically disabled for security +- Packages are scanned before installation +- Installation is cancelled if fatal issues are found +- Security warnings are displayed during installation + +Learn more about [using and writing security scanners](/docs/install/security). + ### `install.linker` Configure the default linker strategy. Default `"hoisted"`. diff --git a/packages/bun-types/index.d.ts b/packages/bun-types/index.d.ts index 870e2ae463..a5430eeec9 100644 --- a/packages/bun-types/index.d.ts +++ b/packages/bun-types/index.d.ts @@ -22,6 +22,7 @@ /// /// /// +/// /// diff --git a/packages/bun-types/security.d.ts b/packages/bun-types/security.d.ts new file mode 100644 index 0000000000..38927ef8ff --- /dev/null +++ b/packages/bun-types/security.d.ts @@ -0,0 +1,101 @@ +declare module "bun" { + /** + * `bun install` security related declarations + */ + export namespace Security { + export interface Package { + /** + * The name of the package + */ + name: string; + + /** + * The resolved version to be installed that matches the requested range. + * + * This is the exact version string, **not** a range. + */ + version: string; + + /** + * The URL of the tgz of this package that Bun will download + */ + tarball: string; + + /** + * The range that was requested by the command + * + * This could be a tag like `beta` or a semver range like `>=4.0.0` + */ + requestedRange: string; + } + + /** + * Advisory represents the result of a security scan result of a package + */ + export interface Advisory { + /** + * Level represents the degree of danger for a security advisory + * + * Bun behaves differently depending on the values returned from the + * {@link Scanner.scan `scan()`} hook: + * + * > In any case, Bun *always* pretty prints *all* the advisories, + * > but... + * > + * > → if any **fatal**, Bun will immediately cancel the installation + * > and quit with a non-zero exit code + * > + * > → else if any **warn**, Bun will either ask the user if they'd like + * > to continue with the install if in a TTY environment, or + * > immediately exit if not. + */ + level: "fatal" | "warn"; + + /** + * The name of the package attempting to be installed. + */ + package: string; + + /** + * If available, this is a url linking to a CVE or report online so + * users can learn more about the advisory. + */ + url: string | null; + + /** + * If available, this is a brief description of the advisory that Bun + * will print to the user. + */ + description: string | null; + } + + export interface Scanner { + /** + * This is the version of the scanner implementation. It may change in + * future versions, so we will use this version to discriminate between + * such versions. It's entirely possible this API changes in the future + * so much that version 1 would no longer be supported. + * + * The version is required because third-party scanner package versions + * are inherently unrelated to Bun versions + */ + version: "1"; + + /** + * Perform an advisory check when a user ran `bun add + * [...packages]` or other related/similar commands. + * + * If this function throws an error, Bun will immediately stop the + * install process and print the error to the user. + * + * @param info An object containing an array of packages to be added. + * The package array will contain all proposed dependencies, including + * transitive ones. More simply, that means it will include dependencies + * of the packages the user wants to add. + * + * @returns A list of advisories. + */ + scan: (info: { packages: Package[] }) => Promise; + } + } +} diff --git a/src/api/schema.zig b/src/api/schema.zig index fffa54a0cb..02166e2861 100644 --- a/src/api/schema.zig +++ b/src/api/schema.zig @@ -3041,6 +3041,8 @@ pub const api = struct { node_linker: ?bun.install.PackageManager.Options.NodeLinker = null, + security_scanner: ?[]const u8 = null, + pub fn decode(reader: anytype) anyerror!BunInstall { var this = std.mem.zeroes(BunInstall); diff --git a/src/bun.js/api/bun/process.zig b/src/bun.js/api/bun/process.zig index 45c7e7d065..4951b68c7a 100644 --- a/src/bun.js/api/bun/process.zig +++ b/src/bun.js/api/bun/process.zig @@ -84,7 +84,7 @@ pub const ProcessExitHandler = struct { LifecycleScriptSubprocess, ShellSubprocess, ProcessHandle, - + SecurityScanSubprocess, SyncProcess, }, ); @@ -115,6 +115,10 @@ pub const ProcessExitHandler = struct { const subprocess = this.ptr.as(ShellSubprocess); subprocess.onProcessExit(process, status, rusage); }, + @field(TaggedPointer.Tag, @typeName(SecurityScanSubprocess)) => { + const subprocess = this.ptr.as(SecurityScanSubprocess); + subprocess.onProcessExit(process, status, rusage); + }, @field(TaggedPointer.Tag, @typeName(SyncProcess)) => { const subprocess = this.ptr.as(SyncProcess); if (comptime Environment.isPosix) { @@ -2246,10 +2250,12 @@ const bun = @import("bun"); const Environment = bun.Environment; const Output = bun.Output; const PosixSpawn = bun.spawn; -const LifecycleScriptSubprocess = bun.install.LifecycleScriptSubprocess; const Maybe = bun.sys.Maybe; const ShellSubprocess = bun.shell.ShellSubprocess; const uv = bun.windows.libuv; +const LifecycleScriptSubprocess = bun.install.LifecycleScriptSubprocess; +const SecurityScanSubprocess = bun.install.SecurityScanSubprocess; + const jsc = bun.jsc; const Subprocess = jsc.Subprocess; diff --git a/src/bunfig.zig b/src/bunfig.zig index fd5d2eae3f..0bda8a7fbc 100644 --- a/src/bunfig.zig +++ b/src/bunfig.zig @@ -609,6 +609,17 @@ pub const Bunfig = struct { install.link_workspace_packages = value; } } + + if (install_obj.get("security")) |security_obj| { + if (security_obj.data == .e_object) { + if (security_obj.get("scanner")) |scanner| { + try this.expectString(scanner); + install.security_scanner = try scanner.asStringCloned(allocator); + } + } else { + try this.addError(security_obj.loc, "Invalid security config, expected an object"); + } + } } if (json.get("run")) |run_expr| { diff --git a/src/install/PackageManager/PackageManagerOptions.zig b/src/install/PackageManager/PackageManagerOptions.zig index fef6415244..c1c9c4b596 100644 --- a/src/install/PackageManager/PackageManagerOptions.zig +++ b/src/install/PackageManager/PackageManagerOptions.zig @@ -71,6 +71,9 @@ depth: ?usize = null, /// isolated installs (pnpm-like) or hoisted installs (yarn-like, original) node_linker: NodeLinker = .auto, +// Security scanner module path +security_scanner: ?[]const u8 = null, + pub const PublishConfig = struct { access: ?Access = null, tag: string = "", @@ -279,6 +282,11 @@ pub fn load( this.node_linker = node_linker; } + if (config.security_scanner) |security_scanner| { + this.security_scanner = security_scanner; + this.do.prefetch_resolved_tarballs = false; + } + if (config.cafile) |cafile| { this.ca_file_name = cafile; } diff --git a/src/install/PackageManager/install_with_manager.zig b/src/install/PackageManager/install_with_manager.zig index 203d45e2e0..45181f339b 100644 --- a/src/install/PackageManager/install_with_manager.zig +++ b/src/install/PackageManager/install_with_manager.zig @@ -1,8 +1,8 @@ pub fn installWithManager( manager: *PackageManager, ctx: Command.Context, - root_package_json_contents: string, - original_cwd: string, + root_package_json_contents: []const u8, + original_cwd: []const u8, ) !void { const log_level = manager.options.log_level; @@ -563,7 +563,12 @@ pub fn installWithManager( return error.InstallFailed; } } + manager.verifyResolutions(log_level); + + if (manager.subcommand == .add and manager.options.security_scanner != null) { + try security_scanner.performSecurityScanAfterResolution(manager); + } } // append scripts to lockfile before generating new metahash @@ -987,8 +992,7 @@ fn printBlockedPackagesInfo(summary: *const PackageInstall.Summary, global: bool } } -const string = []const u8; - +const security_scanner = @import("./security_scanner.zig"); const std = @import("std"); const installHoistedPackages = @import("../hoisted_install.zig").installHoistedPackages; const installIsolatedPackages = @import("../isolated_install.zig").installIsolatedPackages; diff --git a/src/install/PackageManager/security_scanner.zig b/src/install/PackageManager/security_scanner.zig new file mode 100644 index 0000000000..97ad580d02 --- /dev/null +++ b/src/install/PackageManager/security_scanner.zig @@ -0,0 +1,749 @@ +const PackagePath = struct { + pkg_path: []PackageID, + dep_path: []DependencyID, +}; + +pub fn performSecurityScanAfterResolution(manager: *PackageManager) !void { + const security_scanner = manager.options.security_scanner orelse return; + + if (manager.options.dry_run or !manager.options.do.install_packages) return; + if (manager.update_requests.len == 0) { + Output.prettyErrorln("No update requests to scan", .{}); + return; + } + + if (manager.options.log_level == .verbose) { + Output.prettyErrorln("[SecurityProvider] Running at '{s}'", .{security_scanner}); + } + const start_time = std.time.milliTimestamp(); + + var pkg_dedupe: std.AutoArrayHashMap(PackageID, void) = .init(bun.default_allocator); + defer pkg_dedupe.deinit(); + + const QueueItem = struct { + pkg_id: PackageID, + dep_id: DependencyID, + pkg_path: std.ArrayList(PackageID), + dep_path: std.ArrayList(DependencyID), + }; + var ids_queue: std.fifo.LinearFifo(QueueItem, .Dynamic) = .init(bun.default_allocator); + defer ids_queue.deinit(); + + var package_paths = std.AutoArrayHashMap(PackageID, PackagePath).init(manager.allocator); + defer { + var iter = package_paths.iterator(); + while (iter.next()) |entry| { + manager.allocator.free(entry.value_ptr.pkg_path); + manager.allocator.free(entry.value_ptr.dep_path); + } + package_paths.deinit(); + } + + const pkgs = manager.lockfile.packages.slice(); + const pkg_names = pkgs.items(.name); + const pkg_resolutions = pkgs.items(.resolution); + const pkg_dependencies = pkgs.items(.dependencies); + + for (manager.update_requests) |req| { + for (0..pkgs.len) |_update_pkg_id| { + const update_pkg_id: PackageID = @intCast(_update_pkg_id); + + if (update_pkg_id != req.package_id) { + continue; + } + + if (pkg_resolutions[update_pkg_id].tag != .npm) { + continue; + } + + var update_dep_id: DependencyID = invalid_dependency_id; + var parent_pkg_id: PackageID = invalid_package_id; + + 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 and pkg_res.tag != .workspace) { + continue; + } + + const pkg_deps = pkg_dependencies[pkg_id]; + for (pkg_deps.begin()..pkg_deps.end()) |_dep_id| { + const dep_id: DependencyID = @intCast(_dep_id); + + const dep_pkg_id = manager.lockfile.buffers.resolutions.items[dep_id]; + + if (dep_pkg_id == invalid_package_id) { + continue; + } + + if (dep_pkg_id != update_pkg_id) { + continue; + } + + update_dep_id = dep_id; + parent_pkg_id = pkg_id; + break :update_dep_id; + } + } + + if (update_dep_id == invalid_dependency_id) { + continue; + } + + if ((try pkg_dedupe.getOrPut(update_pkg_id)).found_existing) { + continue; + } + + var initial_pkg_path = std.ArrayList(PackageID).init(manager.allocator); + // If this is a direct dependency from root, start with root package + if (parent_pkg_id != invalid_package_id) { + try initial_pkg_path.append(parent_pkg_id); + } + try initial_pkg_path.append(update_pkg_id); + var initial_dep_path = std.ArrayList(DependencyID).init(manager.allocator); + try initial_dep_path.append(update_dep_id); + + try ids_queue.writeItem(.{ + .pkg_id = update_pkg_id, + .dep_id = update_dep_id, + .pkg_path = initial_pkg_path, + .dep_path = initial_dep_path, + }); + } + } + + // For new packages being added via 'bun add', we just scan the update requests directly + // since they haven't been added to the lockfile yet + + var json_buf = std.ArrayList(u8).init(manager.allocator); + var writer = json_buf.writer(); + defer json_buf.deinit(); + + const string_buf = manager.lockfile.buffers.string_bytes.items; + + try writer.writeAll("[\n"); + + var first = true; + + while (ids_queue.readItem()) |item| { + defer item.pkg_path.deinit(); + defer item.dep_path.deinit(); + + const pkg_id = item.pkg_id; + const dep_id = item.dep_id; + + const pkg_path_copy = try manager.allocator.alloc(PackageID, item.pkg_path.items.len); + @memcpy(pkg_path_copy, item.pkg_path.items); + + const dep_path_copy = try manager.allocator.alloc(DependencyID, item.dep_path.items.len); + @memcpy(dep_path_copy, item.dep_path.items); + + try package_paths.put(pkg_id, .{ + .pkg_path = pkg_path_copy, + .dep_path = dep_path_copy, + }); + + const pkg_name = pkg_names[pkg_id]; + const pkg_res = pkg_resolutions[pkg_id]; + const dep_version = manager.lockfile.buffers.dependencies.items[dep_id].version; + + if (!first) try writer.writeAll(",\n"); + + try writer.print( + \\ {{ + \\ "name": {}, + \\ "version": "{s}", + \\ "requestedRange": {}, + \\ "tarball": {} + \\ }} + , .{ bun.fmt.formatJSONStringUTF8(pkg_name.slice(string_buf), .{}), pkg_res.value.npm.version.fmt(string_buf), bun.fmt.formatJSONStringUTF8(dep_version.literal.slice(string_buf), .{}), bun.fmt.formatJSONStringUTF8(pkg_res.value.npm.url.slice(string_buf), .{}) }); + + first = false; + + // then go through it's dependencies and queue them up if + // valid and first time we've seen them + const pkg_deps = pkg_dependencies[pkg_id]; + + for (pkg_deps.begin()..pkg_deps.end()) |_next_dep_id| { + const next_dep_id: DependencyID = @intCast(_next_dep_id); + + const next_pkg_id = manager.lockfile.buffers.resolutions.items[next_dep_id]; + if (next_pkg_id == invalid_package_id) { + continue; + } + + const next_pkg_res = pkg_resolutions[next_pkg_id]; + if (next_pkg_res.tag != .npm) { + continue; + } + + if ((try pkg_dedupe.getOrPut(next_pkg_id)).found_existing) { + continue; + } + + var extended_pkg_path = std.ArrayList(PackageID).init(manager.allocator); + try extended_pkg_path.appendSlice(item.pkg_path.items); + try extended_pkg_path.append(next_pkg_id); + + var extended_dep_path = std.ArrayList(DependencyID).init(manager.allocator); + try extended_dep_path.appendSlice(item.dep_path.items); + try extended_dep_path.append(next_dep_id); + + try ids_queue.writeItem(.{ + .pkg_id = next_pkg_id, + .dep_id = next_dep_id, + .pkg_path = extended_pkg_path, + .dep_path = extended_dep_path, + }); + } + } + + try writer.writeAll("\n]"); + + var code_buf = std.ArrayList(u8).init(manager.allocator); + defer code_buf.deinit(); + var code_writer = code_buf.writer(); + + try code_writer.print( + \\let scanner; + \\const scannerModuleName = '{s}'; + \\const packages = {s}; + \\ + \\try {{ + \\ scanner = (await import(scannerModuleName)).scanner; + \\}} catch (error) {{ + \\ const msg = `\x1b[31merror: \x1b[0mFailed to import security scanner: \x1b[1m'${{scannerModuleName}}'\x1b[0m - if you use a security scanner from npm, please run '\x1b[36mbun install\x1b[0m' before adding other packages.`; + \\ console.error(msg); + \\ process.exit(1); + \\}} + \\ + \\try {{ + \\ if (typeof scanner !== 'object' || scanner === null || typeof scanner.version !== 'string') {{ + \\ throw new Error("Security scanner must export a 'scanner' object with a version property"); + \\ }} + \\ + \\ if (scanner.version !== '1') {{ + \\ throw new Error('Security scanner must be version 1'); + \\ }} + \\ + \\ if (typeof scanner.scan !== 'function') {{ + \\ throw new Error('scanner.scan is not a function, got ' + typeof scanner.scan); + \\ }} + \\ + \\ const result = await scanner.scan({{ packages }}); + \\ + \\ if (!Array.isArray(result)) {{ + \\ throw new Error('Security scanner must return an array of advisories'); + \\ }} + \\ + \\ const fs = require('fs'); + \\ const data = JSON.stringify({{advisories: result}}); + \\ for (let remaining = data; remaining.length > 0;) {{ + \\ const written = fs.writeSync(3, remaining); + \\ if (written === 0) process.exit(1); + \\ remaining = remaining.slice(written); + \\ }} + \\ fs.closeSync(3); + \\ + \\ process.exit(0); + \\}} catch (error) {{ + \\ console.error(error); + \\ process.exit(1); + \\}} + , .{ security_scanner, json_buf.items }); + + var scanner = SecurityScanSubprocess.new(.{ + .manager = manager, + .code = try manager.allocator.dupe(u8, code_buf.items), + .json_data = try manager.allocator.dupe(u8, json_buf.items), + .ipc_data = undefined, + .stderr_data = undefined, + }); + + defer { + manager.allocator.free(scanner.code); + manager.allocator.free(scanner.json_data); + bun.destroy(scanner); + } + + try scanner.spawn(); + + var closure = struct { + scanner: *SecurityScanSubprocess, + + pub fn isDone(this: *@This()) bool { + return this.scanner.isDone(); + } + }{ .scanner = scanner }; + + manager.sleepUntil(&closure, &@TypeOf(closure).isDone); + + const packages_scanned = pkg_dedupe.count(); + try scanner.handleResults(&package_paths, start_time, packages_scanned, security_scanner); +} + +const SecurityAdvisoryLevel = enum { fatal, warn }; + +const SecurityAdvisory = struct { + level: SecurityAdvisoryLevel, + package: []const u8, + url: ?[]const u8, + description: ?[]const u8, +}; + +pub const SecurityScanSubprocess = struct { + manager: *PackageManager, + code: []const u8, + json_data: []const u8, + process: ?*bun.spawn.Process = null, + ipc_reader: bun.io.BufferedReader = bun.io.BufferedReader.init(@This()), + ipc_data: std.ArrayList(u8), + stderr_data: std.ArrayList(u8), + has_process_exited: bool = false, + has_received_ipc: bool = false, + exit_status: ?bun.spawn.Status = null, + remaining_fds: i8 = 0, + + pub const new = bun.TrivialNew(@This()); + + pub fn spawn(this: *SecurityScanSubprocess) !void { + this.ipc_data = std.ArrayList(u8).init(this.manager.allocator); + this.stderr_data = std.ArrayList(u8).init(this.manager.allocator); + this.ipc_reader.setParent(this); + + const pipe_result = bun.sys.pipe(); + const pipe_fds = switch (pipe_result) { + .err => |err| { + Output.errGeneric("Failed to create IPC pipe: {s}", .{@tagName(err.getErrno())}); + Global.exit(1); + }, + .result => |fds| fds, + }; + + const exec_path = try bun.selfExePath(); + + var argv = [_]?[*:0]const u8{ + try this.manager.allocator.dupeZ(u8, exec_path), + "--no-install", + "-e", + try this.manager.allocator.dupeZ(u8, this.code), + null, + }; + defer { + this.manager.allocator.free(bun.span(argv[0].?)); + this.manager.allocator.free(bun.span(argv[3].?)); + } + + const spawn_options = bun.spawn.SpawnOptions{ + .stdout = .inherit, + .stderr = .inherit, + .stdin = .inherit, + .cwd = FileSystem.instance.top_level_dir, + .extra_fds = &.{.{ .pipe = pipe_fds[1] }}, + .windows = if (Environment.isWindows) .{ + .loop = jsc.EventLoopHandle.init(&this.manager.event_loop), + }, + }; + + var spawned = try (try bun.spawn.spawnProcess(&spawn_options, @ptrCast(&argv), @ptrCast(std.os.environ.ptr))).unwrap(); + + pipe_fds[1].close(); + + if (comptime bun.Environment.isPosix) { + _ = bun.sys.setNonblocking(pipe_fds[0]); + } + this.remaining_fds = 1; + this.ipc_reader.flags.nonblocking = true; + if (comptime bun.Environment.isPosix) { + this.ipc_reader.flags.socket = false; + } + try this.ipc_reader.start(pipe_fds[0], true).unwrap(); + + var process = spawned.toProcess(&this.manager.event_loop, false); + this.process = process; + process.setExitHandler(this); + + switch (process.watchOrReap()) { + .err => |err| { + Output.errGeneric("Failed to watch security scanner process: {}", .{err}); + Global.exit(1); + }, + .result => {}, + } + } + + pub fn isDone(this: *SecurityScanSubprocess) bool { + return this.has_process_exited and this.remaining_fds == 0; + } + + pub fn eventLoop(this: *const SecurityScanSubprocess) *jsc.AnyEventLoop { + return &this.manager.event_loop; + } + + pub fn loop(this: *const SecurityScanSubprocess) *bun.uws.Loop { + return this.manager.event_loop.loop(); + } + + pub fn onReaderDone(this: *SecurityScanSubprocess) void { + this.has_received_ipc = true; + this.remaining_fds -= 1; + } + + pub fn onReaderError(this: *SecurityScanSubprocess, err: bun.sys.Error) void { + Output.errGeneric("Failed to read security scanner IPC: {}", .{err}); + this.has_received_ipc = true; + this.remaining_fds -= 1; + } + + pub fn onStderrChunk(this: *SecurityScanSubprocess, chunk: []const u8) void { + this.stderr_data.appendSlice(chunk) catch bun.outOfMemory(); + } + + pub fn getReadBuffer(this: *SecurityScanSubprocess) []u8 { + const available = this.ipc_data.unusedCapacitySlice(); + if (available.len < 4096) { + this.ipc_data.ensureTotalCapacity(this.ipc_data.capacity + 4096) catch bun.outOfMemory(); + return this.ipc_data.unusedCapacitySlice(); + } + return available; + } + + pub fn onReadChunk(this: *SecurityScanSubprocess, chunk: []const u8, hasMore: bun.io.ReadState) bool { + _ = hasMore; + this.ipc_data.appendSlice(chunk) catch bun.outOfMemory(); + return true; + } + + pub fn onProcessExit(this: *SecurityScanSubprocess, _: *bun.spawn.Process, status: bun.spawn.Status, _: *const bun.spawn.Rusage) void { + this.has_process_exited = true; + this.exit_status = status; + + if (this.remaining_fds > 0 and !this.has_received_ipc) { + this.ipc_reader.deinit(); + this.remaining_fds = 0; + } + } + + pub fn handleResults(this: *SecurityScanSubprocess, package_paths: *std.AutoArrayHashMap(PackageID, PackagePath), start_time: i64, packages_scanned: usize, security_scanner: []const u8) !void { + defer { + this.ipc_data.deinit(); + this.stderr_data.deinit(); + } + + const status = this.exit_status orelse bun.spawn.Status{ .exited = .{ .code = 0 } }; + + if (this.ipc_data.items.len == 0) { + switch (status) { + .exited => |exit| { + if (exit.code != 0) { + Output.errGeneric("Security scanner exited with code {d} without sending data", .{exit.code}); + } else { + Output.errGeneric("Security scanner exited without sending any data", .{}); + } + }, + .signaled => |sig| { + Output.errGeneric("Security scanner terminated by signal {s} without sending data", .{@tagName(sig)}); + }, + else => { + Output.errGeneric("Security scanner terminated abnormally without sending data", .{}); + }, + } + Global.exit(1); + } + + const duration = std.time.milliTimestamp() - start_time; + + if (this.manager.options.log_level == .verbose) { + switch (status) { + .exited => |exit| { + if (exit.code == 0) { + Output.prettyErrorln("[SecurityProvider] Completed with exit code {d} [{d}ms]", .{ exit.code, duration }); + } else { + Output.prettyErrorln("[SecurityProvider] Failed with exit code {d} [{d}ms]", .{ exit.code, duration }); + } + }, + .signaled => |sig| { + Output.prettyErrorln("[SecurityProvider] Terminated by signal {s} [{d}ms]", .{ @tagName(sig), duration }); + }, + else => { + Output.prettyErrorln("[SecurityProvider] Completed with unknown status [{d}ms]", .{duration}); + }, + } + } else if (this.manager.options.log_level != .silent and duration >= 1000) { + const maybeHourglass = if (Output.isEmojiEnabled()) "⏳" else ""; + if (packages_scanned == 1) { + Output.prettyErrorln("{s}[{s}] Scanning 1 package took {d}ms", .{ maybeHourglass, security_scanner, duration }); + } else { + Output.prettyErrorln("{s}[{s}] Scanning {d} packages took {d}ms", .{ maybeHourglass, security_scanner, packages_scanned, duration }); + } + } + + try handleSecurityAdvisories(this.manager, this.ipc_data.items, package_paths); + + if (!status.isOK()) { + switch (status) { + .exited => |exited| { + if (exited.code != 0) { + Output.errGeneric("Security scanner failed with exit code: {d}", .{exited.code}); + Global.exit(1); + } + }, + .signaled => |signal| { + Output.errGeneric("Security scanner was terminated by signal: {s}", .{@tagName(signal)}); + Global.exit(1); + }, + else => { + Output.errGeneric("Security scanner failed", .{}); + Global.exit(1); + }, + } + } + } +}; + +fn handleSecurityAdvisories(manager: *PackageManager, ipc_data: []const u8, package_paths: *std.AutoArrayHashMap(PackageID, PackagePath)) !void { + if (ipc_data.len == 0) return; + + const json_source = logger.Source{ + .contents = ipc_data, + .path = bun.fs.Path.init("security-advisories.json"), + }; + + var temp_log = logger.Log.init(manager.allocator); + defer temp_log.deinit(); + + const json_expr = bun.json.parseUTF8(&json_source, &temp_log, manager.allocator) catch |err| { + Output.errGeneric("Security scanner returned invalid JSON: {s}", .{@errorName(err)}); + if (ipc_data.len < 1000) { + // If the response is reasonably small, show it to help debugging + Output.errGeneric("Response: {s}", .{ipc_data}); + } + if (temp_log.errors > 0) { + temp_log.print(Output.errorWriter()) catch {}; + } + Global.exit(1); + }; + + var advisories_list = std.ArrayList(SecurityAdvisory).init(manager.allocator); + defer advisories_list.deinit(); + + if (json_expr.data != .e_object) { + Output.errGeneric("Security scanner response must be a JSON object, got: {s}", .{@tagName(json_expr.data)}); + Global.exit(1); + } + + const obj = json_expr.data.e_object; + + const advisories_expr = obj.get("advisories") orelse { + Output.errGeneric("Security scanner response missing required 'advisories' field", .{}); + Global.exit(1); + }; + + if (advisories_expr.data != .e_array) { + Output.errGeneric("Security scanner 'advisories' field must be an array, got: {s}", .{@tagName(advisories_expr.data)}); + Global.exit(1); + } + + const array = advisories_expr.data.e_array; + for (array.items.slice(), 0..) |item, i| { + if (item.data != .e_object) { + Output.errGeneric("Security advisory at index {d} must be an object, got: {s}", .{ i, @tagName(item.data) }); + Global.exit(1); + } + + const item_obj = item.data.e_object; + + const name_expr = item_obj.get("package") orelse { + Output.errGeneric("Security advisory at index {d} missing required 'package' field", .{i}); + Global.exit(1); + }; + const name_str = name_expr.asString(manager.allocator) orelse { + Output.errGeneric("Security advisory at index {d} 'package' field must be a string", .{i}); + Global.exit(1); + }; + if (name_str.len == 0) { + Output.errGeneric("Security advisory at index {d} 'package' field cannot be empty", .{i}); + Global.exit(1); + } + + const desc_str: ?[]const u8 = if (item_obj.get("description")) |desc_expr| blk: { + if (desc_expr.asString(manager.allocator)) |str| break :blk str; + if (desc_expr.data == .e_null) break :blk null; + Output.errGeneric("Security advisory at index {d} 'description' field must be a string or null", .{i}); + Global.exit(1); + } else null; + + const url_str: ?[]const u8 = if (item_obj.get("url")) |url_expr| blk: { + if (url_expr.asString(manager.allocator)) |str| break :blk str; + if (url_expr.data == .e_null) break :blk null; + Output.errGeneric("Security advisory at index {d} 'url' field must be a string or null", .{i}); + Global.exit(1); + } else null; + + const level_expr = item_obj.get("level") orelse { + Output.errGeneric("Security advisory at index {d} missing required 'level' field", .{i}); + Global.exit(1); + }; + const level_str = level_expr.asString(manager.allocator) orelse { + Output.errGeneric("Security advisory at index {d} 'level' field must be a string", .{i}); + Global.exit(1); + }; + const level = if (std.mem.eql(u8, level_str, "fatal")) + SecurityAdvisoryLevel.fatal + else if (std.mem.eql(u8, level_str, "warn")) + SecurityAdvisoryLevel.warn + else { + Output.errGeneric("Security advisory at index {d} 'level' field must be 'fatal' or 'warn', got: '{s}'", .{ i, level_str }); + Global.exit(1); + }; + + const advisory = SecurityAdvisory{ + .level = level, + .package = name_str, + .url = url_str, + .description = desc_str, + }; + + try advisories_list.append(advisory); + } + + if (advisories_list.items.len > 0) { + var has_fatal = false; + var has_warn = false; + + for (advisories_list.items) |advisory| { + Output.print("\n", .{}); + + switch (advisory.level) { + .fatal => { + has_fatal = true; + Output.pretty(" FATAL: {s}\n", .{advisory.package}); + }, + .warn => { + has_warn = true; + Output.pretty(" WARN: {s}\n", .{advisory.package}); + }, + } + + const pkgs = manager.lockfile.packages.slice(); + const pkg_names = pkgs.items(.name); + const string_buf = manager.lockfile.buffers.string_bytes.items; + + var found_pkg_id: ?PackageID = null; + for (pkg_names, 0..) |pkg_name, i| { + if (std.mem.eql(u8, pkg_name.slice(string_buf), advisory.package)) { + found_pkg_id = @intCast(i); + break; + } + } + + if (found_pkg_id) |pkg_id| { + if (package_paths.get(pkg_id)) |paths| { + if (paths.pkg_path.len > 1) { + Output.pretty(" via ", .{}); + for (paths.pkg_path[0 .. paths.pkg_path.len - 1], 0..) |ancestor_id, idx| { + if (idx > 0) Output.pretty(" › ", .{}); + const ancestor_name = pkg_names[ancestor_id].slice(string_buf); + Output.pretty("{s}", .{ancestor_name}); + } + Output.pretty(" › {s}\n", .{advisory.package}); + } else { + Output.pretty(" (direct dependency)\n", .{}); + } + } + } + + if (advisory.description) |desc| { + if (desc.len > 0) { + Output.pretty(" {s}\n", .{desc}); + } + } + if (advisory.url) |url| { + if (url.len > 0) { + Output.pretty(" {s}\n", .{url}); + } + } + } + + if (has_fatal) { + Output.pretty("\nbun install aborted due to fatal security advisories\n", .{}); + Global.exit(1); + } else if (has_warn) { + const can_prompt = Output.enable_ansi_colors_stdout; + + if (can_prompt) { + Output.pretty("\nSecurity warnings found. Continue anyway? [y/N] ", .{}); + Output.flush(); + + var stdin = std.io.getStdIn(); + const unbuffered_reader = stdin.reader(); + var buffered = std.io.bufferedReader(unbuffered_reader); + var reader = buffered.reader(); + + const first_byte = reader.readByte() catch { + Output.pretty("\nInstallation cancelled.\n", .{}); + Global.exit(1); + }; + + const should_continue = switch (first_byte) { + '\n' => false, + '\r' => blk: { + const next_byte = reader.readByte() catch { + break :blk false; + }; + break :blk next_byte == '\n' and false; + }, + 'y', 'Y' => blk: { + const next_byte = reader.readByte() catch { + break :blk false; + }; + if (next_byte == '\n') { + break :blk true; + } else if (next_byte == '\r') { + const second_byte = reader.readByte() catch { + break :blk false; + }; + break :blk second_byte == '\n'; + } + break :blk false; + }, + else => blk: { + while (reader.readByte()) |b| { + if (b == '\n' or b == '\r') break; + } else |_| {} + break :blk false; + }, + }; + + if (!should_continue) { + Output.pretty("\nInstallation cancelled.\n", .{}); + Global.exit(1); + } + + Output.pretty("\nContinuing with installation...\n\n", .{}); + } else { + Output.pretty("\nSecurity warnings found. Cannot prompt for confirmation (no TTY).\n", .{}); + Output.pretty("Installation cancelled.\n", .{}); + Global.exit(1); + } + } + } +} + +const std = @import("std"); + +const bun = @import("bun"); +const Environment = bun.Environment; +const Global = bun.Global; +const Output = bun.Output; +const jsc = bun.jsc; +const logger = bun.logger; +const FileSystem = bun.fs.FileSystem; + +const DependencyID = bun.install.DependencyID; +const PackageID = bun.install.PackageID; +const PackageManager = bun.install.PackageManager; +const invalid_dependency_id = bun.install.invalid_dependency_id; +const invalid_package_id = bun.install.invalid_package_id; diff --git a/src/install/PackageManager/updatePackageJSONAndInstall.zig b/src/install/PackageManager/updatePackageJSONAndInstall.zig index e4288b9f11..3e508aa4c4 100644 --- a/src/install/PackageManager/updatePackageJSONAndInstall.zig +++ b/src/install/PackageManager/updatePackageJSONAndInstall.zig @@ -55,6 +55,7 @@ fn updatePackageJSONAndInstallWithManagerWithUpdatesAndUpdateRequests( original_cwd, ); } + fn updatePackageJSONAndInstallWithManagerWithUpdates( manager: *PackageManager, ctx: Command.Context, diff --git a/src/install/install.zig b/src/install/install.zig index 8bbf037656..3929bf86b7 100644 --- a/src/install/install.zig +++ b/src/install/install.zig @@ -247,6 +247,7 @@ pub const TextLockfile = @import("./lockfile/bun.lock.zig"); pub const Bin = @import("./bin.zig").Bin; pub const FolderResolution = @import("./resolvers/folder_resolver.zig").FolderResolution; pub const LifecycleScriptSubprocess = @import("./lifecycle_script_runner.zig").LifecycleScriptSubprocess; +pub const SecurityScanSubprocess = @import("./PackageManager/security_scanner.zig").SecurityScanSubprocess; pub const PackageInstall = @import("./PackageInstall.zig").PackageInstall; pub const Repository = @import("./repository.zig").Repository; pub const Resolution = @import("./resolution.zig").Resolution; diff --git a/src/js/node/readline.ts b/src/js/node/readline.ts index e8732930f3..9cf88d4702 100644 --- a/src/js/node/readline.ts +++ b/src/js/node/readline.ts @@ -1236,6 +1236,9 @@ var _Interface = class Interface extends InterfaceConstructor { constructor(input, output, completer, terminal) { super(input, output, completer, terminal); } + [Symbol.dispose]() { + this.close(); + } get columns() { var output = this.output; if (output && output.columns) return output.columns; @@ -2532,6 +2535,7 @@ Interface.prototype._getDisplayPos = _Interface.prototype[kGetDisplayPos]; Interface.prototype._getCursorPos = _Interface.prototype.getCursorPos; Interface.prototype._moveCursor = _Interface.prototype[kMoveCursor]; Interface.prototype._ttyWrite = _Interface.prototype[kTtyWrite]; +Interface.prototype[Symbol.dispose] = _Interface.prototype[Symbol.dispose]; function _ttyWriteDumb(s, key) { key = key || kEmptyObject; diff --git a/test/cli/install/bun-install-security-provider.test.ts b/test/cli/install/bun-install-security-provider.test.ts new file mode 100644 index 0000000000..9645815ad2 --- /dev/null +++ b/test/cli/install/bun-install-security-provider.test.ts @@ -0,0 +1,679 @@ +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; + 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:"); + }, + }); +}); diff --git a/test/cli/install/depends-on-monkey-0.0.2.tgz b/test/cli/install/depends-on-monkey-0.0.2.tgz new file mode 100644 index 0000000000000000000000000000000000000000..c4d31ba868e773d5cbda34772741bbf83a0507ff GIT binary patch literal 485 zcmVFlY`{I80SqBzSmY% zW8H}AD5j8@*w9TG=@eojBMKl+a7+Vk!ww7NP}ogtY@STJxEOOknhG4$|AmFsMKA|I zg*ouB3hS^>uMI58qRY~jrFZ@Ovq##s+DSj`^>~tHc_=0G(Z1q%E@Hla!;v3&cU&;x zaFCe;2W#u|uP;mC6eg+ojw9CH Response | Promise; @@ -25,6 +25,17 @@ export let package_dir: string; export let requested: number; export let root_url: string; export let check_npm_auth_type = { check: true }; + +export async function write(path: string, content: string | object) { + if (!package_dir) throw new Error("writeToPackageDir() must be called in a test"); + + await Bun.write(join(package_dir, path), typeof content === "string" ? content : JSON.stringify(content)); +} + +export function read(path: string) { + return Bun.file(join(package_dir, path)); +} + export function dummyRegistry(urls: string[], info: any = { "0.0.2": {} }, numberOfTimesTo500PerURL = 0) { let retryCountsByURL = new Map(); const _handler: Handler = async request => { @@ -79,9 +90,7 @@ export function dummyRegistry(urls: string[], info: any = { "0.0.2": {} }, numbe latest: info.latest ?? version, }, }), - { - status: status, - }, + { status }, ); }; return _handler; diff --git a/test/cli/install/monkey-0.0.2.tgz b/test/cli/install/monkey-0.0.2.tgz new file mode 100644 index 0000000000000000000000000000000000000000..f1aeee8e7197650b366c9a3e6108e28541e4740e GIT binary patch literal 466 zcmV;@0WJO?iwFS7sETL+1MQdHPQox0$BV`o@LJ<#vrA)2yRBO=;bIaKNWc#u2Cqzm z1rfHvh6H1L5AS^f-^~ZG2tzXvG+`K_zvQH6UAvyP`L}22y3MLhR_7o(FvcdO@S`+_ zC06xeIrKG1DArXfJc1!6DkUa>IKr65-1}YTiBMRpF|(a$R;$f;Kb#62m;Z&Wy=|}o zK!H`^SqWC)h(R5Q3sZ%4gbkiwe)dVLTrQ5>L62u)JE11A&-&e}Ya`Zfw>WBhZjU?6 zsd5n80H^y0n{Tgie*u$}e8)NKXF0p!UNX9KV6OhL8PdOSH30h^E}>t9VlyW6mN znwV5e5!28yN>PItVp9^-g!$aDRm@~_mnW#tx?K-VQ)}9~$XVsvddEtZ`k6{0p{HO; zbjn}N>hwk8iN9WkwPmFGWRzpf)Biv(q5EH>6hInfPyuVM|6u=7kO!hW-*px{hFA|n z*MF1$QA}n2w;W{tx7GyupU49jcn-|fKM9}zhJmTf|CWJ2c3*xQ?j82qRT_lG>C=B$5^69aJ=; IIRF{}01Kkxn*aa+ literal 0 HcmV?d00001 diff --git a/test/harness.ts b/test/harness.ts index 71ccbbd854..c86d971c86 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -1165,20 +1165,20 @@ export function tmpdirSync(pattern: string = "bun.test."): string { export async function runBunInstall( env: NodeJS.Dict, cwd: string, - options?: { + options: { allowWarnings?: boolean; allowErrors?: boolean; - expectedExitCode?: number; + expectedExitCode?: number | null; savesLockfile?: boolean; production?: boolean; frozenLockfile?: boolean; saveTextLockfile?: boolean; packages?: string[]; verbose?: boolean; - }, + } = {}, ) { const production = options?.production ?? false; - const args = production ? [bunExe(), "install", "--production"] : [bunExe(), "install"]; + const args = [bunExe(), "install"]; if (options?.packages) { args.push(...options.packages); } @@ -1204,7 +1204,7 @@ export async function runBunInstall( }); expect(stdout).toBeDefined(); expect(stderr).toBeDefined(); - let err = stderrForInstall(await stderr.text()); + let err: string = stderrForInstall(await stderr.text()); expect(err).not.toContain("panic:"); if (!options?.allowErrors) { expect(err).not.toContain("error:"); @@ -1215,7 +1215,7 @@ export async function runBunInstall( if ((options?.savesLockfile ?? true) && !production && !options?.frozenLockfile) { expect(err).toContain("Saved lockfile"); } - let out = await stdout.text(); + let out: string = await stdout.text(); expect(await exited).toBe(options?.expectedExitCode ?? 0); return { out, err, exited }; } @@ -1781,6 +1781,9 @@ export function normalizeBunSnapshot(snapshot: string, optionalDir?: string) { // line numbers in stack traces like at FunctionName (NN:NN) // it must specifically look at the stacktrace format .replace(/^\s+at (.*?)\(.*?:\d+(?::\d+)?\)/gm, " at $1(file:NN:NN)") + // Handle version strings in error messages like "Bun v1.2.21+revision (platform arch)" + // This needs to come before the other version replacements + .replace(/Bun v[\d.]+(?:-[\w.]+)?(?:\+[\w]+)?(?:\s+\([^)]+\))?/g, "Bun v") .replaceAll(Bun.version_with_sha, " ()") .replaceAll(Bun.version, "") .replaceAll(Bun.revision, "") diff --git a/test/integration/bun-types/fixture/install.ts b/test/integration/bun-types/fixture/install.ts new file mode 100644 index 0000000000..0c98ff8c4c --- /dev/null +++ b/test/integration/bun-types/fixture/install.ts @@ -0,0 +1,44 @@ +// This is (for now) very loose implementation reference, mostly type testing + +import { expectType } from "./utilities"; + +const mySecurityScanner: Bun.Security.Scanner = { + version: "1", + scan: async ({ packages }) => { + const response = await fetch("https://threat-feed.example.com"); + + if (!response.ok) { + throw new Error("Unable to fetch threat feed"); + } + + // Would recommend using a schema library or something to validate here. You + // should throw if the parsing fails rather than returning no advisories, + // this code needs to be defensive... + const myThreatFeed = (await response.json()) as Array<{ + package: string; + version: string; + url: string; + description: string; + category: "unhealthy" | "spam" | "malware"; // Imagine some other categories... + }>; + + return myThreatFeed.flatMap((threat): Bun.Security.Advisory[] => { + const match = packages.some(p => p.name === threat.package && p.version === threat.version); + + if (!match) { + return []; + } + + return [ + { + level: threat.category === "malware" ? "fatal" : "warn", + package: threat.package, + url: threat.url, + description: threat.description, + }, + ]; + }); + }, +}; + +expectType(mySecurityScanner).toBeDefined(); diff --git a/test/integration/bun-types/fixture/utilities.ts b/test/integration/bun-types/fixture/utilities.ts index 609a9188f7..d1775ba851 100644 --- a/test/integration/bun-types/fixture/utilities.ts +++ b/test/integration/bun-types/fixture/utilities.ts @@ -29,6 +29,7 @@ export function expectType(arg: T): { */ is(...args: IfEquals extends true ? [] : [expected: X, but_got: T]): void; extends(...args: T extends X ? [] : [expected: T, but_got: X]): void; + toBeDefined(...args: undefined extends T ? [expected_something_but_got: undefined] : []): void; }; export function expectType(arg?: T) { diff --git a/test/js/bun/test/jest.d.ts b/test/js/bun/test/jest.d.ts index 700f8c9679..dde111abb5 100644 --- a/test/js/bun/test/jest.d.ts +++ b/test/js/bun/test/jest.d.ts @@ -1,10 +1 @@ -declare var jest: typeof import("bun:test").jest; -declare var describe: typeof import("bun:test").describe; -declare var test: typeof import("bun:test").test; -declare var expect: typeof import("bun:test").expect; -declare var expectTypeOf: typeof import("bun:test").expectTypeOf; -declare var it: typeof import("bun:test").it; -declare var beforeEach: typeof import("bun:test").beforeEach; -declare var afterEach: typeof import("bun:test").afterEach; -declare var beforeAll: typeof import("bun:test").beforeAll; -declare var afterAll: typeof import("bun:test").afterAll; +/// diff --git a/test/js/node/readline/readline.node.test.ts b/test/js/node/readline/readline.node.test.ts index 3ac25c6c69..32a07080a6 100644 --- a/test/js/node/readline/readline.node.test.ts +++ b/test/js/node/readline/readline.node.test.ts @@ -2038,4 +2038,52 @@ describe("readline.createInterface()", () => { // rl.write("text"); // rl.write(null, { ctrl: true, name: "c" }); // }); + + it("should support Symbol.dispose for using statements", () => { + const input = new PassThrough(); + const output = new PassThrough(); + let closed = false; + + { + using rl = readline.createInterface({ + input: input, + output: output, + }); + + rl.on("close", () => { + closed = true; + }); + + // Verify the interface has the Symbol.dispose method + assert.strictEqual(typeof rl[Symbol.dispose], "function"); + assert.strictEqual(!closed, true); + } + + // After exiting the using block, the interface should be closed + assert.strictEqual(closed, true); + }); + + it("should support Symbol.dispose as alias for close()", () => { + const input = new PassThrough(); + const output = new PassThrough(); + let closed = false; + + const rl = readline.createInterface({ + input: input, + output: output, + }); + + rl.on("close", () => { + closed = true; + }); + + // Verify Symbol.dispose exists and works the same as close() + assert.strictEqual(typeof rl[Symbol.dispose], "function"); + assert.strictEqual(!closed, true); + + rl[Symbol.dispose](); + + assert.strictEqual(closed, true); + assert.strictEqual(rl.closed, true); + }); }); diff --git a/test/js/node/readline/readline_promises.node.test.ts b/test/js/node/readline/readline_promises.node.test.ts index a2b02ae2ca..97f9d6f3af 100644 --- a/test/js/node/readline/readline_promises.node.test.ts +++ b/test/js/node/readline/readline_promises.node.test.ts @@ -46,4 +46,50 @@ describe("readline/promises.createInterface()", () => { done(); }); }); + + it("should support Symbol.dispose for using statements", () => { + const fi = new FakeInput(); + let closed = false; + + { + using rl = readlinePromises.createInterface({ + input: fi, + output: fi, + }); + + rl.on("close", () => { + closed = true; + }); + + // Verify the interface has the Symbol.dispose method + assert.strictEqual(typeof rl[Symbol.dispose], "function"); + assert.strictEqual(!closed, true); + } + + // After exiting the using block, the interface should be closed + assert.strictEqual(closed, true); + }); + + it("should support Symbol.dispose as alias for close()", () => { + const fi = new FakeInput(); + let closed = false; + + const rl = readlinePromises.createInterface({ + input: fi, + output: fi, + }); + + rl.on("close", () => { + closed = true; + }); + + // Verify Symbol.dispose exists and works the same as close() + assert.strictEqual(typeof rl[Symbol.dispose], "function"); + assert.strictEqual(!closed, true); + + rl[Symbol.dispose](); + + assert.strictEqual(closed, true); + assert.strictEqual(rl.closed, true); + }); }); diff --git a/test/tsconfig.json b/test/tsconfig.json index 78db322a6d..7104437b91 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../tsconfig.base.json", "compilerOptions": { + "lib": ["ESNext"], // Path remapping "baseUrl": ".", "paths": {