diff --git a/crates/socket-patch-cli/src/commands/vex_consumed.rs b/crates/socket-patch-cli/src/commands/vex_consumed.rs index b57d475fb..cb0c68023 100644 --- a/crates/socket-patch-cli/src/commands/vex_consumed.rs +++ b/crates/socket-patch-cli/src/commands/vex_consumed.rs @@ -715,8 +715,11 @@ mod tests { None, ) .await; - assert_eq!(installed_again, installed); - let (paths, calls) = tracked_npm_hosted(&common, &installed_again).await; + // Since #605 the name-keyed resolver probes bundled trees itself, so + // it already returns the aliases and the nested store's peers. Feed + // the earlier, alias-free set to keep exercising alias expansion; + // the resolver's own set is checked against the same result below. + let (paths, calls) = tracked_npm_hosted(&common, &installed).await; assert_eq!(calls.len(), 1); let mut inputs = calls[0].clone(); inputs.sort(); @@ -738,6 +741,9 @@ mod tests { .len(), paths.len() ); + let (mut resolved, _) = tracked_npm_hosted(&common, &installed_again).await; + resolved.sort(); + assert_eq!(resolved, expected, "the resolver's own copy set"); } #[cfg(unix)] @@ -768,14 +774,19 @@ mod tests { None, ) .await; - assert!(installed.is_empty(), "{installed:?}"); - let (mut paths, calls) = tracked_npm_hosted(&common, &installed).await; + // Since #605 the name-keyed resolver reaches the alias and its + // sibling peers on its own. An alias-only set (what an alias-blind + // resolver returns) must still expand to the same copies. + let (mut paths, calls) = tracked_npm_hosted(&common, &HashMap::new()).await; assert_eq!(calls, vec![vec![alias.clone()]]); let mut expected = peers; expected.push(alias); paths.sort(); expected.sort(); assert_eq!(paths, expected); + let (mut resolved, _) = tracked_npm_hosted(&common, &installed).await; + resolved.sort(); + assert_eq!(resolved, expected, "the resolver's own copy set"); } #[cfg(unix)] diff --git a/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs index d06c3d6c7..6c141a4dc 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs @@ -1177,101 +1177,183 @@ fn npm_vendor_shrinkwrap_fresh_checkout_npm_ci_and_manifestless_vex() { /// N/A for npm >= 7 (their own v2/v3 flows are the capstones above). #[test] fn npm6_installs_a_vendored_v2_lock_from_its_legacy_mirror() { - let suite = "e2e_vendor_npm_build (npm 6 × v2 lock)"; - let Some(major) = npm_major_or_skip(suite) else { + let Some(cell) = Npm6Cell::vendor( + "e2e_vendor_npm_build (npm 6 × v2 lock)", + &format!("{DEP}@{DEP_VERSION}"), + DEP, + ) else { return; }; - if major > 6 { - println!("N/A {suite}: npm {major} is not npm 6"); - return; - } - let Some(writer) = npm_e2e_common::modern_npm_writer() else { - npm_e2e_common::skip( - suite, - "no npm >= 7 to write the v2 lock (set SOCKET_PATCH_NPM_E2E_LOCK_WRITER_BIN)", - ); + // `_tmp` keeps the cell's temp dir alive through the tail. + let Npm6Cell { + tmp: _tmp, + fresh, + patched, + lock_before, + .. + } = cell; + vendored_manifestless_tail( + "flavor=package-lock-v2 install=patched", + &fresh, + &patched, + TAIL_GHSA, + vec![("package-lock.json", lock_before)], + &[VexVia::Apply, VexVia::Vendor], + ); +} + +/// #432: the same npm 6 cell for an npm ALIAS install (`npm i +/// lp@npm:left-pad@1.3.0`). The v2 mirror spells it `"lp": {"version": +/// "npm:left-pad@1.3.0"}`; vendoring used to skip that node with +/// `vendor_legacy_alias_skipped`, so npm 6 installed the unpatched registry +/// bytes. npm 6 installs an alias node from its `file:` `resolved`, so it +/// is rewired, npm 6 installs the PATCHED bytes, and revert restores the +/// lock byte-for-byte. +#[test] +fn npm6_installs_a_vendored_v2_alias_from_its_legacy_mirror() { + let Some(cell) = Npm6Cell::vendor( + "e2e_vendor_npm_build (npm 6 × v2 lock × alias)", + &format!("lp@npm:{DEP}@{DEP_VERSION}"), + "lp", + ) else { return; }; - let tmp = tempfile::tempdir().unwrap(); - let proj = tmp.path().join("proj"); - std::fs::create_dir_all(&proj).unwrap(); - std::fs::write( - proj.join("package.json"), - r#"{"name":"vendor-npm6","version":"0.0.0","private":true}"#, - ) - .unwrap(); - let cache = tmp.path().join("npm-cache"); - let install = npm_e2e_common::npm_command_for(&writer, &proj) - .args([ - "install", - &format!("{DEP}@{DEP_VERSION}"), - "--lockfile-version", - "2", - "--no-audit", - "--no-fund", - "--cache", - cache.to_str().unwrap(), - ]) - .output() - .unwrap(); - if !install.status.success() { - npm_e2e_common::skip(suite, &npm_e2e_common::output_text(&install)); - return; - } - assert_eq!(npm_e2e_common::lockfile_version(&proj), Some(2)); - let orig = std::fs::read(proj.join("node_modules").join(DEP).join("index.js")).unwrap(); - let patched: Vec = [MARKER.as_bytes(), orig.as_slice()].concat(); - let purl = format!("pkg:npm/{DEP}@{DEP_VERSION}"); - stage_patch_with_vuln(&proj, &purl, "package/index.js", &orig, &patched, TAIL_GHSA); - let lock_before = std::fs::read(proj.join("package-lock.json")).unwrap(); - + assert!( + !cell.vendor_stdout.contains("vendor_legacy_alias_skipped"), + "{}", + cell.vendor_stdout + ); let (code, stdout, stderr) = run_socket( - &proj, + &cell.proj, &[ "vendor", + "--revert", "--json", "--offline", "--cwd", - proj.to_str().unwrap(), + cell.proj.to_str().unwrap(), ], ); assert_eq!( code, 0, - "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" - ); - let lock: serde_json::Value = - serde_json::from_slice(&std::fs::read(proj.join("package-lock.json")).unwrap()).unwrap(); - let want = format!("file:.socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz"); - assert_eq!( - lock["packages"][format!("node_modules/{DEP}")]["resolved"], - want + "revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); assert_eq!( - lock["dependencies"][DEP]["resolved"], want, - "the v2 legacy mirror (what npm 6 reads) must be rewired too" + std::fs::read(cell.proj.join("package-lock.json")).unwrap(), + cell.lock_before, + "revert must restore the lock byte-for-byte" ); +} - let fresh = tmp.path().join("fresh"); - npm_e2e_common::fresh_checkout(&proj, &fresh, &["package-lock.json"]); - let ci = npm_e2e_common::npm_ci(&fresh, &tmp.path().join("fresh-npm-cache")); - assert!( - ci.status.success(), - "npm {major} `npm ci` of the vendored v2 lock must succeed.\n{}", - npm_e2e_common::output_text(&ci) - ); - assert_eq!( - std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(), - patched, - "npm 6 must install the PATCHED bytes from the vendored tarball" - ); - vendored_manifestless_tail( - "flavor=package-lock-v2 install=patched", - &fresh, - &patched, - TAIL_GHSA, - vec![("package-lock.json", lock_before)], - &[VexVia::Apply, VexVia::Vendor], - ); +/// One npm 6 × vendored v2 lock cell: a modern npm writes the v2 lock for +/// `install` (landing in `node_modules/`), `vendor` rewires both lock +/// halves, and npm 6's fresh `npm ci` must install the PATCHED bytes. +/// `None` after a skip or an N/A (npm >= 7). +struct Npm6Cell { + tmp: tempfile::TempDir, + proj: PathBuf, + fresh: PathBuf, + patched: Vec, + lock_before: Vec, + vendor_stdout: String, +} + +impl Npm6Cell { + fn vendor(suite: &str, install: &str, dir: &str) -> Option { + let major = npm_major_or_skip(suite)?; + if major > 6 { + println!("N/A {suite}: npm {major} is not npm 6"); + return None; + } + let Some(writer) = npm_e2e_common::modern_npm_writer() else { + npm_e2e_common::skip( + suite, + "no npm >= 7 to write the v2 lock (set SOCKET_PATCH_NPM_E2E_LOCK_WRITER_BIN)", + ); + return None; + }; + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + std::fs::write( + proj.join("package.json"), + r#"{"name":"vendor-npm6","version":"0.0.0","private":true}"#, + ) + .unwrap(); + let cache = tmp.path().join("npm-cache"); + let output = npm_e2e_common::npm_command_for(&writer, &proj) + .args([ + "install", + install, + "--lockfile-version", + "2", + "--no-audit", + "--no-fund", + "--cache", + cache.to_str().unwrap(), + ]) + .output() + .unwrap(); + if !output.status.success() { + npm_e2e_common::skip(suite, &npm_e2e_common::output_text(&output)); + return None; + } + assert_eq!(npm_e2e_common::lockfile_version(&proj), Some(2)); + let orig = std::fs::read(proj.join("node_modules").join(dir).join("index.js")).unwrap(); + let patched: Vec = [MARKER.as_bytes(), orig.as_slice()].concat(); + let purl = format!("pkg:npm/{DEP}@{DEP_VERSION}"); + stage_patch_with_vuln(&proj, &purl, "package/index.js", &orig, &patched, TAIL_GHSA); + let lock_before = std::fs::read(proj.join("package-lock.json")).unwrap(); + + let (code, stdout, stderr) = run_socket( + &proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_eq!( + code, 0, + "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let lock: serde_json::Value = + serde_json::from_slice(&std::fs::read(proj.join("package-lock.json")).unwrap()) + .unwrap(); + let want = format!("file:.socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz"); + assert_eq!( + lock["packages"][format!("node_modules/{dir}")]["resolved"], + want + ); + assert_eq!( + lock["dependencies"][dir]["resolved"], want, + "the v2 legacy mirror (what npm 6 reads) must be rewired too" + ); + + let fresh = tmp.path().join("fresh"); + npm_e2e_common::fresh_checkout(&proj, &fresh, &["package-lock.json"]); + let ci = npm_e2e_common::npm_ci(&fresh, &tmp.path().join("fresh-npm-cache")); + assert!( + ci.status.success(), + "npm {major} `npm ci` of the vendored v2 lock must succeed.\n{}", + npm_e2e_common::output_text(&ci) + ); + assert_eq!( + std::fs::read(fresh.join("node_modules").join(dir).join("index.js")).unwrap(), + patched, + "npm 6 must install the PATCHED bytes from the vendored tarball" + ); + Some(Npm6Cell { + tmp, + proj, + fresh, + patched, + lock_before, + vendor_stdout: stdout, + }) + } } /// The one real package dir npm's linked store holds for `name@version` diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 8cd5e6e26..4756cfe38 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -26,6 +26,7 @@ use serde_json::{json, Value}; use crate::utils::digest::is_hex64_lower; use crate::utils::line_endings::{to_lf, LineEndings}; use crate::vendor::common::{parse_json_text, JsonLayout}; +use crate::vendor::lock_inventory::npm_legacy_identity; use crate::vendor::npm_origin::{legacy_packages_key, npm_non_registry_entries, NpmOverrides}; use crate::vendor::yarn_berry_lock::yarnrc_compression_level; @@ -944,6 +945,8 @@ fn rewrite_one_npm_lock( // Match the shared npm lock inventory's object-valued-map precedence. let legacy_is_install_tree = lock.get("packages").and_then(Value::as_object).is_none(); let mut changed = false; + // Legacy `dependencies` alias nodes rewired this run (#432). + let mut aliased: Vec = Vec::new(); for dep in npm { let fname = full_name(dep); let Some(sha512) = dep.integrity.sha512.clone() else { @@ -1036,6 +1039,7 @@ fn rewrite_one_npm_lock( legacy_is_install_tree, result, &mut matched_any, + &mut aliased, ) || changed; } // Parity with the pnpm/berry/uv rewriters: a granted dep the @@ -1056,7 +1060,31 @@ fn rewrite_one_npm_lock( // redirected lock therefore fails EINTEGRITY against the patched // sha512 pin (fail-closed: the unpatched bytes never install). Say // so instead of letting an npm 6 CI discover it. - if lock.get("lockfileVersion").and_then(Value::as_u64) == Some(1) { + let v1 = lock.get("lockfileVersion").and_then(Value::as_u64) == Some(1); + // A v2 lock's legacy mirror is what npm 6 installs from. npm 6 + // installs a plain mirror node from its rewritten `resolved`, but an + // ALIAS node from the configured registry (verified against real + // npm 6.14.18), so its installs fail EINTEGRITY against the patched + // pin. The v1 caveat below already says this for every node. + if !v1 && !aliased.is_empty() { + result.warnings.push(RewriteWarning { + code: "redirect_npm_legacy_alias_client".into(), + detail: format!( + "{lockfile}'s legacy `dependencies` mirror (read by npm <= 6) installs \ + {} through an npm alias; npm <= 6 fetches an aliased dependency from the \ + configured registry and ignores the redirected `resolved` url, so its \ + installs fail EINTEGRITY against the patched sha512 pin (the unpatched \ + bytes are never installed). npm >= 7 installs the hosted patch; use \ + vendored mode to patch npm 6 installs", + aliased + .iter() + .map(|a| format!("`{a}`")) + .collect::>() + .join(", ") + ), + }); + } + if v1 { result.warnings.push(RewriteWarning { code: "redirect_npm_legacy_client".into(), detail: format!( @@ -1118,13 +1146,17 @@ fn rewrite_npm_v2_deps( legacy_is_install_tree: bool, result: &mut RewriteResult, matched_any: &mut bool, + aliased: &mut Vec, ) -> bool { let mut changed = false; for (name, entry) in deps.iter_mut() { let packages_key = legacy_packages_key(parent_key, name); - if name == fname - && entry.get("version").and_then(Value::as_str) == Some(dep.version.as_str()) - { + // An alias node (`"lp": {"version": "npm:left-pad@1.3.0"}`) is an + // install of its target, like the `packages` twin's `name` field. + let (node_name, node_version) = + npm_legacy_identity(name, entry.get("version").and_then(Value::as_str)); + let is_alias = node_name != name.as_str(); + if node_name == fname && node_version == Some(dep.version.as_str()) { // Legacy spelling of `inBundle`: same npm-ignores-the-rewrite // fail-open as the `packages` guard above. if entry.get("bundled").and_then(Value::as_bool) == Some(true) { @@ -1153,6 +1185,9 @@ fn rewrite_npm_v2_deps( { result.edits.push(edit); changed = true; + if is_alias { + aliased.push(name.clone()); + } } } } @@ -1168,6 +1203,7 @@ fn rewrite_npm_v2_deps( legacy_is_install_tree, result, matched_any, + aliased, ) || changed; } } @@ -14042,6 +14078,159 @@ mod tests { ); } + /// #432: a lockfileVersion 2 lock's legacy `dependencies` mirror + /// spells an alias install `"lp": {"version": "npm:left-pad@1.3.0"}`. + /// It is rewired with the `packages` half (plain and scoped alias keys) + /// instead of silently staying on the registry, and the run says that + /// npm 6 installs of an aliased hosted pin fail closed. + #[test] + fn npm_v2_legacy_alias_mirror_is_rewired_and_warned() { + let registry = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"; + let node = json!({ "version": "npm:left-pad@1.3.0", "resolved": registry, + "integrity": "sha512-UPSTREAM==" }); + let entry = json!({ "name": "left-pad", "version": "1.3.0", "resolved": registry, + "integrity": "sha512-UPSTREAM==" }); + let lock = json!({ + "name": "app", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { "name": "app", "version": "0.0.0", + "dependencies": { "lp": "npm:left-pad@1.3.0", + "@x/lp": "npm:left-pad@1.3.0" } }, + "node_modules/@x/lp": entry, + "node_modules/lp": entry + }, + "dependencies": { "@x/lp": node, "lp": node } + }); + let mut files = BTreeMap::new(); + files.insert( + "package-lock.json".to_string(), + serde_json::to_string_pretty(&lock).unwrap(), + ); + let overrides = vec![npm_override( + "left-pad", + "1.3.0", + "http://patch.test/lp.tgz", + "sha512-PATCHED==", + )]; + let r = rewrite_registry_redirect(&files, &overrides); + let mut keys: Vec<_> = r + .edits + .iter() + .map(|e| (e.kind.as_str(), e.key.as_deref())) + .collect(); + keys.sort_unstable(); + assert_eq!( + keys, + [ + ("redirect_npm_lock_dep", Some("@x/lp")), + ("redirect_npm_lock_dep", Some("lp")), + ("redirect_npm_lock_entry", Some("node_modules/@x/lp")), + ("redirect_npm_lock_entry", Some("node_modules/lp")), + ] + ); + let out: Value = serde_json::from_str(&r.files["package-lock.json"]).unwrap(); + for alias in ["lp", "@x/lp"] { + assert_eq!( + out["dependencies"][alias]["resolved"], + "http://patch.test/lp.tgz" + ); + assert_eq!(out["dependencies"][alias]["integrity"], "sha512-PATCHED=="); + assert_eq!(out["dependencies"][alias]["version"], "npm:left-pad@1.3.0"); + } + let codes = warning_codes(&r); + assert!( + !codes.contains(&"redirect_npm_entry_not_found"), + "{codes:?}" + ); + let w = r + .warnings + .iter() + .find(|w| w.code == "redirect_npm_legacy_alias_client") + .unwrap_or_else(|| panic!("missing npm 6 alias caveat: {:?}", r.warnings)); + assert!( + w.detail.contains("npm <= 6") && w.detail.contains("EINTEGRITY"), + "{}", + w.detail + ); + // A plain (non-alias) mirror carries no alias caveat. + let plain = lock_v2_plain_left_pad(registry); + let mut files = BTreeMap::new(); + files.insert("package-lock.json".to_string(), plain); + let r = rewrite_registry_redirect(&files, &overrides); + assert!(r.files.contains_key("package-lock.json")); + assert!( + !warning_codes(&r).contains(&"redirect_npm_legacy_alias_client"), + "{:?}", + r.warnings + ); + } + + fn lock_v2_plain_left_pad(registry: &str) -> String { + let node = json!({ "version": "1.3.0", "resolved": registry, + "integrity": "sha512-UPSTREAM==" }); + serde_json::to_string_pretty(&json!({ + "name": "app", + "lockfileVersion": 2, + "packages": { + "": { "name": "app", "version": "0.0.0" }, + "node_modules/left-pad": node + }, + "dependencies": { "left-pad": node } + })) + .unwrap() + } + + /// #432, lockfileVersion 1: npm 6 writes an alias install as + /// `"lp": {"version": "npm:left-pad@1.3.0"}` and nothing else. The + /// hosted run used to find no entry (`redirect_npm_entry_not_found`, + /// exit 0) and pin nothing; it now rewires the node (npm >= 7 installs + /// the hosted patch from it, verified against npm 8 and 10) with the + /// v1 npm 6 caveat. + #[test] + fn npm_v1_alias_entry_is_rewired() { + let v1 = r#"{ + "name": "app", + "version": "0.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "lp": { + "version": "npm:left-pad@1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-UPSTREAM==" + } + } +} +"#; + let mut files = BTreeMap::new(); + files.insert("package-lock.json".to_string(), v1.to_string()); + let ovr = npm_override( + "left-pad", + "1.3.0", + "http://patch.test/left-pad-1.3.0.tgz", + "sha512-PATCHED==", + ); + let r = rewrite_registry_redirect(&files, std::slice::from_ref(&ovr)); + let codes = warning_codes(&r); + assert!( + !codes.contains(&"redirect_npm_entry_not_found"), + "{codes:?}" + ); + assert!(codes.contains(&"redirect_npm_legacy_client"), "{codes:?}"); + assert!( + !codes.contains(&"redirect_npm_legacy_alias_client"), + "the v1 caveat already covers every npm 6 install: {codes:?}" + ); + let out: Value = serde_json::from_str(&r.files["package-lock.json"]).unwrap(); + assert_eq!( + out["dependencies"]["lp"]["resolved"], + "http://patch.test/left-pad-1.3.0.tgz" + ); + assert_eq!(out["dependencies"]["lp"]["version"], "npm:left-pad@1.3.0"); + } + /// npm 12 removed `npm shrinkwrap` and now auto-creates a /// `package-lock.json` beside any committed `npm-shrinkwrap.json` on first /// install — and reifies the install from `package-lock.json`. So a diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs index cf8354b34..f231ffa38 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/npm.rs @@ -16,6 +16,7 @@ use serde_json::Value; use super::client::NpmDist; use super::{Ctx, FormatResult, HostedPin, View}; use crate::utils::line_endings::{to_lf, LineEndings}; +use crate::vendor::lock_inventory::npm_legacy_identity; /// The pins by uuid. pub(super) fn by_uuid<'p>(pins: &[&'p HostedPin]) -> BTreeMap<&'p str, &'p HostedPin> { @@ -107,17 +108,21 @@ fn v2_hits( } for (name, entry) in deps { let pointer = format!("{prefix}/{}", json_pointer_escape(name)); + // An alias node (`"lp": {"version": "npm:left-pad@1.3.0"}`) restores + // its target's registry dist (#432). + let (node_name, node_version) = + npm_legacy_identity(name, entry.get("version").and_then(Value::as_str)); if let (Some(uuid), Some(version)) = ( entry .get("resolved") .and_then(Value::as_str) .and_then(|u| ctx.hosted_uuid(u)), - entry.get("version").and_then(Value::as_str), + node_version, ) { hits.push(NpmHit { pointer: pointer.clone(), uuid, - name: name.clone(), + name: node_name.to_string(), version: version.to_string(), }); } diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs b/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs index 8adcdf67f..622a3d3e8 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/mod.rs @@ -70,7 +70,10 @@ pub(crate) mod vlt; pub(crate) mod wired; pub(crate) mod yarn; -pub(crate) use self::npm::{npm_lock_bundled_nodes, npm_lock_located_nodes, NpmLockNode}; +pub(crate) use self::npm::{ + npm_legacy_identity, npm_lock_bundled_nodes, npm_lock_legacy_mirror_nodes, + npm_lock_located_nodes, NpmLockNode, +}; #[cfg(test)] pub(crate) use self::npm_family::inventory_npm_lock; pub(crate) use self::pypi::pipfile_lock_entries; diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/npm.rs b/crates/socket-patch-core/src/vendor/lock_inventory/npm.rs index 66a401a1c..0867fb42a 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/npm.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/npm.rs @@ -42,7 +42,8 @@ const MAX_LEGACY_NPM_DEPTH: usize = 64; /// `packages` exists, so it is ignored there; /// * otherwise the lockfileVersion 1 `dependencies` tree, recursive /// through nested `dependencies`, `bundled: true` entries skipped (their -/// nested trees are still walked). +/// nested trees are still walked), alias nodes decoded +/// ([`npm_legacy_identity`]). pub(crate) fn npm_lock_nodes(doc: &Value) -> Vec> { walk_npm_lock(doc, Bundled::Skip, false) .into_iter() @@ -72,6 +73,21 @@ pub(crate) fn npm_lock_bundled_nodes(doc: &Value) -> Vec<(String, NpmLockNode<'_ walk_npm_lock(doc, Bundled::Only, true) } +/// The non-bundled nodes of a lockfileVersion 2 lock's legacy +/// `dependencies` mirror: the tree npm 6 installs from, which +/// [`npm_lock_nodes`] ignores because npm >= 7 reads `packages`. Empty for +/// a lock without `packages` (there the tree IS what [`npm_lock_nodes`] +/// walks). Lockfile discovery weighs these against the `packages` refs. +pub(crate) fn npm_lock_legacy_mirror_nodes(doc: &Value) -> Vec> { + let mut out = Vec::new(); + if doc.get("packages").and_then(Value::as_object).is_some() { + if let Some(deps) = doc.get("dependencies").and_then(Value::as_object) { + walk_npm_legacy_dependencies(deps, 0, Bundled::Skip, false, "", &mut out); + } + } + out.into_iter().map(|(_, node)| node).collect() +} + /// Which side of the bundled split [`walk_npm_lock`] returns. #[derive(Clone, Copy, PartialEq, Eq)] enum Bundled { @@ -119,6 +135,43 @@ impl<'a> NpmLockNode<'a> { integrity: field("integrity"), } } + + /// A legacy `dependencies` node keyed `key`, with an npm alias decoded + /// ([`npm_legacy_identity`]). + fn legacy(key: &'a str, node: &'a Value) -> Self { + let mut out = NpmLockNode::of(key, node); + let (name, version) = npm_legacy_identity(key, out.version); + out.name = name; + out.version = version; + out + } +} + +/// The package a legacy `dependencies` node (lockfileVersion 1, and the v2 +/// mirror) stands for. npm 6 keys the node by the name it installs under +/// and, for an alias install (`npm i lp@npm:left-pad@1.3.0`), spells the +/// target in `version`: `"lp": {"version": "npm:left-pad@1.3.0"}` is an +/// install of `left-pad@1.3.0` (the `packages` entries carry a `name` field +/// instead). Anything else is the key at its own `version`. Every reader and +/// writer of the legacy tree identifies nodes through this one rule (#432). +pub(crate) fn npm_legacy_identity<'a>( + key: &'a str, + version: Option<&'a str>, +) -> (&'a str, Option<&'a str>) { + let alias = version + .and_then(|v| v.strip_prefix("npm:")) + // `@` at index 0 opens a scope; the version follows the LAST `@`. + .and_then(|spec| { + spec.rfind('@') + .filter(|&at| at > 0) + .map(|at| spec.split_at(at)) + }) + .map(|(name, at_version)| (name, &at_version[1..])) + .filter(|(_, v)| !v.is_empty()); + match alias { + Some((name, version)) => (name, Some(version)), + None => (key, version), + } } fn npm_flag(node: &Value, key: &str) -> bool { @@ -145,7 +198,7 @@ fn walk_npm_legacy_dependencies<'a>( false => String::new(), }; if npm_flag(node, "bundled") == (bundled == Bundled::Only) { - out.push((location.clone(), NpmLockNode::of(name, node))); + out.push((location.clone(), NpmLockNode::legacy(name, node))); } if let Some(nested) = node.get("dependencies").and_then(Value::as_object) { walk_npm_legacy_dependencies(nested, depth + 1, bundled, locate, &location, out); diff --git a/crates/socket-patch-core/src/vendor/npm_lock.rs b/crates/socket-patch-core/src/vendor/npm_lock.rs index c01f421fb..7f14ddf8b 100644 --- a/crates/socket-patch-core/src/vendor/npm_lock.rs +++ b/crates/socket-patch-core/src/vendor/npm_lock.rs @@ -26,6 +26,7 @@ use crate::utils::fs::{atomic_write_bytes_preserving_mode, read_regular_to_bytes use crate::utils::socket_dir::remove_tree_and_prune; use super::common::{already_patched_result, done, parse_json_manifest, refused, JsonLayout}; +use super::lock_inventory::npm_legacy_identity; use super::npm_common::{ done_failure_unstage, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack, }; @@ -253,7 +254,6 @@ pub async fn vendor_npm<'a>( &mut wiring, &mut changed, &mut recomputed_deps, - &mut warnings, ) { return done_failure_unstage(purl, e, project_root, &uuid_dir_rel, uuid_dir_preexisted) .await; @@ -273,7 +273,6 @@ pub async fn vendor_npm<'a>( &mut wiring, &mut sib_changed, &mut recomputed_deps, - &mut warnings, ) { return done_failure_unstage(purl, e, project_root, &uuid_dir_rel, uuid_dir_preexisted) .await; @@ -1084,56 +1083,32 @@ fn rewrite_legacy_tree( lock_name: &str, wiring: &mut Vec, changed: &mut bool, - warnings: &mut Vec, ) { - // npm 6 spells an alias install `"": {"version": "npm:real@ver"}` - // in the legacy tree (no `name` field like the `packages` entries carry). - let alias_version = format!("npm:{name}@{version}"); for (dep_name, node) in deps.iter_mut() { let Some(obj) = node.as_object_mut() else { continue; }; let pointer = format!("{pointer_base}/{}", escape_json_pointer_token(dep_name)); let packages_key = legacy_packages_key(parent_key, dep_name); - let node_version = obj.get("version").and_then(Value::as_str); - if node_version == Some(alias_version.as_str()) { - // An aliased consumer of the patched package. The modern - // `packages` twin was rewritten via its `name` field, but this - // legacy spelling has no proven equivalent rewrite — LOUD: an - // npm 6 client reading the v2 mirror still installs the - // UNPATCHED registry bytes through the alias (npm >= 7 is - // unaffected). - warnings.push(VendorWarning::new( - "vendor_legacy_alias_skipped", - format!( - "legacy `dependencies` node `{pointer}` aliases {name}@{version} \ - (`{alias_version}`) and was NOT rewritten — npm 6 clients reading \ - the v2 legacy mirror still install the UNPATCHED registry bytes \ - through it" - ), - )); - } - if dep_name == name - && node_version == Some(version) - && obj.get("bundled").and_then(Value::as_bool) == Some(true) - { + // npm 6 spells an alias install `"": {"version": + // "npm:real@ver"}`; it installs that node from a `file:` `resolved` + // like any other (verified against real npm 6.14.18), so it is + // rewired with the rest (#432). + let (node_name, node_version) = + npm_legacy_identity(dep_name, obj.get("version").and_then(Value::as_str)); + let is_match = node_name == name && node_version == Some(version); + if is_match && obj.get("bundled").and_then(Value::as_bool) == Some(true) { // Parity with the `packages` scan's inBundle skip: this copy // ships inside its parent's tarball, npm never installs it from // `resolved`, and rewriting it would desync the two lock halves. // (The `packages` twin carries `inBundle` and already pushed the // stays-UNPATCHED warning.) - } else if dep_name == name - && node_version == Some(version) - && non_registry.contains_key(&packages_key) - { + } else if is_match && non_registry.contains_key(&packages_key) { // The mirror of a `packages` entry npm installs from a git / url // / `file:` spec (#326): its twin was skipped with // `vendor_non_registry_entry_skipped`, so rewiring this copy // would record wiring for bytes that never install. - } else if dep_name == name - && node_version == Some(version) - && !entry_in_sync(obj, resolved, integrity) - { + } else if is_match && !entry_in_sync(obj, resolved, integrity) { let was_vendored = entry_points_into_vendor(obj); let original = Value::Object(obj.clone()); obj.insert("resolved".to_string(), Value::String(resolved.to_string())); @@ -1164,7 +1139,6 @@ fn rewrite_legacy_tree( lock_name, wiring, changed, - warnings, ); } } @@ -1379,7 +1353,6 @@ impl LockRewire<'_> { wiring: &mut Vec, changed: &mut bool, recomputed_deps: &mut bool, - warnings: &mut Vec, ) -> Result<(), String> { // Taken before any rewrite, for the legacy mirror below. let non_registry = npm_non_registry_entries(lock, self.overrides); @@ -1442,7 +1415,6 @@ impl LockRewire<'_> { lock_name, wiring, changed, - warnings, ); } } @@ -2916,12 +2888,14 @@ mod tests { ); } - /// v2 legacy mirror: an alias consumer (`"aliased": {"version": - /// "npm:left-pad@1.3.0"}`) has no proven equivalent rewrite — it must be - /// left untouched AND loudly warned, since npm 6 reading the mirror - /// still installs the unpatched registry bytes through it. + /// #432, v2 legacy mirror: an alias consumer (`"aliased": {"version": + /// "npm:left-pad@1.3.0"}`) is rewired like every other mirror node. + /// npm 6 installs an alias node from its `file:` `resolved` (verified + /// against real npm 6.14.18), so leaving it on the registry shipped the + /// unpatched bytes to npm 6 while VEX attested the patch. The revert + /// restores the node byte-for-byte. #[tokio::test] - async fn v2_legacy_alias_node_warns_and_keeps_registry_resolution() { + async fn v2_legacy_alias_node_is_rewired_and_reverted() { let lock = json!({ "name": "fixture", "version": "1.0.0", @@ -2935,6 +2909,12 @@ mod tests { "resolved": REG_RESOLVED, "integrity": "sha512-orig==" }, + "node_modules/@x/lp": { + "name": "left-pad", + "version": "1.3.0", + "resolved": REG_RESOLVED, + "integrity": "sha512-orig==" + }, "node_modules/left-pad": { "version": "1.3.0", "resolved": REG_RESOLVED, @@ -2942,6 +2922,11 @@ mod tests { } }, "dependencies": { + "@x/lp": { + "version": "npm:left-pad@1.3.0", + "resolved": REG_RESOLVED, + "integrity": "sha512-orig==" + }, "aliased": { "version": "npm:left-pad@1.3.0", "resolved": REG_RESOLVED, @@ -2958,40 +2943,49 @@ mod tests { let (result, entry, warnings) = expect_done(fx.vendor(false).await); assert!(result.success, "{:?}", result.error); let entry = entry.unwrap(); - - let alias_warning = warnings - .iter() - .find(|w| w.code == "vendor_legacy_alias_skipped") - .unwrap_or_else(|| panic!("missing alias warning: {warnings:?}")); assert!( - alias_warning.detail.contains("UNPATCHED"), - "loud advisory: {}", - alias_warning.detail - ); - assert!( - alias_warning.detail.contains("/dependencies/aliased"), - "names the node: {}", - alias_warning.detail + warnings + .iter() + .all(|w| w.code != "vendor_legacy_alias_skipped"), + "{warnings:?}" ); - // The modern `packages` alias entry IS rewritten (name-field match); - // the legacy alias node stays at the registry resolution. let live = fx.read_lock().await; - assert_eq!( - live["packages"]["node_modules/aliased"]["resolved"], - json!(format!("file:{}", fx.expected_rel_tgz())) - ); - assert_eq!( - live["dependencies"]["aliased"], lock["dependencies"]["aliased"], - "legacy alias node byte-untouched" - ); - let legacy_keys: Vec<&str> = entry + let vendored = json!(format!("file:{}", fx.expected_rel_tgz())); + for alias in ["aliased", "@x/lp"] { + assert_eq!( + live["packages"][format!("node_modules/{alias}")]["resolved"], + vendored + ); + let node = &live["dependencies"][alias]; + assert_eq!(node["resolved"], vendored, "{alias}: {node}"); + assert_ne!(node["integrity"], json!("sha512-orig=="), "{alias}"); + assert_eq!( + node["version"], + json!("npm:left-pad@1.3.0"), + "the alias spelling npm 6 reads is kept" + ); + } + let mut legacy_keys: Vec<&str> = entry .wiring .iter() .filter(|r| r.kind == KIND_LOCK_LEGACY_ENTRY) .map(|r| r.key.as_deref().unwrap()) .collect(); - assert_eq!(legacy_keys, vec!["/dependencies/left-pad"]); + legacy_keys.sort_unstable(); + assert_eq!( + legacy_keys, + vec![ + "/dependencies/@x~1lp", + "/dependencies/aliased", + "/dependencies/left-pad" + ] + ); + + let outcome = revert_npm(&entry, fx.root(), false).await; + assert!(outcome.success, "{:?}", outcome.error); + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); + assert_eq!(fx.read_lock().await, lock, "lock restored"); } /// Hand-mangled locks with non-object nodes (e.g. `null`) are tolerated: diff --git a/crates/socket-patch-core/src/vex/discover/npm.rs b/crates/socket-patch-core/src/vex/discover/npm.rs index a7599bdbc..5fbdc82b4 100644 --- a/crates/socket-patch-core/src/vex/discover/npm.rs +++ b/crates/socket-patch-core/src/vex/discover/npm.rs @@ -56,7 +56,8 @@ use crate::formats::pnpm::{ use crate::utils::digest::is_sri_pin; use crate::vendor::lock_inventory::pnpm::rush_lock_rels; use crate::vendor::lock_inventory::{ - npm_lock_bundled_nodes, npm_lock_located_nodes, LockIntegrity, NpmLockNode, + npm_lock_bundled_nodes, npm_lock_legacy_mirror_nodes, npm_lock_located_nodes, LockIntegrity, + NpmLockNode, }; use crate::vendor::npm_origin::{npm_non_registry_entries, NpmOverrides}; @@ -216,9 +217,69 @@ async fn extract_package_lock( .map(|text| NpmOverrides::from_manifest_text(&text)) .unwrap_or_default(); drop_non_registry_installs(file, &doc, &overrides, &mut read, out); + drop_mirror_unwired(ctx, file, &doc, &mut read, out); Some(read) } +/// A lockfileVersion 2 lock's legacy `dependencies` mirror is what npm 6 +/// installs from (the docs list npm 6 as a v2 client). A `packages` ref +/// whose package the mirror still resolves from a non-Socket source (the +/// registry: a lock a pre-#432 run left with an alias mirror node +/// unrewired) installs unpatched under npm 6, so it is diagnosed and not +/// attested (#432), and the package counts as resolved elsewhere, so the +/// sibling npm lock's and other locks' refs for it are contested too. A +/// mirror node that agrees, or a mirror that does not mention the package, +/// contests nothing; a mirror node wired while `packages` is not is never a +/// ref (see [`npm_lock_nodes`]). +fn drop_mirror_unwired( + ctx: &DiscoverCtx<'_>, + file: &str, + doc: &Value, + read: &mut NpmLockRefs, + out: &mut Discovery, +) { + let mut unwired: BTreeMap = BTreeMap::new(); + for node in npm_lock_legacy_mirror_nodes(doc) { + let Some(purl) = node.version.and_then(|v| npm_purl(node.name, v)) else { + continue; + }; + let located = node.resolved.map_or_else(Located::default, |r| { + ctx.locate(r, LocateOpts::LITERAL_CHECKED) + }); + if located.vendored.is_none() && located.hosted.is_none() { + // An npm 6 install from a non-Socket source: it contests the + // sibling npm lock's ref ([`push_uncontested`]) and any other + // lock's (the orchestrator), like an unwired `packages` entry. + out.resolved_elsewhere(file, Some(purl.clone())); + read.unwired + .entry(purl.clone()) + .or_insert_with(|| "the legacy `dependencies` mirror".to_string()); + let source = node.resolved.unwrap_or("no `resolved` url").to_string(); + unwired.entry(purl).or_insert(source); + } + } + if unwired.is_empty() { + return; + } + read.refs.retain(|r| { + let Some(source) = unwired.get(&r.purl) else { + return true; + }; + out.diag( + DIAG_REF_UNATTRIBUTABLE, + file, + format!( + "{file}: {} is wired to Socket patch {} in `packages`, but the lock's legacy \ + `dependencies` mirror, which npm <= 6 installs from, still resolves it to \ + {source:?}, so npm 6 installs the unpatched bytes and nothing is attested; \ + re-run `socket-patch scan` (or `vendor`) to rewire the mirror", + r.purl, r.uuid + ), + ); + false + }); +} + /// npm installs a git / url / `file:` dependency from the dependent's spec /// and ignores the entry's `resolved` (`vendor::npm_origin`, #326), so such /// an entry stays unpatched whatever its `resolved` says. Every ref for the @@ -955,6 +1016,168 @@ mod tests { ); } + /// #432, lockfileVersion 1: npm 6 writes an alias install as + /// `"lp": {"version": "npm:left-pad@1.3.0"}`; the node is an install of + /// left-pad@1.3.0, never of a package named `lp`. + #[tokio::test] + async fn lockfile_v1_alias_node_is_its_target() { + let url = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); + let p = Project::new(); + p.write( + "package-lock.json", + serde_json::json!({ + "lockfileVersion": 1, + "dependencies": { + "lp": { "version": "npm:left-pad@1.3.0", "resolved": url, "integrity": SRI }, + "@x/lp": { "version": "npm:left-pad@1.3.0", "resolved": url, "integrity": SRI } + } + }) + .to_string(), + ); + let out = run(&p).await; + assert_refs( + &out, + &[ + ("pkg:npm/left-pad@1.3.0", UUID_A, WiringMode::Hosted), + ("pkg:npm/left-pad@1.3.0", UUID_A, WiringMode::Hosted), + ], + ); + assert!(out.diagnostics.is_empty(), "{:?}", out.diagnostics); + } + + /// #432: a v2 lock whose `packages` alias entry is wired while its + /// legacy mirror node (what npm 6 installs from) still resolves to the + /// registry is NOT attested: npm 6 installs the unpatched bytes. This is + /// the lock a pre-#432 hosted or vendored run left behind. + #[tokio::test] + async fn v2_alias_mirror_left_on_the_registry_contests_the_ref() { + let hosted = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); + let vendored = format!("file:.socket/vendor/npm/{UUID_B}/left-pad-1.3.0.tgz"); + let registry = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"; + for wired in [hosted, vendored] { + let p = Project::new(); + p.write( + "package-lock.json", + serde_json::json!({ + "lockfileVersion": 2, + "packages": { + "": { "name": "app", "version": "1.0.0" }, + "node_modules/lp": { + "name": "left-pad", "version": "1.3.0", + "resolved": wired, "integrity": SRI + } + }, + "dependencies": { + "lp": { + "version": "npm:left-pad@1.3.0", + "resolved": registry, "integrity": "sha512-ORIG" + } + } + }) + .to_string(), + ); + let out = run(&p).await; + assert!(out.refs.is_empty(), "{wired}: {:#?}", out.refs); + let diag = out + .diagnostics + .iter() + .find(|d| d.code == DIAG_REF_UNATTRIBUTABLE) + .unwrap_or_else(|| panic!("{wired}: {:?}", out.diagnostics)); + assert!( + diag.detail.contains("npm <= 6") && diag.detail.contains(registry), + "{}", + diag.detail + ); + } + } + + /// #432 (Bugbot on #813): a shrinkwrap whose stale alias mirror still + /// resolves to the registry is what npm 6 installs from, so it also + /// contests the SIBLING package-lock.json's ref for the package, not + /// only its own `packages` ref. + #[tokio::test] + async fn stale_alias_mirror_contests_the_sibling_lock_ref() { + let hosted = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); + let registry = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"; + let p = Project::new(); + p.write( + "npm-shrinkwrap.json", + serde_json::json!({ + "lockfileVersion": 2, + "packages": { + "": { "name": "app", "version": "1.0.0" }, + "node_modules/lp": { + "name": "left-pad", "version": "1.3.0", + "resolved": hosted, "integrity": SRI + } + }, + "dependencies": { + "lp": { + "version": "npm:left-pad@1.3.0", + "resolved": registry, "integrity": "sha512-ORIG" + } + } + }) + .to_string(), + ); + p.write( + "package-lock.json", + lock_with_packages(serde_json::json!({ + "": { "name": "app", "version": "1.0.0" }, + "node_modules/lp": { + "name": "left-pad", "version": "1.3.0", + "resolved": hosted, "integrity": SRI + }, + })), + ); + let out = run(&p).await; + assert!(out.refs.is_empty(), "{:#?}", out.refs); + let files: Vec<&std::path::Path> = out + .diagnostics + .iter() + .filter(|d| d.code == DIAG_REF_UNATTRIBUTABLE) + .map(|d| d.file.as_path()) + .collect(); + assert!( + files.contains(&std::path::Path::new("npm-shrinkwrap.json")) + && files.contains(&std::path::Path::new("package-lock.json")), + "{:#?}", + out.diagnostics + ); + } + + /// #432: the same v2 lock with the alias mirror node rewired too (what + /// hosted and vendored runs now write) is attested once, from + /// `packages`. + #[tokio::test] + async fn v2_alias_mirror_that_agrees_attests() { + let hosted = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); + let p = Project::new(); + p.write( + "package-lock.json", + serde_json::json!({ + "lockfileVersion": 2, + "packages": { + "": { "name": "app", "version": "1.0.0" }, + "node_modules/lp": { + "name": "left-pad", "version": "1.3.0", + "resolved": hosted, "integrity": SRI + } + }, + "dependencies": { + "lp": { "version": "npm:left-pad@1.3.0", "resolved": hosted, "integrity": SRI } + } + }) + .to_string(), + ); + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:npm/left-pad@1.3.0", UUID_A, WiringMode::Hosted)], + ); + assert!(out.diagnostics.is_empty(), "{:?}", out.diagnostics); + } + /// link / inBundle / bundled entries install from somewhere else, so a /// Socket URL written there wires nothing. #[tokio::test] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/package-lock-v3/legacy-alias-mirror-v2/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/package-lock-v3/legacy-alias-mirror-v2/expected-edits.json new file mode 100644 index 000000000..e3aaf4321 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/package-lock-v3/legacy-alias-mirror-v2/expected-edits.json @@ -0,0 +1,58 @@ +[ + { + "path": "package-lock.json", + "kind": "redirect_npm_lock_entry", + "action": "rewritten", + "key": "node_modules/@x/lp", + "original": { + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-XPMACEGRYS9CxC3IUMzAQDLT5SqYFXXX0ABCDEFupstreamUPSTREAMupstreamUPSTREAMabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345==" + }, + "new": { + "resolved": "https://patch.socket.dev/patch/npm/left-pad/1.3.0/11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/left-pad-1.3.0.tgz", + "integrity": "sha512-PATCHEDpatchedPATCHEDpatchedPATCHEDpatched9876543210ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfedcba0123456789AB==" + } + }, + { + "path": "package-lock.json", + "kind": "redirect_npm_lock_entry", + "action": "rewritten", + "key": "node_modules/lp", + "original": { + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-XPMACEGRYS9CxC3IUMzAQDLT5SqYFXXX0ABCDEFupstreamUPSTREAMupstreamUPSTREAMabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345==" + }, + "new": { + "resolved": "https://patch.socket.dev/patch/npm/left-pad/1.3.0/11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/left-pad-1.3.0.tgz", + "integrity": "sha512-PATCHEDpatchedPATCHEDpatchedPATCHEDpatched9876543210ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfedcba0123456789AB==" + } + }, + { + "path": "package-lock.json", + "kind": "redirect_npm_lock_dep", + "action": "rewritten", + "key": "@x/lp", + "original": { + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-XPMACEGRYS9CxC3IUMzAQDLT5SqYFXXX0ABCDEFupstreamUPSTREAMupstreamUPSTREAMabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345==" + }, + "new": { + "resolved": "https://patch.socket.dev/patch/npm/left-pad/1.3.0/11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/left-pad-1.3.0.tgz", + "integrity": "sha512-PATCHEDpatchedPATCHEDpatchedPATCHEDpatched9876543210ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfedcba0123456789AB==" + } + }, + { + "path": "package-lock.json", + "kind": "redirect_npm_lock_dep", + "action": "rewritten", + "key": "lp", + "original": { + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-XPMACEGRYS9CxC3IUMzAQDLT5SqYFXXX0ABCDEFupstreamUPSTREAMupstreamUPSTREAMabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345==" + }, + "new": { + "resolved": "https://patch.socket.dev/patch/npm/left-pad/1.3.0/11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/left-pad-1.3.0.tgz", + "integrity": "sha512-PATCHEDpatchedPATCHEDpatchedPATCHEDpatched9876543210ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfedcba0123456789AB==" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/package-lock-v3/legacy-alias-mirror-v2/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/package-lock-v3/legacy-alias-mirror-v2/expected-warnings.json new file mode 100644 index 000000000..a48794d16 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/package-lock-v3/legacy-alias-mirror-v2/expected-warnings.json @@ -0,0 +1 @@ +["redirect_npm_legacy_alias_client"] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/package-lock-v3/legacy-alias-mirror-v2/expected/package-lock.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/package-lock-v3/legacy-alias-mirror-v2/expected/package-lock.json new file mode 100644 index 000000000..46cd5cc6f --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/package-lock-v3/legacy-alias-mirror-v2/expected/package-lock.json @@ -0,0 +1,40 @@ +{ + "name": "consumer", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "consumer", + "version": "1.0.0", + "dependencies": { + "@x/lp": "npm:left-pad@1.3.0", + "lp": "npm:left-pad@1.3.0" + } + }, + "node_modules/@x/lp": { + "name": "left-pad", + "version": "1.3.0", + "resolved": "https://patch.socket.dev/patch/npm/left-pad/1.3.0/11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/left-pad-1.3.0.tgz", + "integrity": "sha512-PATCHEDpatchedPATCHEDpatchedPATCHEDpatched9876543210ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfedcba0123456789AB==" + }, + "node_modules/lp": { + "name": "left-pad", + "version": "1.3.0", + "resolved": "https://patch.socket.dev/patch/npm/left-pad/1.3.0/11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/left-pad-1.3.0.tgz", + "integrity": "sha512-PATCHEDpatchedPATCHEDpatchedPATCHEDpatched9876543210ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfedcba0123456789AB==" + } + }, + "dependencies": { + "@x/lp": { + "version": "npm:left-pad@1.3.0", + "resolved": "https://patch.socket.dev/patch/npm/left-pad/1.3.0/11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/left-pad-1.3.0.tgz", + "integrity": "sha512-PATCHEDpatchedPATCHEDpatchedPATCHEDpatched9876543210ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfedcba0123456789AB==" + }, + "lp": { + "version": "npm:left-pad@1.3.0", + "resolved": "https://patch.socket.dev/patch/npm/left-pad/1.3.0/11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/left-pad-1.3.0.tgz", + "integrity": "sha512-PATCHEDpatchedPATCHEDpatchedPATCHEDpatched9876543210ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfedcba0123456789AB==" + } + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/package-lock-v3/legacy-alias-mirror-v2/input/package-lock.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/package-lock-v3/legacy-alias-mirror-v2/input/package-lock.json new file mode 100644 index 000000000..4b934ea5a --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/package-lock-v3/legacy-alias-mirror-v2/input/package-lock.json @@ -0,0 +1,40 @@ +{ + "name": "consumer", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "consumer", + "version": "1.0.0", + "dependencies": { + "@x/lp": "npm:left-pad@1.3.0", + "lp": "npm:left-pad@1.3.0" + } + }, + "node_modules/@x/lp": { + "name": "left-pad", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-XPMACEGRYS9CxC3IUMzAQDLT5SqYFXXX0ABCDEFupstreamUPSTREAMupstreamUPSTREAMabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345==" + }, + "node_modules/lp": { + "name": "left-pad", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-XPMACEGRYS9CxC3IUMzAQDLT5SqYFXXX0ABCDEFupstreamUPSTREAMupstreamUPSTREAMabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345==" + } + }, + "dependencies": { + "@x/lp": { + "version": "npm:left-pad@1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-XPMACEGRYS9CxC3IUMzAQDLT5SqYFXXX0ABCDEFupstreamUPSTREAMupstreamUPSTREAMabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345==" + }, + "lp": { + "version": "npm:left-pad@1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-XPMACEGRYS9CxC3IUMzAQDLT5SqYFXXX0ABCDEFupstreamUPSTREAMupstreamUPSTREAMabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012345==" + } + } +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/npm/package-lock-v3/legacy-alias-mirror-v2/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/npm/package-lock-v3/legacy-alias-mirror-v2/overrides.json new file mode 100644 index 000000000..b61f67a25 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/npm/package-lock-v3/legacy-alias-mirror-v2/overrides.json @@ -0,0 +1,13 @@ +[ + { + "ecosystem": "npm", + "name": "left-pad", + "version": "1.3.0", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "22222222-2222-2222-2222-222222222222", + "artifactUrl": "https://patch.socket.dev/patch/npm/left-pad/1.3.0/11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/left-pad-1.3.0.tgz", + "integrity": { + "sha512": "sha512-PATCHEDpatchedPATCHEDpatchedPATCHEDpatched9876543210ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfedcba0123456789AB==" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-npm.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-npm.json index 2130028cb..372837d60 100644 --- a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-npm.json +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-npm.json @@ -859,6 +859,56 @@ ], "live_claims": [] }, + "redirect/npm/package-lock-v3/legacy-alias-mirror-v2/expected": { + "refs": [ + { + "purl": "pkg:npm/left-pad@1.3.0", + "uuid": "22222222-2222-2222-2222-222222222222", + "mode": "hosted", + "source_file": "package-lock.json", + "artifact_rel": null, + "locked_integrity": "Sri(\"sha512-PATCHEDpatchedPATCHEDpatchedPATCHEDpatched9876543210ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfedcba0123456789AB==\")", + "integrity_required": true, + "url": "https://patch.socket.dev/patch/npm/left-pad/1.3.0/11111111-1111-1111-1111-111111111111/22222222-2222-2222-2222-222222222222/left-pad-1.3.0.tgz", + "lockfile_basis_ok": true + } + ], + "diagnostics": [], + "recognized": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": "package-lock.json" + }, + { + "uuid": "22222222-2222-2222-2222-222222222222", + "mode": "hosted", + "file": "package-lock.json" + } + ], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [ + { + "mode": "hosted", + "uuid": "22222222-2222-2222-2222-222222222222", + "purl": "pkg:npm/left-pad@1.3.0" + } + ] + }, + "redirect/npm/package-lock-v3/legacy-alias-mirror-v2/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [ + { + "purl": "pkg:npm/left-pad@1.3.0", + "file": "package-lock.json" + } + ], + "live_claims": [] + }, "redirect/npm/pnpm/basic/expected": { "refs": [ { diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 51a61c066..2770ff26c 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -52,9 +52,16 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. gates them by `allow-file`, default `all`). npm 6 ignores `resolved` for registry dependencies, so a redirected lockfileVersion 1 lock fails closed with EINTEGRITY under npm 6 (`redirect_npm_legacy_client`) and installs under npm - >= 7. Vendoring needs a lockfileVersion 2/3 lock (npm 6 still installs a - vendored v2 lock from its legacy mirror) and rewires both locks in npm 12's - dual-lock state. Majors 6–12 are measured in + >= 7. A lockfileVersion 2 lock's legacy `dependencies` mirror is rewired with + `packages`, npm alias nodes (`"lp": {"version": "npm:left-pad@1.3.0"}`) + included. npm 6 installs an aliased dependency from the configured registry + whatever its `resolved` says, so under npm 6 an aliased hosted pin in a v2 + lock fails closed with EINTEGRITY too (`redirect_npm_legacy_alias_client`). + Lockfile-only `vex` attests nothing for a package whose `packages` entry is + wired while the v2 mirror still resolves it from the registry. Vendoring + needs a lockfileVersion 2/3 lock (npm 6 still installs a vendored v2 lock + from its legacy mirror, alias nodes included) and rewires both locks in npm + 12's dual-lock state. Majors 6–12 are measured in [npm compatibility](testing/npm-compatibility.md). - **pnpm** — hosted rewriting supports legacy `shrinkwrap.yaml` (pnpm 1/2), lockfileVersion 5.x (pnpm 3–7), 6.0 (pnpm 8), and 9.0 (pnpm 9–12). diff --git a/docs/testing/npm-compatibility.md b/docs/testing/npm-compatibility.md index acd5aad9a..abb80e6af 100644 --- a/docs/testing/npm-compatibility.md +++ b/docs/testing/npm-compatibility.md @@ -15,7 +15,7 @@ Measured against the real releases (Node 24.21 on macOS, 2026-09-22): | npm | `npm install` writes | `npm shrinkwrap` | Hosted install of a redirected lock | Vendored install | | --- | --- | --- | --- | --- | | 6.14.18 | lockfileVersion 1 | renames the lock | **fails closed**: EINTEGRITY — npm 6 fetches registry dependencies from the configured registry and ignores `resolved`, so the patched sha512 pin rejects the registry bytes (`redirect_npm_legacy_client` warns) | a v1 lock is refused (`vendor_lockfile_version_unsupported`), and a hosted → vendored takeover refuses it before restoring the hosted pin, so the package stays hosted; npm 6 DOES install a vendored **v2** lock (written by npm 7+) from its legacy `dependencies` mirror | -| 7.0.0, 7.24.2, 8.19.4 | lockfileVersion 2 (+ v1 mirror) | renames the lock | patched | patched | +| 7.0.0, 7.24.2, 8.19.4 | lockfileVersion 2 (+ v1 mirror) | renames the lock | patched (npm 6 installing this v2 lock: patched, except an npm alias, which npm 6 fetches from the registry, so it **fails closed** with EINTEGRITY and `redirect_npm_legacy_alias_client` warns) | patched (npm 6 installing this v2 lock: patched, alias nodes included) | | 9.0.0, 9.9.4, 10.9.9, 11.20.0 | lockfileVersion 3 | renames the lock | patched | patched | | 12.0.0, 12.1.0 | lockfileVersion 3 | **removed** — a committed shrinkwrap gets a package-lock.json twin on first install, and installs read the twin | patched with a plain `npm ci` — the hosted run writes `allow-remote=all` to the project `.npmrc` (`redirect_npm_allow_remote` warns on every npm hosted run); the same checkout WITHOUT that `.npmrc` is refused EALLOWREMOTE | patched (both locks are rewired in the dual-lock state) |