diff --git a/docs/pm/cli/install.mdx b/docs/pm/cli/install.mdx index 0e2a092d549..b352abc8df6 100644 --- a/docs/pm/cli/install.mdx +++ b/docs/pm/cli/install.mdx @@ -275,7 +275,7 @@ minimumReleaseAgeExcludes = ["@types/node", "typescript"] When the minimum age filter is active: -- Only affects new package resolution - existing packages in `bun.lock` remain unchanged +- Versions already pinned in `bun.lock` are also validated: if a locked version was published within the cooldown (or its publish timestamp can't be verified against the manifest), `bun install` fails with a per-package error and points at `install.minimumReleaseAgeExcludes` - All dependencies (direct and transitive) are filtered to meet the age requirement when being resolved - When versions are blocked by the age gate, a stability check detects rapid bugfix patterns - If multiple versions were published close together just outside your age gate, it extends the filter to skip those potentially unstable versions and selects an older, more mature version diff --git a/src/install/PackageManager.zig b/src/install/PackageManager.zig index d78c32759c9..4f83be2c5cb 100644 --- a/src/install/PackageManager.zig +++ b/src/install/PackageManager.zig @@ -1260,6 +1260,7 @@ pub const updatePackageJSONAndInstallCatchError = @import("./PackageManager/upda pub const updatePackageJSONAndInstallWithManager = @import("./PackageManager/updatePackageJSONAndInstall.zig").updatePackageJSONAndInstallWithManager; pub const populateManifestCache = @import("./PackageManager/PopulateManifestCache.zig").populateManifestCache; +pub const enforceLockfileAgeFilter = @import("./PackageManager/PopulateManifestCache.zig").enforceLockfileAgeFilter; const string = []const u8; const stringZ = [:0]const u8; diff --git a/src/install/PackageManager/PopulateManifestCache.zig b/src/install/PackageManager/PopulateManifestCache.zig index 3974b9cf11c..5972f827d1d 100644 --- a/src/install/PackageManager/PopulateManifestCache.zig +++ b/src/install/PackageManager/PopulateManifestCache.zig @@ -149,13 +149,128 @@ pub fn populateManifestCache(manager: *PackageManager, packages: Packages) !void } } +/// After resolution, verify every npm-tagged package in the lockfile +/// satisfies the configured `minimumReleaseAge` cooldown. +/// +/// The resolution-time filter (`findBestVersionWithFilter`, etc.) only +/// runs when Bun is actually picking a new version. If the lockfile +/// already pins a version — e.g. it was resolved before the cooldown +/// was configured, or by a developer whose local bunfig was less strict +/// — that install path skips the filter entirely. Without this gate, +/// `bun install` (and `bun install --frozen-lockfile`) will happily +/// install a locked version that was published inside the cooldown +/// window, defeating the supply-chain protection the setting is meant +/// to provide. +/// +/// This loads manifests for every locked npm package, looks up the +/// exact pinned version's publish timestamp, and aggregates every +/// violation into `manager.log` as an error. Excludes from +/// `minimumReleaseAgeExcludes` are honored. +/// +/// Returns the number of cooldown / fail-closed errors *this function* +/// added to `manager.log`. Errors that `populateManifestCache` funnels +/// in (registry 5xx, parse failures) are not counted — the caller uses +/// this to gate the cooldown-specific remediation note so unrelated +/// errors don't get misattributed to a lockfile age violation. +pub fn enforceLockfileAgeFilter(manager: *PackageManager) !u32 { + const min_age_ms = manager.options.minimum_release_age_ms orelse return 0; + + // Make sure manifests are loaded from disk / network before we + // inspect publish timestamps. `populateManifestCache` already + // honors `minimum_release_age_ms` by requesting extended manifests. + try populateManifestCache(manager, .all); + + const lockfile = manager.lockfile; + const pkgs = lockfile.packages.slice(); + const pkg_resolutions = pkgs.items(.resolution); + const pkg_names = pkgs.items(.name); + const pkg_name_hashes = pkgs.items(.name_hash); + const string_buf = lockfile.buffers.string_bytes.items; + const min_age_seconds = min_age_ms / std.time.ms_per_s; + + var violations: u32 = 0; + + for (pkg_resolutions, pkg_names, pkg_name_hashes) |resolution, name, name_hash| { + if (resolution.tag != .npm) continue; + + const name_str = name.slice(string_buf); + + // Fail closed: if we cannot reach the manifest or locate the exact + // pinned version, we cannot prove the version satisfies the cooldown. + // Silently skipping would re-open the lockfile bypass this gate is + // meant to close (e.g. a version that was unpublished from the + // registry, or a manifest fetch that couldn't be completed). + const manifest = manager.manifests.byNameHash( + manager, + manager.scopeForPackageName(name_str), + name_hash, + .load_from_memory_fallback_to_disk, + true, + ) orelse { + if (isExcludedByName(name_str, manager.options.minimum_release_age_excludes)) continue; + bun.handleOom(manager.log.addErrorFmt( + null, + logger.Loc.Empty, + manager.allocator, + "Package \"{s}@{f}\" in lockfile could not be checked against minimum release age (manifest unavailable)", + .{ name_str, resolution.value.npm.version.fmt(string_buf) }, + )); + violations += 1; + continue; + }; + + if (manifest.shouldExcludeFromAgeFilter(manager.options.minimum_release_age_excludes)) continue; + + const find_result = manifest.findByVersion(resolution.value.npm.version) orelse { + bun.handleOom(manager.log.addErrorFmt( + null, + logger.Loc.Empty, + manager.allocator, + "Package \"{s}@{f}\" in lockfile could not be checked against minimum release age (version not in manifest)", + .{ name_str, resolution.value.npm.version.fmt(string_buf) }, + )); + violations += 1; + continue; + }; + if (!Npm.PackageManifest.isPackageVersionTooRecent(find_result.package, min_age_ms)) continue; + + bun.handleOom(manager.log.addErrorFmt( + null, + logger.Loc.Empty, + manager.allocator, + "Package \"{s}@{f}\" in lockfile was published within minimum release age of {d} seconds", + .{ + name_str, + resolution.value.npm.version.fmt(string_buf), + min_age_seconds, + }, + )); + violations += 1; + } + + return violations; +} + +/// Mirrors `PackageManifest.shouldExcludeFromAgeFilter` for the code path +/// where no manifest is available (the manifest lookup above returned null). +/// Kept in sync with the real check in `src/install/npm.zig`. +fn isExcludedByName(name: []const u8, exclusions: ?[]const []const u8) bool { + const excl = exclusions orelse return false; + for (excl) |entry| { + if (bun.strings.eql(entry, name)) return true; + } + return false; +} + const std = @import("std"); const bun = @import("bun"); const Output = bun.Output; +const logger = bun.logger; const Dependency = bun.install.Dependency; const DependencyID = bun.install.DependencyID; +const Npm = bun.install.Npm; const PackageID = bun.install.PackageID; const PackageManager = bun.install.PackageManager; const Resolution = bun.install.Resolution; diff --git a/src/install/PackageManager/install_with_manager.zig b/src/install/PackageManager/install_with_manager.zig index 2debc3d08c7..137b3a7004c 100644 --- a/src/install/PackageManager/install_with_manager.zig +++ b/src/install/PackageManager/install_with_manager.zig @@ -645,6 +645,33 @@ pub fn installWithManager( manager.verifyResolutions(log_level); + // Enforce `minimumReleaseAge` against versions already pinned in the + // lockfile. Resolution-time filtering only fires when Bun is choosing + // a new version; without this gate, a locked version that was + // published inside the cooldown window would be installed silently — + // exactly the scenario the setting is meant to prevent. Runs before + // the security scanner so a poisoned lockfile can't trigger scanner + // install / network work before we reject it. + if (manager.options.minimum_release_age_ms != null) { + // enforceLockfileAgeFilter returns only the errors its own loop + // added — errors that `populateManifestCache` surfaces (registry + // 5xx, parse failures) are not counted. That lets us gate the + // cooldown-specific remediation note on actual lockfile-age + // errors while still crashing on any error so we never silently + // install with missing manifest data. + const violations = try manager.enforceLockfileAgeFilter(); + if (manager.log.hasErrors()) { + if (log_level != .silent) { + try manager.log.print(Output.errorWriter()); + if (violations > 0) { + Output.note("remove the offending version from bun.lock, lower install.minimumReleaseAge, or add it to install.minimumReleaseAgeExcludes", .{}); + } + } + manager.log.reset(); + Global.crash(); + } + } + if (manager.options.security_scanner != null) { const is_subcommand_to_run_scanner = manager.subcommand == .add or manager.subcommand == .update or manager.subcommand == .install or manager.subcommand == .remove; diff --git a/test/cli/install/minimum-release-age.test.ts b/test/cli/install/minimum-release-age.test.ts index 55aadbbec19..4f8a2be7875 100644 --- a/test/cli/install/minimum-release-age.test.ts +++ b/test/cli/install/minimum-release-age.test.ts @@ -1795,7 +1795,14 @@ registry = "${mockRegistryUrl}"`, }); describe("frozen lockfile", () => { - test("frozen lockfile preserves existing versions regardless of minimum-release-age", async () => { + // Regression test for https://github.com/oven-sh/bun/issues/30525 + // + // Before the fix, resolution-time filtering was the only gate, so a version + // already pinned in bun.lock would install unconditionally — even under + // --frozen-lockfile. That defeats the supply-chain use case: a team adding + // the cooldown after an incident was no safer than one that didn't, because + // already-resolved bad versions remained installable. + test("rejects locked version that violates minimum-release-age", async () => { using dir = tempDir("frozen-lockfile", { "package.json": JSON.stringify({ dependencies: { @@ -1806,23 +1813,24 @@ registry = "${mockRegistryUrl}"`, }); // First install without minimum-release-age to get latest - let proc = Bun.spawn({ - cmd: [bunExe(), "install"], - cwd: String(dir), - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - - let exitCode = await proc.exited; - expect(exitCode).toBe(0); + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + expect(exitCode).toBe(0); + } const lockfile = await Bun.file(`${dir}/bun.lock`).text(); - expect(lockfile).toContain("regular-package@3.0.0"); // Latest version + expect(lockfile).toContain("regular-package@3.0.0"); // Latest version (1 day old) - // Now try with frozen lockfile and minimum-release-age - // Frozen lockfile means no changes to lockfile - versions stay as-is - proc = Bun.spawn({ + // --frozen-lockfile with a cooldown the locked version violates must fail. + await using proc = Bun.spawn({ cmd: [ bunExe(), "install", @@ -1837,40 +1845,170 @@ registry = "${mockRegistryUrl}"`, stderr: "pipe", }); - const exitCode2 = await proc.exited; + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + expect(stderr).toContain("regular-package"); + expect(stderr).toContain("3.0.0"); + expect(stderr.toLowerCase()).toContain("minimum release age"); + expect(exitCode).toBe(1); + }); + + test("bun install (non-frozen) also rejects locked version that violates minimum-release-age", async () => { + using dir = tempDir("non-frozen-lockfile", { + "package.json": JSON.stringify({ + dependencies: { + "regular-package": "*", + }, + }), + ".npmrc": `registry=${mockRegistryUrl}`, + }); + + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + expect(await proc.exited).toBe(0); + } + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--minimum-release-age", `${5 * SECONDS_PER_DAY}`, "--no-verify"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); - // Should succeed - frozen lockfile means no changes, even if version is "too recent" - expect(exitCode2).toBe(0); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); - // Lockfile should remain unchanged - const lockfileAfter = await Bun.file(`${dir}/bun.lock`).text(); - expect(lockfileAfter).toContain("regular-package@3.0.0"); + expect(stderr).toContain("regular-package"); + expect(stderr).toContain("3.0.0"); + expect(stderr.toLowerCase()).toContain("minimum release age"); + expect(exitCode).toBe(1); + }); + + test("excludes list lets a lockfile-pinned violator through", async () => { + using dir = tempDir("frozen-lockfile-excluded", { + "package.json": JSON.stringify({ + dependencies: { + "regular-package": "*", + }, + }), + ".npmrc": `registry=${mockRegistryUrl}`, + }); + + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + expect(await proc.exited).toBe(0); + } + + // Now add a bunfig that configures the cooldown + an exclude for regular-package. + await Bun.write( + `${dir}/bunfig.toml`, + `[install]\nminimumReleaseAge = ${5 * SECONDS_PER_DAY}\nminimumReleaseAgeExcludes = ["regular-package"]\nregistry = "${mockRegistryUrl}"\n`, + ); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--frozen-lockfile", "--no-verify"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + expect(await proc.exited).toBe(0); + + const after = await Bun.file(`${dir}/bun.lock`).text(); + expect(after).toContain("regular-package@3.0.0"); }); test("works with frozen lockfile when versions are old enough", async () => { using dir = tempDir("frozen-old-versions", { "package.json": JSON.stringify({ dependencies: { - "regular-package": "2.1.0", // Old enough version + "regular-package": "2.1.0", // Old enough version (6 days) }, }), ".npmrc": `registry=${mockRegistryUrl}`, }); // First install to create lockfile - let proc = Bun.spawn({ - cmd: [bunExe(), "install"], + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + expect(await proc.exited).toBe(0); + } + + // Install with frozen lockfile and minimum-release-age — locked version is + // older than the cooldown, so it should install. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "install", + "--frozen-lockfile", + "--minimum-release-age", + `${5 * SECONDS_PER_DAY}`, + "--no-verify", + ], cwd: String(dir), env: bunEnv, stdout: "pipe", stderr: "pipe", }); - let exitCode = await proc.exited; - expect(exitCode).toBe(0); + expect(await proc.exited).toBe(0); - // Install with frozen lockfile and minimum-release-age - proc = Bun.spawn({ + const lockfile = await Bun.file(`${dir}/bun.lock`).text(); + expect(lockfile).toContain("regular-package@2.1.0"); + }); + + // Fail-closed: when we cannot verify a locked version's publish date + // against the manifest (e.g. the exact version was unpublished from + // the registry after it was resolved into bun.lock), the install must + // block rather than silently skip. Otherwise a supply-chain actor + // could slip a too-young version past the gate by unpublishing it + // and re-publishing under the same number — or by planting a + // fabricated entry in a lockfile. + test("blocks install when locked version is absent from the manifest", async () => { + using dir = tempDir("frozen-lockfile-missing-version", { + "package.json": JSON.stringify({ + dependencies: { "regular-package": "2.1.0" }, + }), + ".npmrc": `registry=${mockRegistryUrl}`, + }); + + { + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + expect(await proc.exited).toBe(0); + } + + // Point bun.lock at a version that the registry's manifest does not + // list — the next `bun install` has nothing to check the age against. + const lockfilePath = `${dir}/bun.lock`; + const original = await Bun.file(lockfilePath).text(); + await Bun.write(lockfilePath, original.replaceAll("regular-package@2.1.0", "regular-package@99.99.99")); + + await using proc = Bun.spawn({ cmd: [ bunExe(), "install", @@ -1885,11 +2023,12 @@ registry = "${mockRegistryUrl}"`, stderr: "pipe", }); - exitCode = await proc.exited; - expect(exitCode).toBe(0); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); - const lockfile = await Bun.file(`${dir}/bun.lock`).text(); - expect(lockfile).toContain("regular-package@2.1.0"); + expect(stderr).toContain("regular-package"); + expect(stderr).toContain("99.99.99"); + expect(stderr.toLowerCase()).toContain("minimum release age"); + expect(exitCode).toBe(1); }); });