diff --git a/docs/guides/install/trusted.mdx b/docs/guides/install/trusted.mdx
index 7660315003..191255be09 100644
--- a/docs/guides/install/trusted.mdx
+++ b/docs/guides/install/trusted.mdx
@@ -8,7 +8,9 @@ Unlike other npm clients, Bun does not execute arbitrary lifecycle scripts for i
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`.
---
diff --git a/docs/pm/lifecycle.mdx b/docs/pm/lifecycle.mdx
index 0fafcb9b27..4cc4590d1e 100644
--- a/docs/pm/lifecycle.mdx
+++ b/docs/pm/lifecycle.mdx
@@ -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).
+
+ 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.
+
+
---
## `--ignore-scripts`
diff --git a/src/cli/pm_trusted_command.zig b/src/cli/pm_trusted_command.zig
index 817f724516..32883c7cca 100644
--- a/src/cli/pm_trusted_command.zig
+++ b/src/cli/pm_trusted_command.zig
@@ -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;
}
}
diff --git a/src/install/PackageInstaller.zig b/src/install/PackageInstaller.zig
index fabda13fa2..7fec486868 100644
--- a/src/install/PackageInstaller.zig
+++ b/src/install/PackageInstaller.zig
@@ -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 };
};
diff --git a/src/install/isolated_install/Installer.zig b/src/install/isolated_install/Installer.zig
index 9eadd190c1..3012987caf 100644
--- a/src/install/isolated_install/Installer.zig
+++ b/src/install/isolated_install/Installer.zig
@@ -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 };
diff --git a/src/install/lockfile.zig b/src/install/lockfile.zig
index 98aef54174..2affea5dee 100644
--- a/src/install/lockfile.zig
+++ b/src/install/lockfile.zig
@@ -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);
diff --git a/src/install/lockfile/Package/Scripts.zig b/src/install/lockfile/Package/Scripts.zig
index 3c7a19eb62..f2dadd02b4 100644
--- a/src/install/lockfile/Package/Scripts.zig
+++ b/src/install/lockfile/Package/Scripts.zig
@@ -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: {
diff --git a/test/cli/install/bun-install-lifecycle-scripts.test.ts b/test/cli/install/bun-install-lifecycle-scripts.test.ts
index d885142b20..b9afbf776c 100644
--- a/test/cli/install/bun-install-lifecycle-scripts.test.ts
+++ b/test/cli/install/bun-install-lifecycle-scripts.test.ts
@@ -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;