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:
robobun
2025-12-11 17:44:41 -08:00
committed by GitHub
parent c59a6997cd
commit 7dcd49f832
8 changed files with 142 additions and 11 deletions

View File

@@ -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>
---

View File

@@ -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`

View File

@@ -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;
}
}

View File

@@ -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 };
};

View File

@@ -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 };

View File

@@ -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);

View File

@@ -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: {

View File

@@ -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;