Compare commits

...

7 Commits

Author SHA1 Message Date
Dylan Conway
9dca31f3cd update 2025-12-11 13:59:05 -08:00
Claude Bot
aa8d97f964 fix: make tarball integrity optional and fix Windows path handling
- Make integrity hash optional for local_tarball and remote_tarball in
  lockfile parsing (may be missing in migrated lockfiles)
- Only write integrity to lockfile when it's valid (supported tag)
- Fix Windows path separator issue in test by normalizing paths to
  forward slashes when comparing against lockfile content
- Update snapshot for plaintext lockfiles test

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 20:04:25 +00:00
Claude Bot
d8b9dd1f1d chore: use test.concurrent and remove unnecessary String() coercions
- Mark tests as concurrent to run in parallel
- Remove String() coercions since tempDirWithFiles returns string

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 05:02:17 +00:00
Claude Bot
a42c326de6 chore: remove panic assertions from tarball integrity tests
Per review feedback, tests should not assert absence of "panic" - they
should rely on exit code and content assertions instead.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 04:57:05 +00:00
Claude Bot
2f10b71f50 refactor: move tarball integrity test to correct location and improve quality
- Move test from test/regression/issue/ to test/cli/install/
- Use tempDirWithFiles and pack helper from harness
- Use fs/promises rm instead of spawning rm command
- Remove setDefaultTimeout
- Use replaceAll for lockfile modification
- Add verification that lockfile was properly modified

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 04:47:26 +00:00
Claude Bot
4366e68caf refactor: use *const Integrity for function parameters
Pass Integrity by pointer instead of by value to avoid unnecessary copies.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 04:35:43 +00:00
Claude Bot
e2f58577bd fix: add integrity hash verification for HTTP tarball dependencies
Previously, Bun did not store or verify integrity hashes for HTTP tarball
dependencies (remote_tarball) or local tarball dependencies (local_tarball).
Each `bun install` would download/read the tarball without verification,
allowing silent package substitution if the tarball content changed.

This security fix:
1. Computes SHA-512 integrity hash when extracting tarballs that don't
   already have an integrity hash (remote and local tarballs)
2. Stores the computed integrity hash in the package metadata
3. Serializes the integrity hash to bun.lock for remote_tarball and
   local_tarball packages (similar to how npm packages already work)
4. Parses the integrity hash from bun.lock when reading the lockfile
5. Passes the integrity to the tarball extraction task for verification
   on subsequent installs

Now when a remote or local tarball's content changes but the lockfile
integrity doesn't match, bun will fail with "Integrity check failed".

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 04:28:01 +00:00
10 changed files with 209 additions and 4 deletions

View File

