fix(apply): patch every on-disk copy of a duplicated package (multi-copy silent partial P0) - #216
Merged
Mikola Lysenko (mikolalysenko) merged 2 commits intoAug 19, 2026
Conversation
…opy silent partial P0) When >=2 real on-disk copies of the SAME name@version exist in a node_modules tree (an npm nested duplicate, a diamond dependency that cannot hoist past a conflicting top slot, or a `file:` dup), agent-mode `scan --sync`/`apply` patched only ONE copy, left the other(s) with pristine (vulnerable) bytes, and reported status=success with no signal a second copy existed. For a security tool that is a silent false-success leaving an exploitable copy live -- P0 (reproduces on npm 2/3/4/6/7/9/12). Root cause (shared dispatch, ecosystem-wide): - `NpmCrawler::find_by_purls` returned `HashMap<String, CrawledPackage>` = one path per PURL, resolving one copy per PURL and stopping (root preference as a STOP condition). - The shared `ecosystem_dispatch` merge folded crawler results into `HashMap<String, PathBuf>` via `entry(purl).or_insert(path)` -- one path per PURL. The type itself could not carry a second copy, for EVERY ecosystem's apply/rollback consumers. Fix: - `find_by_purls` now returns `HashMap<String, Vec<CrawledPackage>>` and collects EVERY importer-tree copy, root-copy-first. Root preference survives as ORDERING ONLY, never as a stop condition. pnpm's virtual-store peer variants stay the apply engine's `find_pnpm_peer_ variant_copies` fan-out job (the store is probed only for targets with no importer-tree copy), so pnpm direct-dep behavior is unchanged and transitive-only discovery is preserved. - Dispatch merge accumulates into `HashMap<String, Vec<PathBuf>>` (`merge_npm_copies` for npm; `merge_first_wins`/`merge_qualified` push into the Vec, preserving `merge_qualified`'s inverse fan-out for pypi/gem/maven). New `find_all_packages_for_purls`/ `find_all_packages_for_rollback` return the multi-path map; the existing one-path wrappers collapse to the root-preferred first path so vendor/vex/setup/get/repair-vendor are unchanged. - apply and rollback iterate every path per PURL and count per copy: the JSON summary now counts each patched copy (the signal a second copy exists); a failure on any copy fails the run. Release-variant ecosystems keep their group + narrow path (one install dir per version). TDD evidence (red -> green): - New host-level e2e (`in_process_npm_multicopy.rs`): builds a two-copy tree, runs the real apply/rollback flow; asserts BOTH copies patched (byte + git-sha256) and `summary.applied == 2`, then BOTH restored. RED on base (copy B left VULNERABLE); GREEN after. - New dispatch unit test proves the merge carries multiple paths per PURL; new crawler unit test proves both copies returned root-first. - Updated `find_by_purls_prefers_root_copy_over_nested_duplicate` -> `..._returns_root_and_nested_duplicate_root_first` (root-first ordering is fine; leaving a sibling unpatched is not). Regression coverage: full `cargo test --workspace` green (4677 passed, 0 failed); docker-e2e npm + cargo + pypi suites green in host mode. Hermetic era-image proof (real prototype-pollution oracle, `--network none`): a static musl binary built from this worktree, mounted over the baked binary in the npm 9 and npm 12 era images, patches BOTH nested copies (oracle=FIXED on each) and rollback restores both; the baked (unfixed) binary leaves the second copy oracle=VULNERABLE (silent partial confirmed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Blob gates ignore extra copies
- Updated both blob gates to check all physical copies per PURL instead of only the first path, ensuring blobs are fetched for any copy that needs them.
Or push these changes by commenting:
@cursor push 63f3471113
Preview (63f3471113)
diff --git a/crates/socket-patch-cli/src/commands/apply.rs b/crates/socket-patch-cli/src/commands/apply.rs
--- a/crates/socket-patch-cli/src/commands/apply.rs
+++ b/crates/socket-patch-cli/src/commands/apply.rs
@@ -68,7 +68,7 @@
async fn ensure_blobs_for_mismatches(
args: &ApplyArgs,
manifest: &PatchManifest,
- all_packages: &HashMap<String, PathBuf>,
+ all_packages: &HashMap<String, Vec<PathBuf>>,
vendored_purls: &HashSet<String>,
staged: &mut StagedSources,
) {
@@ -142,13 +142,13 @@
/// queue fetches either.
async fn mismatch_blob_gaps(
manifest: &PatchManifest,
- all_packages: &HashMap<String, PathBuf>,
+ all_packages: &HashMap<String, Vec<PathBuf>>,
vendored_purls: &HashSet<String>,
blobs_path: &Path,
force: bool,
) -> HashSet<String> {
let mut needed: HashSet<String> = HashSet::new();
- for (purl, pkg_path) in all_packages {
+ for (purl, pkg_paths) in all_packages {
let variant_eco = Ecosystem::from_purl(purl).is_some_and(|e| e.supports_release_variants());
let stripped = strip_purl_qualifiers(purl);
let records: Vec<(&String, &PatchRecord)> = manifest
@@ -164,36 +164,41 @@
{
continue;
}
- let gated = variant_eco
- && !force
- && (records.len() > 1
- || records
- .first()
- .is_some_and(|(key, _)| key.as_str() != stripped));
- for (_, record) in records {
- if gated {
- if let Some((file_name, file_info)) = representative_file(&record.files) {
- let status = verify_file_patch(pkg_path, file_name, file_info)
- .await
- .status;
- if !variant_matches_installed(Some(&status)) {
+ // Check every copy: each copy's on-disk state is independent, so a
+ // nested copy can have a HashMismatch even when the root copy is
+ // Ready/AlreadyPatched. Need-blob is per copy, not per hash.
+ for pkg_path in pkg_paths {
+ let gated = variant_eco
+ && !force
+ && (records.len() > 1
+ || records
+ .first()
+ .is_some_and(|(key, _)| key.as_str() != stripped));
+ for (_, record) in &records {
+ if gated {
+ if let Some((file_name, file_info)) = representative_file(&record.files) {
+ let status = verify_file_patch(pkg_path, file_name, file_info)
+ .await
+ .status;
+ if !variant_matches_installed(Some(&status)) {
+ continue;
+ }
+ }
+ }
+ for (file_name, info) in &record.files {
+ if info.before_hash.is_empty() {
continue;
}
+ let verify = verify_file_patch(pkg_path, file_name, info).await;
+ if verify.status == VerifyStatus::HashMismatch
+ && tokio::fs::metadata(blobs_path.join(&info.after_hash))
+ .await
+ .is_err()
+ {
+ needed.insert(info.after_hash.clone());
+ }
}
}
- for (file_name, info) in &record.files {
- if info.before_hash.is_empty() {
- continue;
- }
- let verify = verify_file_patch(pkg_path, file_name, info).await;
- if verify.status == VerifyStatus::HashMismatch
- && tokio::fs::metadata(blobs_path.join(&info.after_hash))
- .await
- .is_err()
- {
- needed.insert(info.after_hash.clone());
- }
- }
}
}
needed
@@ -1083,14 +1088,6 @@
)
.await;
- // One representative path per PURL, for the mismatch-blob gate and the
- // pre-attempt checks that only need "is it installed / a sample copy"
- // (the gate's fetch decision is per-hash, identical across copies).
- let first_paths: HashMap<String, PathBuf> = all_packages
- .iter()
- .filter_map(|(purl, paths)| paths.first().map(|p| (purl.clone(), p.clone())))
- .collect();
-
if all_packages.is_empty() && partitioned.is_empty() {
// Nothing in scope: the manifest lists no patches (or every patch was
// filtered out by `--ecosystems`). There is genuinely no work to do,
@@ -1128,7 +1125,7 @@
}
// Apply patches
- ensure_blobs_for_mismatches(args, &manifest, &first_paths, &vendored_purls, &mut staged).await;
+ ensure_blobs_for_mismatches(args, &manifest, &all_packages, &vendored_purls, &mut staged).await;
let sources = staged.as_patch_sources();
let policy = mismatch_policy(args.force, args.common.strict);
let mut has_errors = false;
@@ -1751,7 +1748,7 @@
files,
);
let mut all_packages = HashMap::new();
- all_packages.insert("pkg:pypi/foo@1.0.0".to_string(), pkg.clone());
+ all_packages.insert("pkg:pypi/foo@1.0.0".to_string(), vec![pkg.clone()]);
let needed =
mismatch_blob_gaps(&manifest, &all_packages, &HashSet::new(), &blobs, false).await;
@@ -1821,7 +1818,7 @@
},
);
let mut all_packages = HashMap::new();
- all_packages.insert("pkg:pypi/foo@1.0.0".to_string(), pkg.clone());
+ all_packages.insert("pkg:pypi/foo@1.0.0".to_string(), vec![pkg.clone()]);
let needed =
mismatch_blob_gaps(&manifest, &all_packages, &HashSet::new(), &blobs, false).await;
@@ -1867,7 +1864,7 @@
);
let manifest = manifest_with_record("pkg:gem/foo@1.0.0", files);
let mut all_packages = HashMap::new();
- all_packages.insert("pkg:gem/foo@1.0.0".to_string(), pkg.clone());
+ all_packages.insert("pkg:gem/foo@1.0.0".to_string(), vec![pkg.clone()]);
let needed =
mismatch_blob_gaps(&manifest, &all_packages, &HashSet::new(), &blobs, false).await;
@@ -1907,7 +1904,7 @@
);
let manifest = manifest_with_record("pkg:gem/foo@1.0.0?platform=x86_64-linux", files);
let mut all_packages = HashMap::new();
- all_packages.insert("pkg:gem/foo@1.0.0".to_string(), pkg.clone());
+ all_packages.insert("pkg:gem/foo@1.0.0".to_string(), vec![pkg.clone()]);
let needed =
mismatch_blob_gaps(&manifest, &all_packages, &HashSet::new(), &blobs, false).await;
@@ -1954,7 +1951,7 @@
);
let manifest = manifest_with_record("pkg:gem/foo@1.0.0", files);
let mut all_packages = HashMap::new();
- all_packages.insert("pkg:gem/foo@1.0.0".to_string(), pkg.clone());
+ all_packages.insert("pkg:gem/foo@1.0.0".to_string(), vec![pkg.clone()]);
let needed =
mismatch_blob_gaps(&manifest, &all_packages, &HashSet::new(), &blobs, false).await;
@@ -2002,7 +1999,7 @@
);
let manifest = manifest_with_record("pkg:npm/foo@1.0.0", files);
let mut all_packages = HashMap::new();
- all_packages.insert("pkg:npm/foo@1.0.0".to_string(), pkg.clone());
+ all_packages.insert("pkg:npm/foo@1.0.0".to_string(), vec![pkg.clone()]);
let needed =
mismatch_blob_gaps(&manifest, &all_packages, &HashSet::new(), &blobs, false).await;
diff --git a/crates/socket-patch-cli/src/commands/rollback.rs b/crates/socket-patch-cli/src/commands/rollback.rs
--- a/crates/socket-patch-cli/src/commands/rollback.rs
+++ b/crates/socket-patch-cli/src/commands/rollback.rs
@@ -742,9 +742,9 @@
)
.await;
- // One representative path per PURL for the "is it installed" checks and
- // the before-blob gate (the gate's fetch decision is per-hash, identical
- // across copies). The per-copy restore uses `all_packages_multi`.
+ // One representative path per PURL for the "is it installed" checks.
+ // The before-blob gate now checks every copy (need-blob is per on-disk
+ // state, not per hash), so it uses `rollback_targets` directly.
let all_packages: HashMap<String, PathBuf> = all_packages_multi
.iter()
.filter_map(|(purl, paths)| paths.first().map(|p| (purl.clone(), p.clone())))
@@ -931,22 +931,33 @@
// it must not abort the run or trigger a download; the rollback loop's
// own per-file verification still reports those states honestly
// (already_original / not_found / hash_mismatch).
+ //
+ // Check every copy: each copy's on-disk state is independent, so a
+ // nested copy can be patched while the root copy is already original.
+ // Need-blob is per copy, not per hash — the gate must probe every
+ // physical copy that will be attempted, or a later copy can be missing
+ // its blob even when an online run fetches for the first copy.
let absent_blobs = get_missing_before_blobs(&gate_manifest, &blobs_path).await;
let mut missing_blobs: HashSet<String> = HashSet::new();
let mut blob_gated_purls: HashSet<String> = HashSet::new();
if !absent_blobs.is_empty() {
for (purl, patch) in &gate_manifest.patches {
- let pkg_path = all_packages
- .get(purl)
- .expect("gate manifest holds only attempted targets, which the crawler discovered");
- for (file, info) in &patch.files {
- if info.before_hash.is_empty() || !absent_blobs.contains(&info.before_hash) {
+ // Probe every copy of this PURL that will be attempted. The
+ // rollback loop below restores every copy in `rollback_targets`,
+ // so the gate must verify all of them too.
+ for (target_purl, pkg_path) in &rollback_targets {
+ if *target_purl != purl {
continue;
}
- let v = verify_file_rollback(pkg_path, file, info, &blobs_path).await;
- if v.status == VerifyRollbackStatus::MissingBlob {
- missing_blobs.insert(info.before_hash.clone());
- blob_gated_purls.insert(purl.clone());
+ for (file, info) in &patch.files {
+ if info.before_hash.is_empty() || !absent_blobs.contains(&info.before_hash) {
+ continue;
+ }
+ let v = verify_file_rollback(pkg_path, file, info, &blobs_path).await;
+ if v.status == VerifyRollbackStatus::MissingBlob {
+ missing_blobs.insert(info.before_hash.clone());
+ blob_gated_purls.insert(purl.clone());
+ }
}
}
}You can send follow-ups to the cloud agent here.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 063e79a. Configure here.
…le-patch regression) The multi-copy fan-out over-generalized merge_first_wins into an accumulate: for single-copy ecosystems (cargo/go/composer/nuget/deno) the same logical install is legitimately reachable from several source roots (NuGet's global cache + a project-local packages folder), so accumulating every root made apply re-patch what is effectively one package — producing a spurious `already_patched` skip that broke docker_e2e_nuget's `skipped:1` contract, and, for a shared global cache, mutating state other projects rely on. Restore true first-wins for those ecosystems (byte-identical to the prior HashMap<String,PathBuf> contract that passed nuget CI). Genuine multi-copy fan-out stays npm-only via merge_npm_copies. Corrects the unit test that encoded the wrong intent and adds a regression guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mikola Lysenko (mikolalysenko)
enabled auto-merge (squash)
August 19, 2026 17:29
Wenxin Jiang (Wenxin-Jiang)
approved these changes
Aug 19, 2026
Mikola Lysenko (mikolalysenko)
merged commit Aug 19, 2026
0433bcb
into
main
100 of 101 checks passed
Mikola Lysenko (mikolalysenko)
deleted the
fix/npm-multicopy-apply-fanout
branch
August 19, 2026 19:17
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


The bug (P0, confirmed on npm 2/3/4/6/7/9/12)
When two or more real on-disk copies of the same
name@versionexist in anode_modulestree — an npm nested duplicate, a diamond dependency that cannot hoist past a conflicting top slot, or afile:dup — agent-modescan --sync/applypatched only ONE copy, left the other(s) with pristine (vulnerable) bytes, and reportedstatus=successwith no signal a second copy existed. For a security tool this is a silent false-success that leaves an exploitable copy live.Root cause (shared dispatch — ecosystem-wide)
crates/socket-patch-core/src/crawlers/npm_crawler.rs—find_by_purlsreturnedHashMap<String, CrawledPackage>= one path per PURL. Its BFS resolved one copy per PURL and stopped (root preference used as a stop condition, not just ordering).crates/socket-patch-cli/src/ecosystem_dispatch.rs— the shared merge folded crawler results intoHashMap<String, PathBuf>viaentry(purl).or_insert(path). The type itself could not carry a second copy, for every ecosystem's apply/rollback consumers. Most ecosystems have exactly one copy per version (cargo/go global cache; pypi/gem/maven use the inversemerge_qualifiedrelease-variant fan-out), but npm genuinely nests duplicates.The fix
find_by_purlsnow returnsHashMap<String, Vec<CrawledPackage>>and collects every importer-tree copy, root-copy-first. Root preference survives as ordering only, never as a stop condition. pnpm's virtual-store peer variants stay the apply engine's existingfind_pnpm_peer_variant_copiesfan-out job — the store is probed only for targets with no importer-tree copy — so pnpm direct-dep behavior is unchanged and transitive-only discovery is preserved.HashMap<String, Vec<PathBuf>>(merge_npm_copiesfor npm;merge_first_wins/merge_qualifiedpush into the Vec, preservingmerge_qualified's inverse fan-out for pypi/gem/maven). Newfind_all_packages_for_purls/find_all_packages_for_rollbackreturn the multi-path map; the existing one-path wrappers collapse to the root-preferred first path, so vendor/vex/setup/get/repair-vendor are unchanged.summarynow counts each patched/restored copy (the signal a second copy exists), and a failure on any copy fails the run. Release-variant ecosystems keep their group-and-narrow path (one install dir per version).npm-version blast radius
Reproduces silently on npm 1–12 (nested layout on npm 1/2, hoist-conflict nesting on npm 3+). The fix is version-agnostic (it operates on the physical
node_modulestree).TDD evidence (red → green)
crates/socket-patch-cli/tests/in_process_npm_multicopy.rs: builds a two-copy tree (root + nested), runs the realapply/rollbackflow through the built binary, asserts BOTH copies patched (byte-for-byte + git-sha256) ANDsummary.applied == 2, then BOTH restored on rollback.copy B(nested) ... was NOT patched (silent partial).find_by_purls_prefers_root_copy_over_nested_duplicate→find_by_purls_returns_root_and_nested_duplicate_root_first(root-first ordering is fine; leaving a sibling unpatched is not).Regression coverage
cargo test --workspace: 4677 passed, 0 failed, 96 ignored.Hermetic era-image proof (real oracle,
--network none)A static musl binary built from this worktree, mounted over the baked binary in the npm 9 and npm 12 era images, running the confirmed A2 nested-dupe repros with a REAL prototype-pollution oracle:
oracle=FIXED, rollback restores both (rolledBack: 2) →===E2E PASS===.C1 FIXED / C2 VULNERABLE→===A2 PRODUCT BUG CONFIRMED===(silent partial reproduced).Files changed
crates/socket-patch-core/src/crawlers/npm_crawler.rs—find_by_purlsreturns all copies (Vec), root-first.crates/socket-patch-cli/src/ecosystem_dispatch.rs— Vec-valued merge,merge_npm_copies,find_all_*+ collapsing wrappers.crates/socket-patch-cli/src/commands/apply.rs— iterate every copy, count per copy.crates/socket-patch-cli/src/commands/rollback.rs— restore every copy (per-copy targets for non-variant ecosystems).crates/socket-patch-cli/tests/in_process_npm_multicopy.rs— new host-level e2e.crates/socket-patch-core/tests/crawler_npm_e2e.rs,crawlers_empty_paths_e2e.rs— updated to the Vec contract; multi-copy intent.🤖 Generated with Claude Code
Note
High Risk
Changes security-critical apply and rollback paths for npm multi-copy trees; incorrect resolution could still leave vulnerable bytes on disk, though coverage is extensive.
Overview
Fixes a silent partial apply when npm installs more than one on-disk copy of the same
name@version(nested duplicates, diamonds,file:dupes): only one copy was patched or rolled back while the run still reported success.npm crawler —
find_by_purlsnow returns every matching physical path per PURL (root-first), and keeps searching after the first match instead of treating root preference as a stop condition. pnpm store probing stays limited to targets with no importer-tree copy.Dispatch — Package resolution maps PURLs to
Vec<PathBuf>; npm usesmerge_npm_copies. Newfind_all_packages_for_purls/find_all_packages_for_rollbackfeed apply and rollback; existing one-path helpers collapse to the first path so vendor/vex/setup behavior is unchanged.apply — Loops over each resolved path per PURL (per-copy results and JSON events). Mismatch-blob prefetch still uses one representative path per PURL.
rollback — Restores every copy for non–release-variant ecosystems; release-variant grouping no longer collapses npm multi-copy installs into variant groups.
Tests — New host-level
in_process_npm_multicopye2e plus crawler/dispatch unit updates for the multi-copy contract.Reviewed by Cursor Bugbot for commit 063e79a. Configure here.