Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ limits, and required install commands.
not build from a previous patch's modified bytes. Verified service artifacts
keep their identity; integrity failures do not fall through to a local rebuild.
Repair rebuilds against recorded pins and reports unavailable inputs.
- VEX no longer attests an npm package that also ships a bundled, unpatched
copy of the same `name@version` (`inBundle: true`, or v1 `bundled: true`).
npm unpacks that copy from the parent's tarball, so no rewire reaches it; the
reference is now reported `patched_ref_unattributable`, naming the bundled
copy, in hosted and vendored mode (#325).
- API throttling uses bounded retries, failed queries appear in JSON diagnostics,
and hosted reference resolution handles batches larger than 500 patches.
- Transient apply locks are removed on normal command exit; no-op scans and full
Expand Down
2 changes: 1 addition & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ Recognition rules that hold for every ecosystem:

* **Patch hosts.** A hosted reference counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin, with no userinfo. The uuid is the URL's LAST canonical-uuid path segment, because grant tokens may themselves be uuid-shaped. The Go module prefix is fixed. `socket-patch-<uuid>` registry / repository / source names count only through a pin. For a URL on any other host, see **Patch hosts** above.
* **Pins, not definitions.** A registry, index or source *definition* alone (cargo `[registries]`, nuget `<add>`, pom `<repository>`, uv index tables, `.npmrc`) never makes a reference, because it survives a reverted pin. Sections the package manager ignores are not read: npm's v2 `dependencies` mirror, a `.cargo/config.toml` shadowed by `.cargo/config`. A Socket pin inside a maven `<profile>` is diagnosed, never a reference.
* **Contested locks.** When one lock wires a package to a patch and another lock resolves the same `name@version` from a non-Socket source, the build's bytes depend on which package manager runs. The reference is then dropped with a `patched_ref_unattributable` diagnostic naming both files. This applies across npm / pnpm / yarn / bun and across uv / pylock / poetry / pdm / Pipfile.lock / requirements. PEP 723 script locks neither contest nor are contested.
* **Contested locks.** When one lock wires a package to a patch and another lock resolves the same `name@version` from a non-Socket source, the build's bytes depend on which package manager runs. The reference is then dropped with a `patched_ref_unattributable` diagnostic naming both files. This applies across npm / pnpm / yarn / bun and across uv / pylock / poetry / pdm / Pipfile.lock / requirements. PEP 723 script locks neither contest nor are contested. A **bundled** npm copy (`inBundle: true`, or v1 `bundled: true`) of the same `name@version` contests the reference too, in the same lock, in the other npm lock of a shrinkwrap/package-lock pair, or in any other lock. npm unpacks it from the parent package's tarball, so no rewire reaches it and it stays unpatched.
* **Lockless pins.** With no lock to name a version, a `Cargo.toml` pin (every declaration on `socket-patch-<uuid>`, that registry defined on the patch host for the same uuid) or an exclusive nuget exact-id mapping is never a reference on its own, so v5.0 does not attest it (nor does `list` show it, or `rollback` / `remove` restore it — restore those files from version control). Only a pre-v5 redirect-ledger record naming a version the pin admits keeps it live. The same holds for a gem wired only in the `Gemfile` (the pre-bundler-2.6 mixed state, lock not converged).

**Record resolution.** A candidate's record must carry the patch uuid the lockfile actually **wires**. It is taken from the first source that has one: the manifest (matched qualifier-insensitively), the hosted records above (this run's, then a pre-v5 ledger's), then the vendor ledger's embedded records. If none has it and the run is online, `vex` fetches the patch view by uuid from the patch API — for a v5 hosted checkout this is the normal path. The fetch uses `get`'s API client: the public proxy when no token is configured, and a one-shot 401/403 fallback to the proxy (free patches only). At most 10 fetches run concurrently. Fetched records stay in memory: `vex` never writes the manifest. A candidate still has no record under `--offline`, after a transport error or a 404, or when the patch is refused (paid without an entitled token); it is then omitted as `record_unavailable`, and the run is not aborted. A record whose uuid or package disagrees with the wiring is omitted as `record_mismatch`. The informational `socket-patch.vendor.json` marker is never a record source. When the lockfile wires a package to patch U, a manifest or ledger record for that package under another uuid is superseded, and a human-mode `Note:` says so.
Expand Down
55 changes: 55 additions & 0 deletions crates/socket-patch-cli/tests/e2e_vex_redirect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1618,6 +1618,61 @@ fn a_sibling_lock_resolving_the_registry_contests_a_ledger_record() {
}
}

/// REGRESSION (#325): the hosted rewriter rewires the hoisted
/// `left-pad@1.3.0` and skips a parent's bundled copy of the same version
/// (`redirect_npm_bundled_instance_skipped`: npm unpacks it from the
/// parent's tarball, so it stays unpatched). The ledger record must be dead
/// (`redirect_unwired`, naming the bundled copy), not attested from the
/// lock basis. Without the bundled copy the same ledger attests.
#[test]
fn hosted_npm_patch_with_an_unpatched_bundled_copy_is_not_attested() {
let purl = "pkg:npm/left-pad@1.3.0";
for bundled in [false, true] {
let tmp = tempfile::tempdir().unwrap();
let cwd = tmp.path();
write_hosted_package_lock(cwd, &[("left-pad", "1.3.0", UUID)], true);
if bundled {
let lock_path = cwd.join("package-lock.json");
let mut lock: Value =
serde_json::from_str(&std::fs::read_to_string(&lock_path).unwrap()).unwrap();
let packages = lock["packages"].as_object_mut().unwrap();
packages.insert(
"node_modules/bund".to_string(),
serde_json::json!({ "version": "1.0.0", "resolved": "file:bund-1.0.0.tgz" }),
);
packages.insert(
"node_modules/bund/node_modules/left-pad".to_string(),
serde_json::json!({
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",
"inBundle": true
}),
);
std::fs::write(&lock_path, lock.to_string()).unwrap();
}
write_redirect_ledger(
cwd,
&[(
purl,
make_record(UUID, &"b".repeat(64), "GHSA-bndl-host", &["CVE-2026-325"]),
)],
&[("package-lock.json", "npm_lock_entry")],
);
let (code, env) = vex_json(cwd, &["--offline", "--no-verify"]);
if !bundled {
assert_eq!(code, Some(0), "control: {env}");
continue;
}
assert_eq!(code, Some(1), "{env}");
assert_eq!(skipped_reason(&env, purl), "redirect_unwired", "{env}");
assert!(
env.to_string()
.contains("node_modules/bund/node_modules/left-pad"),
"the bundled copy is named: {env}"
);
}
}

/// REGRESSION: a hosted pin with NO lock at all is the rewriters' ordinary
/// output, not a stale shape — `rewrite_nuget` edits only nuget.config for a
/// project without RestorePackagesWithLockFile, `rewrite_cargo` only
Expand Down
129 changes: 129 additions & 0 deletions crates/socket-patch-cli/tests/e2e_vex_vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1343,6 +1343,135 @@ fn vendored_live_tree_out_of_sync_warns_but_attests() {
);
}

