diff --git a/cmake/sources/ZigSources.txt b/cmake/sources/ZigSources.txt index dfa050738a..107333e42c 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.md b/docs/install/security.md index e458e504f3..fa89d25ebb 100644 --- a/docs/install/security.md +++ b/docs/install/security.md @@ -1,4 +1,4 @@ -# Security Scanners +# Security Scanner API Bun's package manager can scan packages for security vulnerabilities before installation, helping protect your applications from supply chain attacks and known vulnerabilities. diff --git a/docs/runtime/bunfig.md b/docs/runtime/bunfig.md index 7c5795fd98..8af289c7af 100644 --- a/docs/runtime/bunfig.md +++ b/docs/runtime/bunfig.md @@ -496,7 +496,7 @@ Whether to generate a non-Bun lockfile alongside `bun.lock`. (A `bun.lock` will print = "yarn" ``` -### `install.security.provider` +### `install.security.scanner` Configure a security provider to scan packages for vulnerabilities before installation. @@ -510,7 +510,7 @@ Then configure it in your `bunfig.toml`: ```toml [install.security] -provider = "@acme/bun-security-provider" +scanner = "@acme/bun-security-provider" ``` When a security provider is configured: diff --git a/src/api/schema.zig b/src/api/schema.zig index 2f929b7a14..02166e2861 100644 --- a/src/api/schema.zig +++ b/src/api/schema.zig @@ -3041,7 +3041,7 @@ pub const api = struct { node_linker: ?bun.install.PackageManager.Options.NodeLinker = null, - security_provider: ?[]const u8 = null, + security_scanner: ?[]const u8 = null, pub fn decode(reader: anytype) anyerror!BunInstall { var this = std.mem.zeroes(BunInstall); diff --git a/src/bunfig.zig b/src/bunfig.zig index a4ecc1bcce..0bda8a7fbc 100644 --- a/src/bunfig.zig +++ b/src/bunfig.zig @@ -612,9 +612,9 @@ pub const Bunfig = struct { if (install_obj.get("security")) |security_obj| { if (security_obj.data == .e_object) { - if (security_obj.get("provider")) |provider| { - try this.expectString(provider); - install.security_provider = try provider.asStringCloned(allocator); + 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"); diff --git a/src/cli/Arguments.zig b/src/cli/Arguments.zig index 27440c8c67..e0556a97a1 100644 --- a/src/cli/Arguments.zig +++ b/src/cli/Arguments.zig @@ -650,32 +650,7 @@ pub fn parse(allocator: std.mem.Allocator, ctx: Command.Context, comptime cmd: C else Bunfig.OfflineMode.online; - const has_security_provider = if (ctx.install) |install| - install.security_provider != null - else - false; - - if (has_security_provider) { - if (args.flag("-i")) { - Output.prettyErrorln("warning: Autoinstall is disabled because a security provider is configured. The -i flag will be ignored.", .{}); - Output.flush(); - } else if (args.option("--install")) |enum_value| { - const requested_mode = options.GlobalCache.Map.get(enum_value) orelse - (if (enum_value.len == 0) options.GlobalCache.force else null); - - if (requested_mode) |mode| { - if (mode != .disable) { - Output.prettyErrorln("warning: Autoinstall is disabled because a security provider is configured. The --install={s} flag will be ignored.", .{enum_value}); - Output.flush(); - } - } - } - - // disable autoinstall because it doesn't make sense we enable - // autoinstall when something designed to block installing packages - // exists... - ctx.debug.global_cache = .disable; - } else if (args.flag("--no-install")) { + if (args.flag("--no-install")) { ctx.debug.global_cache = .disable; } else if (args.flag("-i")) { ctx.debug.global_cache = .fallback; diff --git a/src/install/PackageManager/PackageManagerOptions.zig b/src/install/PackageManager/PackageManagerOptions.zig index 875fe1c0aa..793025510f 100644 --- a/src/install/PackageManager/PackageManagerOptions.zig +++ b/src/install/PackageManager/PackageManagerOptions.zig @@ -71,8 +71,8 @@ depth: ?usize = null, /// isolated installs (pnpm-like) or hoisted installs (yarn-like, original) node_linker: NodeLinker = .auto, -// Security provider module path -security_provider: ?[]const u8 = null, +// Security scanner module path +security_scanner: ?[]const u8 = null, pub const PublishConfig = struct { access: ?Access = null, @@ -282,8 +282,8 @@ pub fn load( this.node_linker = node_linker; } - if (config.security_provider) |security_provider| { - this.security_provider = security_provider; + if (config.security_scanner) |security_scanner| { + this.security_scanner = security_scanner; } if (config.cafile) |cafile| { diff --git a/src/install/PackageManager/install_with_manager.zig b/src/install/PackageManager/install_with_manager.zig index de22fbc032..d10c2adc0b 100644 --- a/src/install/PackageManager/install_with_manager.zig +++ b/src/install/PackageManager/install_with_manager.zig @@ -566,8 +566,8 @@ pub fn installWithManager( manager.verifyResolutions(log_level); - if (manager.subcommand == .add and manager.options.security_provider != null) { - try performSecurityScanAfterResolution(manager); + if (manager.subcommand == .add and manager.options.security_scanner != null) { + try security_scanner.performSecurityScanAfterResolution(manager); } } @@ -994,743 +994,10 @@ fn printBlockedPackagesInfo(summary: *const PackageInstall.Summary, global: bool const string = []const u8; -const PackagePath = struct { - pkg_path: []PackageID, - dep_path: []DependencyID, -}; - -fn performSecurityScanAfterResolution(manager: *PackageManager) !void { - const security_provider = manager.options.security_provider 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_provider}); - } - 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( - \\try {{ - \\ const {{provider}} = await import('{s}'); - \\ const packages = {s}; - \\ - \\ if (provider.version !== '1') {{ - \\ throw new Error('Security provider must be version 1'); - \\ }} - \\ - \\ if (typeof provider.scan !== 'function') {{ - \\ throw new Error('provider.scan is not a function'); - \\ }} - \\ - \\ const result = await provider.scan({{packages:packages}}); - \\ - \\ if (!Array.isArray(result)) {{ - \\ throw new Error('Security provider must return an array of advisories'); - \\ }} - \\ - \\ const fs = require('fs'); - \\ const data = JSON.stringify({{advisories: result}}); - \\ fs.writeSync(3, data); - \\ fs.closeSync(3); - \\ - \\ process.exit(0); - \\}} catch (error) {{ - \\ console.error(error); - \\ process.exit(1); - \\}} - , .{ security_provider, 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 progress_node: ?*Progress.Node = null; - if (manager.options.log_level != .verbose and manager.options.log_level != .silent) { - manager.progress.supports_ansi_escape_codes = Output.enable_ansi_colors_stderr; - const scanner_name = if (Output.isEmojiEnabled()) " 👮 Scanning packages with security provider" else "Scanning packages with security provider"; - progress_node = manager.progress.start(scanner_name, 0); - if (progress_node) |node| { - node.activate(); - manager.progress.refresh(); - } - } - - var closure = struct { - scanner: *SecurityScanSubprocess, - - pub fn isDone(this: *@This()) bool { - return this.scanner.isDone(); - } - }{ .scanner = scanner }; - - manager.sleepUntil(&closure, &@TypeOf(closure).isDone); - - if (progress_node) |node| { - node.end(); - manager.progress.refresh(); - manager.progress.root.end(); - manager.progress = .{}; - } - - const packages_scanned = pkg_dedupe.count(); - try scanner.handleResults(&package_paths, start_time, packages_scanned, security_provider); -} - -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), - "-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[2].?)); - } - - 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_provider: []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 provider exited with code {d} without sending data", .{exit.code}); - } else { - Output.errGeneric("Security provider exited without sending any data", .{}); - } - }, - .signaled => |sig| { - Output.errGeneric("Security provider terminated by signal {s} without sending data", .{@tagName(sig)}); - }, - else => { - Output.errGeneric("Security provider 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_provider, duration }); - } else { - Output.prettyErrorln("{s}[{s}] Scanning {d} packages took {d}ms", .{ maybeHourglass, security_provider, 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 provider failed with exit code: {d}", .{exited.code}); - Global.exit(1); - } - }, - .signaled => |signal| { - Output.errGeneric("Security provider was terminated by signal: {s}", .{@tagName(signal)}); - Global.exit(1); - }, - else => { - Output.errGeneric("Security provider 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 provider 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 provider 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 provider response missing required 'advisories' field", .{}); - Global.exit(1); - }; - - if (advisories_expr.data != .e_array) { - Output.errGeneric("Security provider '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 installHoistedPackages = @import("../hoisted_install.zig").installHoistedPackages; const installIsolatedPackages = @import("../isolated_install.zig").installIsolatedPackages; +const security_scanner = @import("security_scanner.zig"); const bun = @import("bun"); const Environment = bun.Environment; diff --git a/src/install/PackageManager/security_scanner.zig b/src/install/PackageManager/security_scanner.zig new file mode 100644 index 0000000000..dd344a7bc5 --- /dev/null +++ b/src/install/PackageManager/security_scanner.zig @@ -0,0 +1,756 @@ +const std = @import("std"); +const bun = @import("bun"); +const Environment = bun.Environment; +const Global = bun.Global; +const Output = bun.Output; +const Progress = bun.Progress; +const FileSystem = bun.fs.FileSystem; +const jsc = bun.jsc; +const logger = bun.logger; + +const DependencyID = bun.install.DependencyID; +const PackageID = bun.install.PackageID; +const invalid_dependency_id = bun.install.invalid_dependency_id; +const invalid_package_id = bun.install.invalid_package_id; + +const PackageManager = bun.install.PackageManager; + +const string = []const u8; + +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( + \\try {{ + \\ const {{provider}} = await import('{s}'); + \\ const packages = {s}; + \\ + \\ if (provider.version !== '1') {{ + \\ throw new Error('Security provider must be version 1'); + \\ }} + \\ + \\ if (typeof provider.scan !== 'function') {{ + \\ throw new Error('provider.scan is not a function'); + \\ }} + \\ + \\ const result = await provider.scan({{packages:packages}}); + \\ + \\ if (!Array.isArray(result)) {{ + \\ throw new Error('Security provider 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 progress_node: ?*Progress.Node = null; + if (manager.options.log_level != .verbose and manager.options.log_level != .silent) { + manager.progress.supports_ansi_escape_codes = Output.enable_ansi_colors_stderr; + const scanner_name = if (Output.isEmojiEnabled()) " 👮 Scanning packages with security provider" else "Scanning packages with security provider"; + progress_node = manager.progress.start(scanner_name, 0); + if (progress_node) |node| { + node.activate(); + manager.progress.refresh(); + } + } + + var closure = struct { + scanner: *SecurityScanSubprocess, + + pub fn isDone(this: *@This()) bool { + return this.scanner.isDone(); + } + }{ .scanner = scanner }; + + manager.sleepUntil(&closure, &@TypeOf(closure).isDone); + + if (progress_node) |node| { + node.end(); + manager.progress.refresh(); + manager.progress.root.end(); + manager.progress = .{}; + } + + 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), + "-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[2].?)); + } + + 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 provider exited with code {d} without sending data", .{exit.code}); + } else { + Output.errGeneric("Security provider exited without sending any data", .{}); + } + }, + .signaled => |sig| { + Output.errGeneric("Security provider terminated by signal {s} without sending data", .{@tagName(sig)}); + }, + else => { + Output.errGeneric("Security provider 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 provider failed with exit code: {d}", .{exited.code}); + Global.exit(1); + } + }, + .signaled => |signal| { + Output.errGeneric("Security provider was terminated by signal: {s}", .{@tagName(signal)}); + Global.exit(1); + }, + else => { + Output.errGeneric("Security provider 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 provider 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 provider 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 provider response missing required 'advisories' field", .{}); + Global.exit(1); + }; + + if (advisories_expr.data != .e_array) { + Output.errGeneric("Security provider '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); + } + } + } +} \ No newline at end of file diff --git a/src/install/install.zig b/src/install/install.zig index 26693a977b..3929bf86b7 100644 --- a/src/install/install.zig +++ b/src/install/install.zig @@ -247,7 +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/install_with_manager.zig").SecurityScanSubprocess; +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;