mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
fix(install): only apply default trusted dependencies to npm packages (#25163)
## Summary - The default trusted dependencies list should only apply to packages installed from npm - Non-npm sources (file:, link:, git:, github:) now require explicit trustedDependencies - This prevents malicious packages from spoofing trusted names through local paths or git repos ## Test plan - [x] Added test: file: dependency named "esbuild" does NOT auto-run postinstall scripts - [x] Added test: file: dependency runs scripts when explicitly added to trustedDependencies - [x] Verified tests fail with system bun (old behavior) and pass with new build - [x] Build compiles successfully 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Bot <claude-bot@bun.sh> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Jarred Sumner <jarred@jarredsumner.com> Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
This commit is contained in:
@@ -8,7 +8,9 @@ Unlike other npm clients, Bun does not execute arbitrary lifecycle scripts for i
|
||||
|
||||
<Note>
|
||||
Bun includes a default allowlist of popular packages containing `postinstall` scripts that are known to be safe. You
|
||||
can see this list [here](https://github.com/oven-sh/bun/blob/main/src/install/default-trusted-dependencies.txt).
|
||||
can see this list [here](https://github.com/oven-sh/bun/blob/main/src/install/default-trusted-dependencies.txt). This
|
||||
default list only applies to packages installed from npm. For packages from other sources (such as `file:`, `link:`,
|
||||
`git:`, or `github:` dependencies), you must explicitly add them to `trustedDependencies`.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
@@ -46,6 +46,13 @@ Once added to `trustedDependencies`, install/re-install the package. Bun will re
|
||||
|
||||
The top 500 npm packages with lifecycle scripts are allowed by default. You can see the full list [here](https://github.com/oven-sh/bun/blob/main/src/install/default-trusted-dependencies.txt).
|
||||
|
||||
<Note>
|
||||
The default trusted dependencies list only applies to packages installed from npm. For packages from other sources
|
||||
(such as `file:`, `link:`, `git:`, or `github:` dependencies), you must explicitly add them to `trustedDependencies`
|
||||
to run their lifecycle scripts, even if the package name matches an entry in the default list. This prevents malicious
|
||||
packages from spoofing trusted package names through local file paths or git repositories.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
## `--ignore-scripts`
|
||||
|
||||
@@ -37,8 +37,8 @@ pub const UntrustedCommand = struct {
|
||||
|
||||
// called alias because a dependency name is not always the package name
|
||||
const alias = dep.name.slice(buf);
|
||||
|
||||
if (!pm.lockfile.hasTrustedDependency(alias)) {
|
||||
const resolution = &resolutions[package_id];
|
||||
if (!pm.lockfile.hasTrustedDependency(alias, resolution)) {
|
||||
try untrusted_dep_ids.put(ctx.allocator, dep_id, {});
|
||||
}
|
||||
}
|
||||
@@ -191,8 +191,8 @@ pub const TrustCommand = struct {
|
||||
if (package_id == Install.invalid_package_id) continue;
|
||||
|
||||
const alias = dep.name.slice(buf);
|
||||
|
||||
if (!pm.lockfile.hasTrustedDependency(alias)) {
|
||||
const resolution = &resolutions[package_id];
|
||||
if (!pm.lockfile.hasTrustedDependency(alias, resolution)) {
|
||||
try untrusted_dep_ids.put(ctx.allocator, dep_id, {});
|
||||
}
|
||||
}
|
||||
@@ -263,7 +263,7 @@ pub const TrustCommand = struct {
|
||||
if (trust_all) break :brk false;
|
||||
|
||||
for (packages_to_trust.items) |package_name_from_cli| {
|
||||
if (strings.eqlLong(package_name_from_cli, alias, true) and !pm.lockfile.hasTrustedDependency(alias)) {
|
||||
if (strings.eqlLong(package_name_from_cli, alias, true) and !pm.lockfile.hasTrustedDependency(alias, resolution)) {
|
||||
break :brk false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1152,7 +1152,7 @@ pub const PackageInstaller = struct {
|
||||
const truncated_dep_name_hash: TruncatedPackageNameHash = @truncate(dep.name_hash);
|
||||
const is_trusted, const is_trusted_through_update_request = brk: {
|
||||
if (this.trusted_dependencies_from_update_requests.contains(truncated_dep_name_hash)) break :brk .{ true, true };
|
||||
if (this.lockfile.hasTrustedDependency(alias.slice(this.lockfile.buffers.string_bytes.items))) break :brk .{ true, false };
|
||||
if (this.lockfile.hasTrustedDependency(alias.slice(this.lockfile.buffers.string_bytes.items), resolution)) break :brk .{ true, false };
|
||||
break :brk .{ false, false };
|
||||
};
|
||||
|
||||
|
||||
@@ -916,7 +916,7 @@ pub const Installer = struct {
|
||||
if (installer.trusted_dependencies_from_update_requests.contains(truncated_dep_name_hash)) {
|
||||
break :brk .{ true, true };
|
||||
}
|
||||
if (installer.lockfile.hasTrustedDependency(dep.name.slice(string_buf))) {
|
||||
if (installer.lockfile.hasTrustedDependency(dep.name.slice(string_buf), &pkg_res)) {
|
||||
break :brk .{ true, false };
|
||||
}
|
||||
break :brk .{ false, false };
|
||||
|
||||
@@ -2119,13 +2119,14 @@ pub const default_trusted_dependencies = brk: {
|
||||
break :brk &final;
|
||||
};
|
||||
|
||||
pub fn hasTrustedDependency(this: *const Lockfile, name: []const u8) bool {
|
||||
pub fn hasTrustedDependency(this: *const Lockfile, name: []const u8, resolution: *const Resolution) bool {
|
||||
if (this.trusted_dependencies) |trusted_dependencies| {
|
||||
const hash = @as(u32, @truncate(String.Builder.stringHash(name)));
|
||||
return trusted_dependencies.contains(hash);
|
||||
}
|
||||
|
||||
return default_trusted_dependencies.has(name);
|
||||
// Only allow default trusted dependencies for npm packages
|
||||
return resolution.tag == .npm and default_trusted_dependencies.has(name);
|
||||
}
|
||||
|
||||
pub const NameHashMap = std.ArrayHashMapUnmanaged(PackageNameHash, String, ArrayIdentityContext.U64, false);
|
||||
|
||||
@@ -259,7 +259,7 @@ pub const Scripts = extern struct {
|
||||
resolution: *const Resolution,
|
||||
) !?Package.Scripts.List {
|
||||
if (this.hasAny()) {
|
||||
const add_node_gyp_rebuild_script = if (lockfile.hasTrustedDependency(folder_name) and
|
||||
const add_node_gyp_rebuild_script = if (lockfile.hasTrustedDependency(folder_name, resolution) and
|
||||
this.install.isEmpty() and
|
||||
this.preinstall.isEmpty())
|
||||
brk: {
|
||||
|
||||
@@ -1760,6 +1760,127 @@ for (const forceWaiterThread of isLinux ? [false, true] : [false]) {
|
||||
expect(await exists(join(packageDir, "node_modules", "electron", "preinstall.txt"))).toBeFalse();
|
||||
});
|
||||
|
||||
test("default trusted dependencies should only apply to npm packages, not file: dependencies", async () => {
|
||||
const testEnv = forceWaiterThread ? { ...env, BUN_FEATURE_FLAG_FORCE_WAITER_THREAD: "1" } : env;
|
||||
|
||||
// Create a file: dependency named "esbuild" (which is in the default trusted dependencies list)
|
||||
// with a postinstall script that would fail if it ran
|
||||
const esbuildPath = join(packageDir, "local-esbuild");
|
||||
await mkdir(esbuildPath, { recursive: true });
|
||||
await writeFile(
|
||||
join(esbuildPath, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "esbuild",
|
||||
version: "1.0.0",
|
||||
scripts: {
|
||||
postinstall: "exit 1",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
packageJson,
|
||||
JSON.stringify({
|
||||
name: "foo",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
// file: dependency named "esbuild" - should NOT use default trusted list
|
||||
esbuild: "file:./local-esbuild",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { stdout, stderr, exited } = spawn({
|
||||
cmd: [bunExe(), "install"],
|
||||
cwd: packageDir,
|
||||
stdout: "pipe",
|
||||
stdin: "ignore",
|
||||
stderr: "pipe",
|
||||
env: testEnv,
|
||||
});
|
||||
|
||||
const err = await stderr.text();
|
||||
const out = await stdout.text();
|
||||
|
||||
// The install should succeed because the postinstall script should NOT run
|
||||
// (file: dependencies don't use default trusted list, even if name matches)
|
||||
// The postinstall is blocked (not trusted), so we expect the "Blocked" message
|
||||
expect(err).toContain("Saved lockfile");
|
||||
expect(err).not.toContain("not found");
|
||||
expect(err).not.toContain("error:");
|
||||
expect(out.replace(/\s*\[[0-9\.]+m?s\]$/m, "").split(/\r?\n/)).toEqual([
|
||||
expect.stringContaining("bun install v1."),
|
||||
"",
|
||||
"+ esbuild@local-esbuild",
|
||||
"",
|
||||
"1 package installed",
|
||||
"",
|
||||
"Blocked 1 postinstall. Run `bun pm untrusted` for details.",
|
||||
"",
|
||||
]);
|
||||
expect(await exited).toBe(0);
|
||||
});
|
||||
|
||||
test("file: dependency with default trusted name should run scripts when explicitly added to trustedDependencies", async () => {
|
||||
const testEnv = forceWaiterThread ? { ...env, BUN_FEATURE_FLAG_FORCE_WAITER_THREAD: "1" } : env;
|
||||
|
||||
// Create a file: dependency named "esbuild" with a postinstall script that creates a marker file
|
||||
const esbuildPath = join(packageDir, "local-esbuild");
|
||||
await mkdir(esbuildPath, { recursive: true });
|
||||
await writeFile(
|
||||
join(esbuildPath, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "esbuild",
|
||||
version: "1.0.0",
|
||||
scripts: {
|
||||
postinstall: `${bunExe()} -e "require('fs').writeFileSync('postinstall-ran.txt', 'ran')"`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
packageJson,
|
||||
JSON.stringify({
|
||||
name: "foo",
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
esbuild: "file:./local-esbuild",
|
||||
},
|
||||
// Explicitly trust the file: dependency
|
||||
trustedDependencies: ["esbuild"],
|
||||
}),
|
||||
);
|
||||
|
||||
const { stdout, stderr, exited } = spawn({
|
||||
cmd: [bunExe(), "install"],
|
||||
cwd: packageDir,
|
||||
stdout: "pipe",
|
||||
stdin: "ignore",
|
||||
stderr: "pipe",
|
||||
env: testEnv,
|
||||
});
|
||||
|
||||
const err = await stderr.text();
|
||||
const out = await stdout.text();
|
||||
|
||||
expect(err).toContain("Saved lockfile");
|
||||
expect(err).not.toContain("not found");
|
||||
expect(err).not.toContain("error:");
|
||||
expect(out.replace(/\s*\[[0-9\.]+m?s\]$/m, "").split(/\r?\n/)).toEqual([
|
||||
expect.stringContaining("bun install v1."),
|
||||
"",
|
||||
"+ esbuild@local-esbuild",
|
||||
"",
|
||||
"1 package installed",
|
||||
"",
|
||||
]);
|
||||
expect(out).not.toContain("Blocked");
|
||||
expect(await exited).toBe(0);
|
||||
|
||||
// The postinstall script should have run because we explicitly trusted it
|
||||
expect(await exists(join(packageDir, "node_modules", "esbuild", "postinstall-ran.txt"))).toBeTrue();
|
||||
});
|
||||
|
||||
test("will run default trustedDependencies after install that didn't include them", async () => {
|
||||
await verdaccio.writeBunfig(packageDir, { saveTextLockfile: false, linker: "hoisted" });
|
||||
const testEnv = forceWaiterThread ? { ...env, BUN_FEATURE_FLAG_FORCE_WAITER_THREAD: "1" } : env;
|
||||
|
||||
Reference in New Issue
Block a user