/// REGRESSION (#325): the lock rewires the hoisted `lodash@4.17.21` to the
/// vendored tarball, but a parent package also BUNDLES `lodash@4.17.21`
/// (`inBundle: true`). npm unpacks that copy from the parent's tarball, so
/// no rewire reaches it and the build ships it unpatched. `vex` must not
/// attest the purl, from the lock basis (no `node_modules`) or after an
/// install whose hoisted copy is patched and bundled copy is not. The
/// same lock without the bundled copy is the control and still attests.
#[test]
fn vendored_npm_patch_with_an_unpatched_bundled_copy_is_not_attested() {
let purl = "pkg:npm/lodash@4.17.21";
let uuid = "0a0a0a0a-1111-4111-8111-0a0a0a0a0a0a";
let patched = b"patched npm bytes\n";
let after_hash = compute_git_sha256_from_bytes(patched);
for (label, bundled, installed) in [
("control, lock basis", false, false),
("bundled, lock basis", true, false),
("bundled, installed", true, true),
] {
let tmp = tempfile::tempdir().expect("create tempdir");
let cwd = tmp.path();
let rel = format!(".socket/vendor/npm/{uuid}/lodash-4.17.21.tgz");
let sha256 = sha256_hex(&write_member_tgz(
&cwd.join(&rel),
"package/index.js",
patched,
));
let record = make_record(
uuid,
"package/index.js",
&after_hash,
"GHSA-bndl-aaaa",
&["CVE-2026-325"],
);
let wiring = write_matrix_wiring(cwd, "npm", uuid, &rel);
if bundled {
let lock_path = cwd.join("package-lock.json");
let mut lock: Value =
serde_json::from_str(&std::fs::read_to_string(&lock_path).unwrap()).unwrap();
let packages = lock["packages"].as_object_mut().unwrap();
packages.insert(
"node_modules/bund".to_string(),
serde_json::json!({ "version": "1.0.0", "resolved": "file:bund-1.0.0.tgz" }),
);
packages.insert(
"node_modules/bund/node_modules/lodash".to_string(),
serde_json::json!({
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-T1JJR0lOQUw=",
"inBundle": true
}),
);
std::fs::write(&lock_path, lock.to_string()).unwrap();
}
let mut state = VendorState::new();
state.entries.insert(
purl.to_string(),
detached_matrix_entry("npm", purl, uuid, &rel, sha256, record, wiring),
);
let dir = cwd.join(".socket/vendor");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("state.json"),
serde_json::to_string_pretty(&state).unwrap(),
)
.unwrap();
if installed {
for (pkg_dir, bytes) in [
("node_modules/lodash", &patched[..]),
(
"node_modules/bund/node_modules/lodash",
b"original unpatched bytes\n",
),
] {
let nm = cwd.join(pkg_dir);
std::fs::create_dir_all(&nm).unwrap();
std::fs::write(
nm.join("package.json"),
r#"{"name":"lodash","version":"4.17.21"}"#,
)
.unwrap();
std::fs::write(nm.join("index.js"), bytes).unwrap();
}
}

let vex_path = cwd.join("out.vex.json");
let out = cli()
.args([
"vex",
"--cwd",
cwd.to_str().unwrap(),
"--json",
"--output",
vex_path.to_str().unwrap(),
"--product",
"pkg:npm/app@1.0.0",
])
.output()
.expect("invoke vex");
let env: Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| {
panic!(
"{label}: envelope JSON on stdout ({e}): {}",
String::from_utf8_lossy(&out.stdout)
)
});
if !bundled {
assert!(out.status.success(), "{label}: {env}");
let doc: Value =
serde_json::from_str(&std::fs::read_to_string(&vex_path).unwrap()).unwrap();
assert_eq!(
doc["statements"].as_array().unwrap().len(),
1,
"{label}: {doc}"
);
continue;
}
assert_eq!(out.status.code(), Some(1), "{label}: {env}");
assert!(
!vex_path.exists(),
"{label}: no VEX document may attest the purl: {env}"
);
assert!(
env.to_string()
.contains("node_modules/bund/node_modules/lodash"),
"{label}: the envelope names the bundled copy: {env}"
);
}
}

// ──────────────────────────────────────────────────────────────────────
// 8. an applied, byte-verified agent-mode patch attests whether or not its
// ecosystem has an install hook (there is no setup-state filter).
Expand Down
2 changes: 1 addition & 1 deletion crates/socket-patch-core/src/vendor/lock_inventory/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ pub(crate) mod vlt;
pub(crate) mod wired;
pub(crate) mod yarn;

