Skip to content

Commit 6e7ef74

Browse files
mikolalysenkoclaudeMikola Lysenko
authored
Fix npm rewiring git/URL/file lock entries (#326) (#345)
* Start fix for #326 Assisted-by: Claude Code:claude-opus-5-5 * Skip npm lock entries installed from git or URLs npm installs a git, remote-tarball or file: dependency from the dependent's spec and ignores the lock entry's resolved. Hosted and vendored mode rewired those entries anyway, so the scan reported the package patched and vex attested it while npm ci installed the original bytes. Both rewriters now skip such entries with a loud stays-UNPATCHED warning (vendored refuses when no registry copy is left), and vex no longer attests a name@version while a non-registry copy of it is in the lock. Fixes #326 Assisted-by: Claude Code:claude-opus-5-5 * Document the npm non-registry entry skip Assisted-by: Claude Code:claude-opus-5-5 * Let the npm 12 e2e install a URL dependency npm 12 refuses remote-tarball specs unless allow-remote permits them, so the new vendored e2e now passes --allow-remote=all there. The VEX test messages and the new diagnostic no longer print patch URLs or uuids. Assisted-by: Claude Code:claude-opus-5-5 * Skip the v2 legacy mirror of non-registry npm entries The hosted rewriter and the vendored backend skipped a git, URL or file: packages entry, but still rewired its lockfileVersion 2 legacy dependencies mirror when that mirror stored the plain version. Each legacy node now maps to the packages key it mirrors, and a node whose twin is non-registry is left alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LycgFGuki2BqZ25VNhwxJ5 --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Mikola Lysenko <mik@socket.dev>
1 parent 5678b76 commit 6e7ef74

8 files changed

Lines changed: 1037 additions & 7 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,17 @@ limits, and required install commands.
102102

103103
### Fixed
104104

105+
- **npm dependencies installed from git, a URL or `file:` are no longer
106+
reported patched.** npm installs such a dependency from the dependent's
107+
spec (`github:user/repo`, `https://…/x.tgz`, `file:…`) and ignores the
108+
lock entry's `resolved`, so `npm ci` kept installing the original bytes
109+
after `scan --mode hosted` or `vendor` rewired the entry and `vex`
110+
attested it. Both modes now skip such an entry with a loud
111+
stays-UNPATCHED warning (`redirect_npm_non_registry_entry_skipped` /
112+
`vendor_non_registry_entry_skipped`; vendoring refuses with
113+
`vendor_lock_entry_not_rewritable` when no registry copy is left), and
114+
`vex` attests nothing for a `name@version` while such a copy is in the
115+
lock (#326).
105116
- **Agent mode finds Poetry's virtualenv in more setups.** Three cases
106117
missed the virtualenv Poetry installed into. Each fell back to the
107118
wrong interpreter, skipped the patch as `package_not_installed` and

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ Discovery is read-only, never touches the network, and never fails the run: a ma
356356

357357
| Ecosystem | Files read | Hosted reference | Vendored reference | Hosted pin (`integrity_required`) |
358358
|---|---|---|---|---|
359-
| npm | `package-lock.json` and `npm-shrinkwrap.json` (both when both exist) | `resolved` on the patch host (`packages` in v2/v3; `dependencies` only in v1; `link` / `inBundle` / `bundled` entries skipped) | `resolved: file:.socket/vendor/npm/<uuid>/<name>-<ver>.tgz` | `integrity`, required |
359+
| npm | `package-lock.json` and `npm-shrinkwrap.json` (both when both exist) | `resolved` on the patch host (`packages` in v2/v3; `dependencies` only in v1; `link` / `inBundle` / `bundled` entries skipped, and so is any entry npm installs from a git, URL or `file:` spec, together with every ref for the same `name@version`) | `resolved: file:.socket/vendor/npm/<uuid>/<name>-<ver>.tgz` | `integrity`, required |
360360
| pnpm | `pnpm-lock.yaml` (every `lockfileVersion`); `shrinkwrap.yaml` only when there is no `pnpm-lock.yaml`; with `rush.json`, `common/config/rush/pnpm-lock.yaml` + `common/config/subspaces/*/pnpm-lock.yaml` | `packages:` `resolution.tarball` on the patch host | `file:.socket/vendor/npm/…` tarball + key | `integrity`, required |
361361
| yarn | `yarn.lock` (classic and berry) | classic `resolved`; berry `resolution: …::__archiveUrl=<url>` | classic `resolved "file:./.socket/vendor/npm/…#<sha1>"`; berry `file:` entry **plus** a root `package.json` `resolutions` mapping onto the same artifact (without it the entry is orphaned: diagnosed, no ref) | classic `integrity` / `#sha1`, berry `checksum`, required |
362362
| bun | `bun.lock`; `bun.lockb` only when there is no `bun.lock` (bun reads exactly one) | URL tuple / binary remote-tarball resolution; version from the URL leaf | `.socket/vendor/npm/<uuid>/<name>-<ver>.tgz` tuple / local-tarball resolution | `sha512-…`, required. A 2-tuple that Bun < 1.3.10 re-saved without its digest is still a reference, but it attests only from an installed tree. |

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

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,119 @@ fn npm_vendor_vex_attests_against_vendored_tarball() {
596596
);
597597
}
598598

599+
/// #326: a dependency installed from a remote-tarball spec (`"left-pad":
600+
/// "https://…/left-pad-1.3.0.tgz"`) is fetched from that url by `npm ci`,
601+
/// whatever the lock's `resolved` says. Vendoring used to rewire the entry
602+
/// and report `applied` (and `vex` attested it) while `npm ci` installed
603+
/// the original bytes. It must refuse, leave the lock untouched, and give
604+
/// `vex` nothing to attest.
605+
#[test]
606+
fn npm_vendor_refuses_a_remote_tarball_dependency() {
607+
let suite = "e2e_vendor_npm_build (remote tarball)";
608+
let Some(major) = npm_major_or_skip(suite) else {
609+
return;
610+
};
611+
let tmp = tempfile::tempdir().unwrap();
612+
let proj = tmp.path().join("proj");
613+
std::fs::create_dir_all(&proj).unwrap();
614+
std::fs::write(
615+
proj.join("package.json"),
616+
r#"{"name":"vendor-url-spec","version":"0.0.0","private":true}"#,
617+
)
618+
.unwrap();
619+
let cache = tmp.path().join("npm-cache");
620+
let url = format!("https://registry.npmjs.org/{DEP}/-/{DEP}-{DEP_VERSION}.tgz");
621+
// npm 12 refuses remote-tarball specs unless `allow-remote` permits them.
622+
let spec = if major >= 12 {
623+
format!("{url} --allow-remote=all")
624+
} else {
625+
url.clone()
626+
};
627+
let mut args = vec!["install", "--no-audit", "--no-fund", "--cache"];
628+
args.push(cache.to_str().unwrap());
629+
args.extend(spec.split(' '));
630+
let out = npm(&proj, &args);
631+
if !out.status.success() {
632+
npm_e2e_common::skip(
633+
suite,
634+
&format!(
635+
"`npm install {spec}` failed (registry unreachable?):\n{}",
636+
String::from_utf8_lossy(&out.stderr)
637+
),
638+
);
639+
return;
640+
}
641+
let pkg: serde_json::Value =
642+
serde_json::from_slice(&std::fs::read(proj.join("package.json")).unwrap()).unwrap();
643+
assert_eq!(
644+
pkg["dependencies"][DEP], url,
645+
"the fixture depends on the url spec: {pkg}"
646+
);
647+
648+
let installed_index = proj.join("node_modules").join(DEP).join("index.js");
649+
let orig = std::fs::read(&installed_index).expect("installed index.js");
650+
let patched: Vec<u8> = [MARKER.as_bytes(), orig.as_slice()].concat();
651+
let purl = format!("pkg:npm/{DEP}@{DEP_VERSION}");
652+
const GHSA: &str = "GHSA-vend-npm-url";
653+
stage_patch_with_vuln(&proj, &purl, "package/index.js", &orig, &patched, GHSA);
654+
if v1_lock_is_refused(&proj, major) {
655+
return;
656+
}
657+
let lock_before = std::fs::read(proj.join("package-lock.json")).unwrap();
658+
659+
let (_code, stdout, stderr) = run_socket(
660+
&proj,
661+
&[
662+
"vendor",
663+
"--json",
664+
"--offline",
665+
"--cwd",
666+
proj.to_str().unwrap(),
667+
],
668+
);
669+
let env = parse_envelope(&stdout);
670+
assert_eq!(
671+
env["summary"]["applied"], 0,
672+
"a url-spec dependency must not be vendored: {env}\nstderr:\n{stderr}"
673+
);
674+
assert!(
675+
stdout.contains("vendor_lock_entry_not_rewritable") && stdout.contains("UNPATCHED"),
676+
"the refusal must say why: {env}"
677+
);
678+
assert_eq!(
679+
std::fs::read(proj.join("package-lock.json")).unwrap(),
680+
lock_before,
681+
"the lock is untouched"
682+
);
683+
assert!(
684+
!proj.join(format!(".socket/vendor/npm/{UUID}")).exists(),
685+
"no artifact is written"
686+
);
687+
688+
let vex_path = proj.join("out.vex.json");
689+
let (code, stdout, stderr) = run_socket(
690+
&proj,
691+
&[
692+
"vex",
693+
"--cwd",
694+
proj.to_str().unwrap(),
695+
"--output",
696+
vex_path.to_str().unwrap(),
697+
"--product",
698+
"pkg:npm/app@1.0.0",
699+
],
700+
);
701+
let attested = std::fs::read(&vex_path)
702+
.ok()
703+
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
704+
.and_then(|doc| doc["statements"].as_array().map(|s| !s.is_empty()))
705+
.unwrap_or(false);
706+
assert!(
707+
!attested,
708+
"vex must not attest an unwired patch (exit {code}).\nstdout:\n{stdout}\nstderr:\n{stderr}"
709+
);
710+
}
711+
599712
/// get-driven twin of the capstone (v3.6): instead of hand-staging
600713
/// `.socket/` (manifest + blob) and running `vendor --offline`,
601714
/// `get <uuid> --mode vendored` resolves the SAME patch from a mocked

0 commit comments

Comments
 (0)