@@ -1003,6 +1003,7 @@ pub const PackageInstaller = struct {
alias.slice(this.lockfile.buffers.string_bytes.items),
resolution,
context,
&this.metas[package_id].integrity,
);
},
.remote_tarball => {

View File

@@ -129,6 +129,7 @@ pub fn enqueueTarballForReading(
alias: string,
resolution: *const Resolution,
task_context: TaskCallbackContext,
integrity: *const Integrity,
) void {
const path = this.lockfile.str(&resolution.value.local_tarball);
const task_id = Task.Id.forTarball(path);
@@ -151,6 +152,7 @@ pub fn enqueueTarballForReading(
alias,
path,
resolution.*,
integrity.*,
)));
}
@@ -1133,6 +1135,7 @@ pub fn enqueueDependencyWithMainAndSuccessFn(
this.lockfile.str(&dependency.name),
url,
res,
.{}, // No integrity for first-time install, will be computed during extraction
)));
},
.remote => {
@@ -1294,6 +1297,7 @@ fn enqueueLocalTarball(
name: string,
path: string,
resolution: Resolution,
integrity: Integrity,
) *ThreadPool.Task {
var task = this.preallocated_resolve_tasks.get();
task.* = Task{
@@ -1313,6 +1317,7 @@ fn enqueueLocalTarball(
.cache_dir = this.getCacheDirectory(),
.temp_dir = this.getTemporaryDirectory().handle,
.dependency_id = dependency_id,
.integrity = integrity,
.url = strings.StringOrTinyString.initAppendIfNeeded(
path,
*FileSystem.FilenameStore,
@@ -1886,6 +1891,7 @@ const DependencyID = bun.install.DependencyID;
const ExtractTarball = bun.install.ExtractTarball;
const Features = bun.install.Features;
const FolderResolution = bun.install.FolderResolution;
const Integrity = bun.install.Integrity;
const Npm = bun.install.Npm;
const PackageID = bun.install.PackageID;
const PackageNameHash = bun.install.PackageNameHash;

View File

@@ -187,6 +187,8 @@ pub fn processExtractedTarballPackage(
};
package.meta.setHasInstallScript(has_scripts);
// Store the computed integrity hash for remote/local tarballs
package.meta.integrity = data.computed_integrity;
package = manager.lockfile.appendPackage(package) catch unreachable;
package_id.* = package.meta.id;

View File

@@ -11,6 +11,13 @@ url: strings.StringOrTinyString,
package_manager: *PackageManager,
pub inline fn run(this: *const ExtractTarball, log: *logger.Log, bytes: []const u8) !Install.ExtractData {
// Compute integrity for tarballs that don't have one (remote/local tarballs).
// For npm packages, the integrity is provided by the registry.
const computed_integrity: Integrity = if (!this.integrity.tag.isSupported())
Integrity.compute(bytes)
else
this.integrity;
if (!this.skip_verify and this.integrity.tag.isSupported()) {
if (!this.integrity.verify(bytes)) {
log.addErrorFmt(
@@ -23,7 +30,9 @@ pub inline fn run(this: *const ExtractTarball, log: *logger.Log, bytes: []const
return error.IntegrityCheckFailed;
}
}
return this.extract(log, bytes);
var result = try this.extract(log, bytes);
result.computed_integrity = computed_integrity;
return result;
}
pub fn buildURL(

View File

@@ -209,6 +209,9 @@ pub const ExtractData = struct {
path: string = "",
buf: []u8 = "",
} = null,
/// Integrity hash computed from the tarball bytes during extraction.
/// Used for remote and local tarballs to verify integrity on subsequent installs.
computed_integrity: Integrity = .{},
};
pub const DependencyInstallContext = struct {
@@ -272,6 +275,7 @@ pub const VersionSlice = external.VersionSlice;
pub const Dependency = @import("./dependency.zig");
pub const Behavior = @import("./dependency.zig").Behavior;
pub const Integrity = @import("./integrity.zig").Integrity;
pub const Lockfile = @import("./lockfile.zig");
pub const PatchedDep = Lockfile.PatchedDep;

View File

@@ -180,6 +180,15 @@ pub const Integrity = extern struct {
}
}
/// Compute an integrity hash from bytes using SHA-512 (the standard for npm packages).
pub fn compute(bytes: []const u8) Integrity {
var integrity = Integrity{ .tag = Tag.sha512 };
const len = std.crypto.hash.sha2.Sha512.digest_length;
const ptr: *[len]u8 = integrity.value[0..len];
Crypto.SHA512.hash(bytes, ptr);
return integrity;
}
pub fn verify(this: *const Integrity, bytes: []const u8) bool {
return @call(bun.callmod_inline, verifyByTag, .{ this.tag, bytes, &this.value });
}

View File

@@ -805,6 +805,7 @@ pub fn installIsolatedPackages(
const pkg_names = pkgs.items(.name);
const pkg_name_hashes = pkgs.items(.name_hash);
const pkg_resolutions = pkgs.items(.resolution);
const pkg_metas = pkgs.items(.meta);
var seen_entry_ids: std.AutoHashMapUnmanaged(Store.Entry.Id, void) = .empty;
defer seen_entry_ids.deinit(lockfile.allocator);
@@ -1085,6 +1086,7 @@ pub fn installIsolatedPackages(
dep.name.slice(string_buf),
&pkg_res,
ctx,
&pkg_metas[pkg_id].integrity,
);
},
.remote_tarball => {

View File

@@ -535,7 +535,13 @@ pub const Stringifier = struct {
&path_buf,
);
try writer.writeByte(']');
if (pkg_meta.integrity.tag.isSupported()) {
try writer.print(", \"{f}\"]", .{
pkg_meta.integrity,
});
} else {
try writer.writeByte(']');
}
},
.remote_tarball => {
try writer.print("[\"{f}@{f}\", ", .{
@@ -558,7 +564,13 @@ pub const Stringifier = struct {
&path_buf,
);
try writer.writeByte(']');
if (pkg_meta.integrity.tag.isSupported()) {
try writer.print(", \"{f}\"]", .{
pkg_meta.integrity,
});
} else {
try writer.writeByte(']');
}
},
.symlink => {
try writer.print("[\"{f}@link:{f}\", ", .{
@@ -1863,6 +1875,16 @@ pub fn parseIntoBinaryLockfile(
pkg.meta.integrity = Integrity.parse(integrity_str);
},
.local_tarball, .remote_tarball => {
// Integrity is optional for tarballs (may be missing in migrated lockfiles)
if (i < pkg_info.len) {
const integrity_expr = pkg_info.at(i);
if (integrity_expr.asString(allocator)) |integrity_str| {
i += 1;
pkg.meta.integrity = Integrity.parse(integrity_str);
}
}
},
inline .git, .github => |tag| {
// .bun-tag
if (i >= pkg_info.len) {

View File

@@ -13,7 +13,7 @@ exports[`should write plaintext lockfiles 1`] = `
},
},
"packages": {
"dummy-package": ["bar@./bar-0.0.2.tgz", {}],
"dummy-package": ["bar@./bar-0.0.2.tgz", {}, "sha512-DXWxn8qZ4n87XMJjwZUdYPnsrl8Ntz66PudFoxDVkaPEkZBBzENAKsJPgbBacD782W8RwD/v4mjwVyqlPpQ59w=="],
}
}
"

View File

@@ -0,0 +1,150 @@
import { describe, expect, test } from "bun:test";
import { rm } from "fs/promises";
import { bunEnv, bunExe, pack, tempDirWithFiles } from "harness";
import { join } from "path";
/**
* Tests for tarball integrity verification.
*
* Bun computes and stores integrity hashes for HTTP tarball and local tarball dependencies.
* This ensures integrity is verified on subsequent installs, preventing silent package substitution.
*
* This test verifies that:
* 1. Integrity hashes are computed and stored in bun.lock for local tarballs
* 2. Integrity is verified on subsequent installs
*/
describe("tarball integrity", () => {
test.concurrent("local tarball has integrity hash in lockfile", async () => {
// Create a package to be packed
const pkgDir = tempDirWithFiles("tarball-pkg", {
"package.json": JSON.stringify({ name: "local-pkg", version: "1.0.0" }),
"index.js": "module.exports = 'hello';",
});
// Pack it into a tarball
await pack(pkgDir, bunEnv);
const tarballPath = join(pkgDir, "local-pkg-1.0.0.tgz");
// Create a project that depends on the local tarball
const projectDir = tempDirWithFiles("tarball-project", {
"package.json": JSON.stringify({
name: "test-project",
dependencies: {
"local-pkg": `file:${tarballPath}`,
},
}),
});
// Run bun install
const installResult = Bun.spawnSync({
cmd: [bunExe(), "install"],
cwd: projectDir,
env: {
...bunEnv,
BUN_INSTALL_CACHE_DIR: join(projectDir, ".bun-cache"),
},
});
expect(installResult.exitCode).toBe(0);
// Read the lockfile and verify it contains the integrity hash
const lockfileContent = await Bun.file(join(projectDir, "bun.lock")).text();
// The lockfile should contain an integrity hash for the local tarball
expect(lockfileContent).toContain("sha512-");
expect(lockfileContent).toContain("local-pkg-1.0.0.tgz");
});
test.concurrent("integrity verification fails when tarball content changes", async () => {
// Create version 1 package
const v1Dir = tempDirWithFiles("tarball-v1", {
"package.json": JSON.stringify({ name: "pkg", version: "1.0.0" }),
"index.js": "module.exports = 'v1';",
});
await pack(v1Dir, bunEnv);
const v1Tarball = join(v1Dir, "pkg-1.0.0.tgz");
// Create version 2 package (different content, same name/version for testing)
const v2Dir = tempDirWithFiles("tarball-v2", {
"package.json": JSON.stringify({ name: "pkg", version: "1.0.0", description: "changed" }),
"index.js": "module.exports = 'v2 - different content';",
});
await pack(v2Dir, bunEnv);
const v2Tarball = join(v2Dir, "pkg-1.0.0.tgz");
// Create project pointing to v1
const projectDir = tempDirWithFiles("tarball-integrity-project", {
"package.json": JSON.stringify({
name: "test-project",
dependencies: {
pkg: `file:${v1Tarball}`,
},
}),
});
const cacheDir = join(projectDir, ".bun-cache");
// First install - should succeed and create lockfile with integrity
const firstInstall = Bun.spawnSync({
cmd: [bunExe(), "install"],
cwd: projectDir,
env: {
...bunEnv,
BUN_INSTALL_CACHE_DIR: cacheDir,
},
});
expect(firstInstall.exitCode).toBe(0);
// Verify lockfile has integrity
const lockfilePath = join(projectDir, "bun.lock");
const lockfileContent = await Bun.file(lockfilePath).text();
expect(lockfileContent).toContain("sha512-");
// Modify the lockfile to point to v2 tarball but keep the v1 integrity hash
// This simulates an attacker swapping the tarball content
// Note: we replace globally since the path appears in multiple places
// On Windows, the lockfile normalizes paths to forward slashes, so we need to match that
const v1TarballNormalized = v1Tarball.replaceAll("\\", "/");
const v2TarballNormalized = v2Tarball.replaceAll("\\", "/");
const modifiedLockfile = lockfileContent.replaceAll(v1TarballNormalized, v2TarballNormalized);
await Bun.write(lockfilePath, modifiedLockfile);
// Also update package.json to point to v2 (to match the lockfile path)
await Bun.write(
join(projectDir, "package.json"),
JSON.stringify({
name: "test-project",
dependencies: {
pkg: `file:${v2Tarball}`,
},
}),
);
// Verify the lockfile was modified correctly
const newLockfile = await Bun.file(lockfilePath).text();
expect(newLockfile).toContain(v2TarballNormalized);
expect(newLockfile).not.toContain(v1TarballNormalized);
// Clean cache and node_modules to force re-extraction
await rm(join(projectDir, "node_modules"), { recursive: true, force: true });
await rm(cacheDir, { recursive: true, force: true });
// Second install with different tarball content should fail integrity check
const secondInstall = Bun.spawnSync({
cmd: [bunExe(), "install"],
cwd: projectDir,
env: {
...bunEnv,
BUN_INSTALL_CACHE_DIR: cacheDir,
},
});
// The install should fail because the integrity check failed
const stderr = secondInstall.stderr.toString();
const stdout = secondInstall.stdout.toString();
const output = stdout + stderr;
expect(output).toContain("Integrity check failed");
expect(secondInstall.exitCode).not.toBe(0);
});
});