pub(crate) use self::npm::{npm_lock_nodes, NpmLockNode};
pub(crate) use self::npm::{npm_lock_bundled_nodes, npm_lock_nodes, NpmLockNode};
#[cfg(test)]
pub(crate) use self::npm_family::inventory_npm_lock;
pub(crate) use self::pypi::pipfile_lock_entries;
Expand Down
56 changes: 49 additions & 7 deletions crates/socket-patch-core/src/vendor/lock_inventory/npm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,20 +44,55 @@ const MAX_LEGACY_NPM_DEPTH: usize = 64;
/// through nested `dependencies`, `bundled: true` entries skipped (their
/// nested trees are still walked).
pub(crate) fn npm_lock_nodes(doc: &Value) -> Vec<NpmLockNode<'_>> {
walk_npm_lock(doc, Bundled::Skip)
.into_iter()
.map(|(_, node)| node)
.collect()
}

/// The BUNDLED entries of a parsed npm lock, each with where the lock puts
/// it: `inBundle: true` in `packages` (lockfileVersion 2/3; the location is
/// the `packages` key), `bundled: true` in the v1 `dependencies` tree (the
/// location is the `>`-joined chain of dependency names). These are exactly
/// the entries [`npm_lock_nodes`] skips for being bundled, read from the
/// same tree npm reads. npm unpacks them from the parent package's own
/// tarball, so a Socket rewire never reaches them: a bundled copy of a
/// patched `name@version` stays unpatched in the install (the rewriters
/// warn `*_bundled_instance_skipped`), and lockfile discovery
/// (`vex::discover::npm`) weighs it against the rewired entries.
pub(crate) fn npm_lock_bundled_nodes(doc: &Value) -> Vec<(String, NpmLockNode<'_>)> {
walk_npm_lock(doc, Bundled::Only)
}

/// Which side of the bundled split [`walk_npm_lock`] returns.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Bundled {
Skip,
Only,
}

/// [`npm_lock_nodes`] / [`npm_lock_bundled_nodes`]: one walk, split on the
/// bundled flag. Locations are built only for [`Bundled::Only`] (empty
/// otherwise), so the common walk allocates nothing extra.
fn walk_npm_lock(doc: &Value, bundled: Bundled) -> Vec<(String, NpmLockNode<'_>)> {
let mut out = Vec::new();
if let Some(packages) = doc.get("packages").and_then(Value::as_object) {
for (key, node) in packages {
let Some((_, key_name)) = key.rsplit_once("node_modules/") else {
continue;
};
if npm_flag(node, "link") || npm_flag(node, "inBundle") {
if npm_flag(node, "link") || npm_flag(node, "inBundle") != (bundled == Bundled::Only) {
continue;
}
let name = node.get("name").and_then(Value::as_str).unwrap_or(key_name);
out.push(NpmLockNode::of(name, node));
let location = match bundled {
Bundled::Only => key.clone(),
Bundled::Skip => String::new(),
};
out.push((location, NpmLockNode::of(name, node)));
}
} else if let Some(deps) = doc.get("dependencies").and_then(Value::as_object) {
walk_npm_legacy_dependencies(deps, 0, &mut out);
walk_npm_legacy_dependencies(deps, 0, bundled, "", &mut out);
}
out
}
Expand Down Expand Up @@ -89,17 +124,24 @@ fn npm_flag(node: &Value, key: &str) -> bool {
fn walk_npm_legacy_dependencies<'a>(
deps: &'a serde_json::Map<String, Value>,
depth: usize,
out: &mut Vec<NpmLockNode<'a>>,
bundled: Bundled,
parent: &str,
out: &mut Vec<(String, NpmLockNode<'a>)>,
) {
if depth > MAX_LEGACY_NPM_DEPTH {
return;
}
for (name, node) in deps {
if !npm_flag(node, "bundled") {
out.push(NpmLockNode::of(name, node));
let location = match bundled {
Bundled::Only if parent.is_empty() => name.clone(),
Bundled::Only => format!("{parent} > {name}"),
Bundled::Skip => String::new(),
};
if npm_flag(node, "bundled") == (bundled == Bundled::Only) {
out.push((location.clone(), NpmLockNode::of(name, node)));
}
if let Some(nested) = node.get("dependencies").and_then(Value::as_object) {
walk_npm_legacy_dependencies(nested, depth + 1, out);
walk_npm_legacy_dependencies(nested, depth + 1, bundled, &location, out);
}
}
}
Expand Down
Loading
Loading