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 0000000000..c4d31ba868
Binary files /dev/null and b/test/cli/install/depends-on-monkey-0.0.2.tgz differ
diff --git a/test/cli/install/dummy.registry.ts b/test/cli/install/dummy.registry.ts
index 566ebe73be..8d8cbba7bc 100644
--- a/test/cli/install/dummy.registry.ts
+++ b/test/cli/install/dummy.registry.ts
@@ -8,7 +8,7 @@ import { tmpdirSync } from "harness";
let expect: (typeof import("bun:test"))["expect"];
-import { readdir, writeFile } from "fs/promises";
+import { writeFile } from "fs/promises";
import { basename, join } from "path";
type Handler = (req: Request) => 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 0000000000..f1aeee8e71
Binary files /dev/null and b/test/cli/install/monkey-0.0.2.tgz differ
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": {