Skip to content

Commit f023506

Browse files
Fix npm 6 installing unpatched aliases (#432) (#813)
* Start fix for #432 Assisted-by: Claude Code:claude-opus-5-5 * Pin npm aliases in the npm 6 lock mirror A lockfileVersion 2 package-lock.json keeps a legacy `dependencies` mirror for npm 6, which spells an alias install as `"lp": {"version": "npm:left-pad@1.3.0"}`. Hosted scans matched mirror nodes on a plain version only, so the alias node silently stayed on the registry, and a lockfileVersion 1 alias lock pinned nothing at all ("no package-lock.json entry"). Rollback could not restore such a node either. Every reader of the legacy tree now decodes the alias through one helper. Hosted scans rewire the alias node with the rest, and rollback restores it. npm 6 fetches an aliased dependency from the registry whatever `resolved` says, so under npm 6 the pinned lock fails closed (EINTEGRITY) instead of installing unpatched bytes, and the run warns `redirect_npm_legacy_alias_client`. Refs #432 Assisted-by: Claude Code:claude-opus-5-5 * Vendor npm aliases in the npm 6 lock mirror Vendoring skipped the v2 mirror node of an npm alias with `vendor_legacy_alias_skipped`, so npm 6 installed the unpatched registry tarball through it. npm 6 does install an alias node from a `file:` resolved (checked against npm 6.14.18), so the node is now rewired like every other mirror node and revert restores it. Refs #432 Assisted-by: Claude Code:claude-opus-5-5 * Withhold VEX when the npm 6 mirror is unpatched Lockfile-only VEX read a v2 lock's `packages` half only, so it attested `not_affected` for a package whose legacy mirror (what npm 6 installs from) still resolved to the registry, as locks written before this fix do for npm aliases. Such a ref is now diagnosed as unattributable and not attested. A lockfileVersion 1 alias node is read as an install of its target package. Fixes #432 Assisted-by: Claude Code:claude-opus-5-5 * Let a stale npm 6 mirror contest the sibling lock When npm-shrinkwrap.json's legacy mirror still resolved a package from the registry, VEX dropped the shrinkwrap's own ref but still attested the same package from package-lock.json, although npm 6 installs from the shrinkwrap. A mirror node off Socket now counts as resolving the package elsewhere, so the sibling lock's ref is contested too. Refs #432 Assisted-by: Claude Code:claude-opus-5-5 * Port vex alias test fix from #851 Main has been red since #605: two commands::vex_consumed tests assume the name-keyed resolver never returns npm-aliased copies, but #605 taught it to probe bundled store trees. Port #851's test-only fix so this PR's CI runs on a green base. It becomes a no-op once #851 lands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EgBZwmqgXLZaRGyfDFoWwp --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1714299 commit f023506

15 files changed

Lines changed: 918 additions & 160 deletions

File tree

‎crates/socket-patch-cli/tests/e2e_vendor_npm_build.rs‎

Lines changed: 159 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1177,101 +1177,183 @@ fn npm_vendor_shrinkwrap_fresh_checkout_npm_ci_and_manifestless_vex() {
11771177
/// N/A for npm >= 7 (their own v2/v3 flows are the capstones above).
11781178
#[test]
11791179
fn npm6_installs_a_vendored_v2_lock_from_its_legacy_mirror() {
1180-
let suite = "e2e_vendor_npm_build (npm 6 × v2 lock)";
1181-
let Some(major) = npm_major_or_skip(suite) else {
1180+
let Some(cell) = Npm6Cell::vendor(
1181+
"e2e_vendor_npm_build (npm 6 × v2 lock)",
1182+
&format!("{DEP}@{DEP_VERSION}"),
1183+
DEP,
1184+
) else {
11821185
return;
11831186
};
1184-
if major > 6 {
1185-
println!("N/A {suite}: npm {major} is not npm 6");
1186-
return;
1187-
}
1188-
let Some(writer) = npm_e2e_common::modern_npm_writer() else {
1189-
npm_e2e_common::skip(
1190-
suite,
1191-
"no npm >= 7 to write the v2 lock (set SOCKET_PATCH_NPM_E2E_LOCK_WRITER_BIN)",
1192-
);
1187+
// `_tmp` keeps the cell's temp dir alive through the tail.
1188+
let Npm6Cell {
1189+
tmp: _tmp,
1190+
fresh,
1191+
patched,
1192+
lock_before,
1193+
..
1194+
} = cell;
1195+
vendored_manifestless_tail(
1196+
"flavor=package-lock-v2 install=patched",
1197+
&fresh,
1198+
&patched,
1199+
TAIL_GHSA,
1200+
vec![("package-lock.json", lock_before)],
1201+
&[VexVia::Apply, VexVia::Vendor],
1202+
);
1203+
}
1204+
1205+
/// #432: the same npm 6 cell for an npm ALIAS install (`npm i
1206+
/// lp@npm:left-pad@1.3.0`). The v2 mirror spells it `"lp": {"version":
1207+
/// "npm:left-pad@1.3.0"}`; vendoring used to skip that node with
1208+
/// `vendor_legacy_alias_skipped`, so npm 6 installed the unpatched registry
1209+
/// bytes. npm 6 installs an alias node from its `file:` `resolved`, so it
1210+
/// is rewired, npm 6 installs the PATCHED bytes, and revert restores the
1211+
/// lock byte-for-byte.
1212+
#[test]
1213+
fn npm6_installs_a_vendored_v2_alias_from_its_legacy_mirror() {
1214+
let Some(cell) = Npm6Cell::vendor(
1215+
"e2e_vendor_npm_build (npm 6 × v2 lock × alias)",
1216+
&format!("lp@npm:{DEP}@{DEP_VERSION}"),
1217+
"lp",
1218+
) else {
11931219
return;
11941220
};
1195-
let tmp = tempfile::tempdir().unwrap();
1196-
let proj = tmp.path().join("proj");
1197-
std::fs::create_dir_all(&proj).unwrap();
1198-
std::fs::write(
1199-
proj.join("package.json"),
1200-
r#"{"name":"vendor-npm6","version":"0.0.0","private":true}"#,
1201-
)
1202-
.unwrap();
1203-
let cache = tmp.path().join("npm-cache");
1204-
let install = npm_e2e_common::npm_command_for(&writer, &proj)
1205-
.args([
1206-
"install",
1207-
&format!("{DEP}@{DEP_VERSION}"),
1208-
"--lockfile-version",
1209-
"2",
1210-
"--no-audit",
1211-
"--no-fund",
1212-
"--cache",
1213-
cache.to_str().unwrap(),
1214-
])
1215-
.output()
1216-
.unwrap();
1217-
if !install.status.success() {
1218-
npm_e2e_common::skip(suite, &npm_e2e_common::output_text(&install));
1219-
return;
1220-
}
1221-
assert_eq!(npm_e2e_common::lockfile_version(&proj), Some(2));
1222-
let orig = std::fs::read(proj.join("node_modules").join(DEP).join("index.js")).unwrap();
1223-
let patched: Vec<u8> = [MARKER.as_bytes(), orig.as_slice()].concat();
1224-
let purl = format!("pkg:npm/{DEP}@{DEP_VERSION}");
1225-
stage_patch_with_vuln(&proj, &purl, "package/index.js", &orig, &patched, TAIL_GHSA);
1226-
let lock_before = std::fs::read(proj.join("package-lock.json")).unwrap();
1227-
1221+
assert!(
1222+
!cell.vendor_stdout.contains("vendor_legacy_alias_skipped"),
1223+
"{}",
1224+
cell.vendor_stdout
1225+
);
12281226
let (code, stdout, stderr) = run_socket(
1229-
&proj,
1227+
&cell.proj,
12301228
&[
12311229
"vendor",
1230+
"--revert",
12321231
"--json",
12331232
"--offline",
12341233
"--cwd",
1235-
proj.to_str().unwrap(),
1234+
cell.proj.to_str().unwrap(),
12361235
],
12371236
);
12381237
assert_eq!(
12391238
code, 0,
1240-
"vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"
1241-
);
1242-
let lock: serde_json::Value =
1243-
serde_json::from_slice(&std::fs::read(proj.join("package-lock.json")).unwrap()).unwrap();
1244-
let want = format!("file:.socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz");
1245-
assert_eq!(
1246-
lock["packages"][format!("node_modules/{DEP}")]["resolved"],
1247-
want
1239+
"revert failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"
12481240
);
12491241
assert_eq!(
1250-
lock["dependencies"][DEP]["resolved"], want,
1251-
"the v2 legacy mirror (what npm 6 reads) must be rewired too"
1242+
std::fs::read(cell.proj.join("package-lock.json")).unwrap(),
1243+
cell.lock_before,
1244+
"revert must restore the lock byte-for-byte"
12521245
);
1246+
}
12531247

1254-
let fresh = tmp.path().join("fresh");
1255-
npm_e2e_common::fresh_checkout(&proj, &fresh, &["package-lock.json"]);
1256-
let ci = npm_e2e_common::npm_ci(&fresh, &tmp.path().join("fresh-npm-cache"));
1257-
assert!(
1258-
ci.status.success(),
1259-
"npm {major} `npm ci` of the vendored v2 lock must succeed.\n{}",
1260-
npm_e2e_common::output_text(&ci)
1261-
);
1262-
assert_eq!(
1263-
std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(),
1264-
patched,
1265-
"npm 6 must install the PATCHED bytes from the vendored tarball"
1266-
);
1267-
vendored_manifestless_tail(
1268-
"flavor=package-lock-v2 install=patched",
1269-
&fresh,
1270-
&patched,
1271-
TAIL_GHSA,
1272-
vec![("package-lock.json", lock_before)],
1273-
&[VexVia::Apply, VexVia::Vendor],
1274-
);
1248+
/// One npm 6 × vendored v2 lock cell: a modern npm writes the v2 lock for
1249+
/// `install` (landing in `node_modules/<dir>`), `vendor` rewires both lock
1250+
/// halves, and npm 6's fresh `npm ci` must install the PATCHED bytes.
1251+
/// `None` after a skip or an N/A (npm >= 7).
1252+
struct Npm6Cell {
1253+
tmp: tempfile::TempDir,
1254+
proj: PathBuf,
1255+
fresh: PathBuf,
1256+
patched: Vec<u8>,
1257+
lock_before: Vec<u8>,
1258+
vendor_stdout: String,
1259+
}
1260+
1261+
impl Npm6Cell {
1262+
fn vendor(suite: &str, install: &str, dir: &str) -> Option<Npm6Cell> {
1263+
let major = npm_major_or_skip(suite)?;
1264+
if major > 6 {
1265+
println!("N/A {suite}: npm {major} is not npm 6");
1266+
return None;
1267+
}
1268+
let Some(writer) = npm_e2e_common::modern_npm_writer() else {
1269+
npm_e2e_common::skip(
1270+
suite,
1271+
"no npm >= 7 to write the v2 lock (set SOCKET_PATCH_NPM_E2E_LOCK_WRITER_BIN)",
1272+
);
1273+
return None;
1274+
};
1275+
let tmp = tempfile::tempdir().unwrap();
1276+
let proj = tmp.path().join("proj");
1277+
std::fs::create_dir_all(&proj).unwrap();
1278+
std::fs::write(
1279+
proj.join("package.json"),
1280+
r#"{"name":"vendor-npm6","version":"0.0.0","private":true}"#,
1281+
)
1282+
.unwrap();
1283+
let cache = tmp.path().join("npm-cache");
1284+
let output = npm_e2e_common::npm_command_for(&writer, &proj)
1285+
.args([
1286+
"install",
1287+
install,
1288+
"--lockfile-version",
1289+
"2",
1290+
"--no-audit",
1291+
"--no-fund",
1292+
"--cache",
1293+
cache.to_str().unwrap(),
1294+
])
1295+
.output()
1296+
.unwrap();
1297+
if !output.status.success() {
1298+
npm_e2e_common::skip(suite, &npm_e2e_common::output_text(&output));
1299+
return None;
1300+
}
1301+
assert_eq!(npm_e2e_common::lockfile_version(&proj), Some(2));
1302+
let orig = std::fs::read(proj.join("node_modules").join(dir).join("index.js")).unwrap();
1303+
let patched: Vec<u8> = [MARKER.as_bytes(), orig.as_slice()].concat();
1304+
let purl = format!("pkg:npm/{DEP}@{DEP_VERSION}");
1305+
stage_patch_with_vuln(&proj, &purl, "package/index.js", &orig, &patched, TAIL_GHSA);
1306+
let lock_before = std::fs::read(proj.join("package-lock.json")).unwrap();
1307+
1308+
let (code, stdout, stderr) = run_socket(
1309+
&proj,
1310+
&[
1311+
"vendor",
1312+
"--json",
1313+
"--offline",
1314+
"--cwd",
1315+
proj.to_str().unwrap(),
1316+
],
1317+
);
1318+
assert_eq!(
1319+
code, 0,
1320+
"vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"
1321+
);
1322+
let lock: serde_json::Value =
1323+
serde_json::from_slice(&std::fs::read(proj.join("package-lock.json")).unwrap())
1324+
.unwrap();
1325+
let want = format!("file:.socket/vendor/npm/{UUID}/{DEP}-{DEP_VERSION}.tgz");
1326+
assert_eq!(
1327+
lock["packages"][format!("node_modules/{dir}")]["resolved"],
1328+
want
1329+
);
1330+
assert_eq!(
1331+
lock["dependencies"][dir]["resolved"], want,
1332+
"the v2 legacy mirror (what npm 6 reads) must be rewired too"
1333+
);
1334+
1335+
let fresh = tmp.path().join("fresh");
1336+
npm_e2e_common::fresh_checkout(&proj, &fresh, &["package-lock.json"]);
1337+
let ci = npm_e2e_common::npm_ci(&fresh, &tmp.path().join("fresh-npm-cache"));
1338+
assert!(
1339+
ci.status.success(),
1340+
"npm {major} `npm ci` of the vendored v2 lock must succeed.\n{}",
1341+
npm_e2e_common::output_text(&ci)
1342+
);
1343+
assert_eq!(
1344+
std::fs::read(fresh.join("node_modules").join(dir).join("index.js")).unwrap(),
1345+
patched,
1346+
"npm 6 must install the PATCHED bytes from the vendored tarball"
1347+
);
1348+
Some(Npm6Cell {
1349+
tmp,
1350+
proj,
1351+
fresh,
1352+
patched,
1353+
lock_before,
1354+
vendor_stdout: stdout,
1355+
})
1356+
}
12751357
}
12761358

12771359
/// The one real package dir npm's linked store holds for `name@version`

0 commit comments

Comments
 (0)