Skip to content

fix(apply): patch every on-disk copy of a duplicated package (multi-copy silent partial P0) - #216

Merged
Mikola Lysenko (mikolalysenko) merged 2 commits into
mainfrom
fix/npm-multicopy-apply-fanout
Aug 19, 2026
Merged

fix(apply): patch every on-disk copy of a duplicated package (multi-copy silent partial P0)#216
Mikola Lysenko (mikolalysenko) merged 2 commits into
mainfrom
fix/npm-multicopy-apply-fanout

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

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@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 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.rsfind_by_purls returned HashMap<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 into HashMap<String, PathBuf> via entry(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 inverse merge_qualified release-variant fan-out), but npm genuinely nests duplicates.

The fix

  • Crawler: 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 existing 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 & rollback iterate every path per PURL and count per copy: the JSON summary now 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_modules tree).

TDD evidence (red → green)

  • New host-level e2e crates/socket-patch-cli/tests/in_process_npm_multicopy.rs: builds a two-copy tree (root + nested), runs the real apply/rollback flow through the built binary, asserts BOTH copies patched (byte-for-byte + git-sha256) AND summary.applied == 2, then BOTH restored on rollback.
    • RED on base: copy B(nested) ... was NOT patched (silent partial).
    • GREEN after the fix.
  • New dispatch unit test proves the merge carries multiple paths per PURL; new crawler unit test proves both copies are returned root-first.
  • Renamed find_by_purls_prefers_root_copy_over_nested_duplicatefind_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.
  • docker-e2e npm (6) + cargo (1) + pypi (5) suites green in host mode.
  • clippy clean on all changed files.

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:

  • Fixed binary: both nested copies oracle=FIXED, rollback restores both (rolledBack: 2) → ===E2E PASS===.
  • Baked (unfixed) binary: C1 FIXED / C2 VULNERABLE===A2 PRODUCT BUG CONFIRMED=== (silent partial reproduced).

Files changed

  • crates/socket-patch-core/src/crawlers/npm_crawler.rsfind_by_purls returns 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 crawlerfind_by_purls now 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 uses merge_npm_copies. New find_all_packages_for_purls / find_all_packages_for_rollback feed 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_multicopy e2e plus crawler/dispatch unit updates for the multi-copy contract.

Reviewed by Cursor Bugbot for commit 063e79a. Configure here.

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

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

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.

Create PR

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.

Comment thread crates/socket-patch-cli/src/commands/rollback.rs
Comment thread crates/socket-patch-cli/src/commands/rollback.rs
…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>
@mikolalysenko
Mikola Lysenko (mikolalysenko) merged commit 0433bcb into main Aug 19, 2026
100 of 101 checks passed
@mikolalysenko
Mikola Lysenko (mikolalysenko) deleted the fix/npm-multicopy-apply-fanout branch August 19, 2026 19:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants