From 8215c754117bbde30793bb1ec426986aee9ebc14 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 13:28:02 +0000 Subject: [PATCH 01/11] Start fix for #935, #938, #939 Assisted-by: Claude Code:claude-opus-5-5 From 918a9ce76c76de770c0c9c9aeb5cb5fff7c62334 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 13:48:14 +0000 Subject: [PATCH 02/11] Stop VEX attesting beside an unpatched lock copy A lockfile-only `vex` attested a package as not_affected while the same lock also installed an unpatched copy of that exact name@version: - pnpm: a `file:` directory or tarball copy (#935) - yarn classic: a registry block left beside the Socket block, e.g. after `yarn add -W left-pad --exact` (#938) - yarn berry: a `file:` / url copy locked under another dependency name, which the `resolutions` pin never reaches (#939) The cross-lock contest only weighs OTHER locks, and each extractor wrote its own same-lock rule (npm and yarn classic git only). Discovery now has one shared same-lock rule: extractors record an unpatched copy and every ref of the same name@version in that lock is dropped with a patched_ref_unattributable diagnostic naming the copy. Yarn classic's git-copy filter moves onto it. The berry and pnpm extractors read the copy's real package from its package.json (directory or tarball) or from the registry tarball url. Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-core/src/vex/discover/mod.rs | 112 ++++++ .../socket-patch-core/src/vex/discover/npm.rs | 184 +++++++++- .../src/vex/discover/testing/golden.rs | 24 +- .../src/vex/discover/yarn.rs | 347 ++++++++++++++++-- .../vex-discover-golden/redirect-npm.json | 50 ++- 5 files changed, 673 insertions(+), 44 deletions(-) diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index d43715c5d..7cc525045 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -404,6 +404,23 @@ pub struct ResolvedElsewhere { pub file: PathBuf, } +/// A lock entry that installs its OWN copy of a package — a `file:` +/// directory or tarball, a user url, git, a registry block no Socket rewrite +/// reached — beside a Socket wiring of the same `name@version` in the SAME +/// lock (see [`Discovery::unpatched_copy`]). The package manager installs +/// that copy too (or instead), so the lock's wiring is never attested. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct UnpatchedCopy { + /// Canonical base purl ([`canonical_base_purl`]). + pub purl: String, + /// Root-relative lock file. + pub file: PathBuf, + /// The lock entry's key, as the lock spells it. + pub key: String, + /// How that entry installs, completing "lock entry `` …". + pub how: String, +} + /// A ref discovery emits (so rollback, remove and list find the wiring) /// that must not be attested: the files show a build that resolves the /// package from somewhere the pin does not reach. Today: a Gradle lock @@ -437,6 +454,9 @@ pub struct Discovery { /// non-Socket source — the evidence the cross-lock contest /// ([`discover_patched_refs_with`]) weighs against another lock's ref. pub elsewhere: Vec, + /// Same-lock copies that contest that lock's own refs + /// ([`Discovery::unpatched_copy`]). + pub unpatched_copies: Vec, /// Refs in `refs` whose wiring a build bypasses ([`Unattested`]). pub unattested: Vec, } @@ -576,6 +596,70 @@ impl Discovery { } } + /// Record that lock `file`'s entry `key` installs its own copy of `purl` + /// (`None` is ignored), `how` saying from where. The cross-lock contest + /// only weighs OTHER locks (a lock that wires a package is never its + /// own contester there), so this is the one same-lock rule every + /// extractor shares (#935, #938, #939): the copy is also + /// [`Discovery::resolved_elsewhere`] evidence, and + /// [`Discovery::contest_within_locks`] drops every ref of the same + /// `name@version` in the same file. + pub(crate) fn unpatched_copy( + &mut self, + file: &str, + purl: Option, + key: &str, + how: &str, + ) { + let Some(purl) = purl else { + return; + }; + self.resolved_elsewhere(file, Some(purl.clone())); + let copy = UnpatchedCopy { + purl: canonical_base_purl(&purl), + file: PathBuf::from(file), + key: key.to_string(), + how: how.to_string(), + }; + if !self.unpatched_copies.contains(©) { + self.unpatched_copies.push(copy); + } + } + + /// Drop every ref whose OWN lock also installs an unpatched copy of the + /// same `name@version` ([`Discovery::unpatched_copy`]): the build ships + /// that copy whatever the wiring does, so the ref is diagnosed + /// ([`DIAG_REF_UNATTRIBUTABLE`], naming the entry) and not emitted. Its + /// uuid stays recognized (rule 11). + fn contest_within_locks(&mut self) { + if self.unpatched_copies.is_empty() { + return; + } + let refs = std::mem::take(&mut self.refs); + for r in refs { + let copy = self + .unpatched_copies + .iter() + .find(|c| c.purl == r.purl && c.file == r.source_file) + .cloned(); + match copy { + Some(c) => { + let file = r.source_file.to_string_lossy().into_owned(); + self.diag( + DIAG_REF_UNATTRIBUTABLE, + &file, + format!( + "{file}: {} is wired to Socket patch {} but lock entry `{}` {}; \ + that copy stays UNPATCHED and nothing is attested", + r.purl, r.uuid, c.key, c.how + ), + ); + } + None => self.refs.push(r), + } + } + } + /// Drop every ref that ANOTHER lock contests: a lock that resolves the /// same package at the same version from a non-Socket source /// ([`Discovery::resolved_elsewhere`]) while wiring it to no patch @@ -699,6 +783,8 @@ impl Discovery { fn finalize(&mut self) { self.elsewhere.sort(); self.elsewhere.dedup(); + self.unpatched_copies.sort(); + self.unpatched_copies.dedup(); self.unattested.sort(); self.unattested.dedup(); self.refs.sort_by(|a, b| { @@ -770,6 +856,7 @@ async fn discover_with_ctx(ctx: DiscoverCtx<'_>) -> Discovery { gradle::extract(&ctx, &mut out).await; nuget::extract(&ctx, &mut out).await; deno::extract(&ctx, &mut out).await; + out.contest_within_locks(); out.contest_across_locks(); out.recognized.extend(ctx.take_recognized()); out.finalize(); @@ -913,6 +1000,12 @@ impl<'a> DiscoverCtx<'a> { self.view.read_text(rel).await.ok() } + /// Bytes twin of [`DiscoverCtx::read_advisory_text`] (a user's `file:` + /// tarball, read only to name the package it holds). + pub(crate) async fn read_advisory_bytes(&self, rel: &str) -> Option> { + self.view.read_bytes(rel).await.ok() + } + /// Bytes twin of [`DiscoverCtx::read_text`] (JSON and binary locks). A /// binary lock is swept through its lossy UTF-8 view: string pools store /// resolutions verbatim, and a stale string an older patch generation @@ -2066,6 +2159,23 @@ pub(crate) mod testing { /// the patch uuid after it. pub(crate) const TOKEN: &str = "11111111-2222-4333-8444-555555555555"; + /// A minimal npm tarball (`package/package.json` naming + /// `name@version`) — a user's `file:` tarball copy. + pub(crate) fn npm_tgz(name: &str, version: &str) -> Vec { + let manifest = format!(r#"{{"name":"{name}","version":"{version}"}}"#); + let mut tar = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::default(), + )); + let mut header = tar::Header::new_gnu(); + header.set_size(manifest.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + tar.append_data(&mut header, "package/package.json", manifest.as_bytes()) + .unwrap(); + tar.into_inner().unwrap().finish().unwrap() + } + /// Production artifact-URL shape on Socket's patch server /// (`…/patch//////`). pub(crate) fn hosted_url( @@ -2163,6 +2273,8 @@ pub(crate) mod testing { let ctx = self.ctx(); let mut out = Discovery::default(); extract(&ctx, &mut out).await; + // Same-lock copies contest within one extractor's own locks. + out.contest_within_locks(); let swept = ctx.take_recognized(); assert_recognition_covers_refs(&out, &swept, "the ctx sweep", Some(self.root())); out.recognized.extend(swept); diff --git a/crates/socket-patch-core/src/vex/discover/npm.rs b/crates/socket-patch-core/src/vex/discover/npm.rs index 289b2bf61..c130efa95 100644 --- a/crates/socket-patch-core/src/vex/discover/npm.rs +++ b/crates/socket-patch-core/src/vex/discover/npm.rs @@ -572,9 +572,119 @@ async fn extract_pnpm_lock(ctx: &DiscoverCtx<'_>, file: &str, out: &mut Discover ); return; } + let mut copies: Vec = Vec::new(); for package in lock.packages() { - pnpm_entry_ref(ctx, file, package, out); + pnpm_entry_ref(ctx, file, package, &mut copies, out); } + record_pnpm_file_copies(ctx, file, copies, out).await; +} + +/// A pnpm `packages:` entry installed from a user's `file:` directory or +/// tarball, awaiting [`record_pnpm_file_copies`]. +struct PnpmFileCopy { + key: String, + /// The package name (the v9 key's, or a legacy entry's `name:` field). + name: Option, + /// The entry's `version:` field (always there for a tarball). + version: Option, + /// The `file:` path, relative to the lock's directory. + path: String, + directory: bool, +} + +/// Record each [`PnpmFileCopy`] as an unpatched copy of its package (#935): +/// pnpm installs a `file:` directory or tarball from the user's own bytes, +/// and no override or tarball rewire of the registry entry reaches it. A +/// directory entry carries no version, so it is read from the directory's +/// `package.json` (a legacy entry's name too); one whose package cannot be +/// read is left alone. +async fn record_pnpm_file_copies( + ctx: &DiscoverCtx<'_>, + file: &str, + copies: Vec, + out: &mut Discovery, +) { + let lock_dir = std::path::Path::new(file) + .parent() + .map(|d| d.to_string_lossy().replace('\\', "/")) + .unwrap_or_default(); + for copy in copies { + let (mut name, mut version) = (copy.name, copy.version); + if name.is_none() || version.is_none() { + let manifest: Option = + match crate::utils::cargo_workspace::normalize_rel(&lock_dir, ©.path) { + Some(rel) if copy.directory => { + let manifest = if rel.is_empty() { + "package.json".to_string() + } else { + format!("{rel}/package.json") + }; + ctx.read_advisory_text(&manifest).await.and_then(|t| { + serde_json::from_str(t.trim_start_matches('\u{feff}')).ok() + }) + } + Some(rel) => match ctx.read_advisory_bytes(&rel).await { + Some(bytes) => tokio::task::spawn_blocking(move || { + let map = + crate::patch::package::read_archive_bytes_to_map(&bytes).ok()?; + serde_json::from_slice::(map.get("package.json")?).ok() + }) + .await + .ok() + .flatten(), + None => None, + }, + None => None, + }; + let field = |k: &str| { + manifest + .as_ref() + .and_then(|m| m.get(k)) + .and_then(Value::as_str) + .map(str::to_string) + }; + name = name.or_else(|| field("name")); + version = version.or_else(|| field("version")); + } + let (Some(name), Some(version)) = (name, version) else { + continue; + }; + let what = if copy.directory { + "directory" + } else { + "tarball" + }; + out.unpatched_copy( + file, + npm_purl(&name, &version), + ©.key, + &format!( + "installs it from the user's file: {what} {:?}, which no Socket wiring \ + reaches", + copy.path + ), + ); + } +} + +/// The [`PnpmFileCopy`] of a `file:`-keyed entry, `None` for any other key. +fn pnpm_file_copy(package: &PnpmPackage<'_>, directory: bool) -> Option { + let (name, path) = match classify_pnpm_key(package.key) { + PnpmKey::V9File { name, path } => (Some(name.to_string()), path), + PnpmKey::LegacyFile { path } => ( + entry_field(&package.entry, "name").map(str::to_string), + path, + ), + PnpmKey::Registry { .. } | PnpmKey::Other => return None, + }; + let path = path.strip_prefix("file:").unwrap_or(path).to_string(); + Some(PnpmFileCopy { + key: package.key.to_string(), + name, + version: entry_field(&package.entry, "version").map(str::to_string), + path, + directory, + }) } /// Classify one `packages:` entry and push its ref, if any. @@ -582,6 +692,7 @@ fn pnpm_entry_ref( ctx: &DiscoverCtx<'_>, file: &str, package: &PnpmPackage<'_>, + copies: &mut Vec, out: &mut Discovery, ) { let key = package.key; @@ -601,6 +712,7 @@ fn pnpm_entry_ref( let Some(tarball) = resolution.tarball() else { // A plain registry entry (integrity only) or a directory/git dep. out.resolved_elsewhere(file, pnpm_registry_key_purl(key)); + copies.extend(pnpm_file_copy(package, true)); return; }; let integrity = resolution @@ -641,8 +753,10 @@ fn pnpm_entry_ref( true, )); } else { - // A registry-keyed entry fetching some other tarball. + // A registry-keyed entry fetching some other tarball, or a user's + // `file:` tarball. out.resolved_elsewhere(file, pnpm_registry_key_purl(key)); + copies.extend(pnpm_file_copy(package, false)); } } @@ -1979,6 +2093,72 @@ mod tests { assert!(out.diagnostics.is_empty(), "{:#?}", out.diagnostics); } + /// #935: pnpm installs a `file:` directory or `file:` tarball copy of + /// the wired name@version from the user's own bytes, so a hosted pin of + /// the registry entry in the SAME lock is not attested: the ref is + /// dropped with a diagnostic naming the copy (v9 keys and pnpm 8's + /// legacy `file:` keys alike). Controls: the wiring alone is a ref, and + /// a `file:` copy of ANOTHER version does not contest it. + #[tokio::test] + async fn issue_935_same_lock_file_copy_contests_the_pnpm_ref() { + let url = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); + let wired = + format!(" left-pad@1.3.0:\n resolution: {{integrity: {SRI}, tarball: {url}}}\n\n"); + let lock = |extra: &str| format!("lockfileVersion: '9.0'\n\npackages:\n\n{wired}{extra}"); + let dir_v9 = " left-pad@file:forks/left-pad:\n \ + resolution: {directory: forks/left-pad, type: directory}\n\n"; + let tgz_v9 = " left-pad@file:forks/left-pad-1.3.0.tgz:\n \ + resolution: {integrity: sha512-UPSTREAM==, tarball: file:forks/left-pad-1.3.0.tgz}\n \ + version: 1.3.0\n\n"; + let dir_legacy = " file:forks/left-pad:\n \ + resolution: {directory: forks/left-pad, type: directory}\n \ + name: left-pad\n version: 1.3.0\n\n"; + + let control = Project::new(); + control.write("pnpm-lock.yaml", lock("")); + assert_refs( + &run(&control).await, + &[("pkg:npm/left-pad@1.3.0", UUID_A, WiringMode::Hosted)], + ); + + for (case, extra, fork_version) in [ + ("v9 file: directory", dir_v9, "1.3.0"), + ("v9 file: tarball", tgz_v9, "1.3.0"), + ("legacy file: directory", dir_legacy, "1.3.0"), + ] { + let p = Project::new(); + p.write("pnpm-lock.yaml", lock(extra)); + p.write( + "forks/left-pad/package.json", + format!(r#"{{"name":"left-pad","version":"{fork_version}"}}"#), + ); + p.write("forks/left-pad-1.3.0.tgz", npm_tgz("left-pad", "1.3.0")); + let out = run(&p).await; + assert!(out.refs.is_empty(), "{case}: {:#?}", out.refs); + assert!( + out.diagnostics + .iter() + .any(|d| d.code == DIAG_REF_UNATTRIBUTABLE + && d.detail.contains("forks/left-pad") + && d.detail.contains("UNPATCHED")), + "{case}: {:#?}", + out.diagnostics + ); + } + + // A `file:` directory holding ANOTHER version is not a copy of it. + let p = Project::new(); + p.write("pnpm-lock.yaml", lock(dir_v9)); + p.write( + "forks/left-pad/package.json", + r#"{"name":"left-pad","version":"2.0.0"}"#, + ); + assert_refs( + &run(&p).await, + &[("pkg:npm/left-pad@1.3.0", UUID_A, WiringMode::Hosted)], + ); + } + /// The committed golden (TS backend output — what a depscan PR leaves). #[tokio::test] async fn pnpm_golden_hosted_fixture() { diff --git a/crates/socket-patch-core/src/vex/discover/testing/golden.rs b/crates/socket-patch-core/src/vex/discover/testing/golden.rs index 7c73c6761..92542b27f 100644 --- a/crates/socket-patch-core/src/vex/discover/testing/golden.rs +++ b/crates/socket-patch-core/src/vex/discover/testing/golden.rs @@ -37,7 +37,8 @@ use std::path::{Path, PathBuf}; use serde_json::{json, Map, Value}; use crate::vex::discover::{ - Diag, Discovery, PatchedRef, Recognized, ResolvedElsewhere, Unattested, UnlockedPin, WiringMode, + Diag, Discovery, PatchedRef, Recognized, ResolvedElsewhere, Unattested, UnlockedPin, + UnpatchedCopy, WiringMode, }; /// Set to `1` to (re)write the goldens instead of comparing against them. @@ -119,6 +120,7 @@ fn render(out: &Discovery, root: &Path) -> Value { recognized, unlocked_pins, elsewhere, + unpatched_copies, unattested, } = out; let refs: Vec = refs @@ -225,6 +227,26 @@ fn render(out: &Discovery, root: &Path) -> Value { .collect::>() .into(); } + if !unpatched_copies.is_empty() { + rendered["unpatched_copies"] = unpatched_copies + .iter() + .map(|c| { + let UnpatchedCopy { + purl, + file, + key, + how, + } = c; + json!({ + "purl": purl, + "file": path_str(file), + "key": key, + "how": normalize(how, &roots), + }) + }) + .collect::>() + .into(); + } rendered } diff --git a/crates/socket-patch-core/src/vex/discover/yarn.rs b/crates/socket-patch-core/src/vex/discover/yarn.rs index b6763b88f..6aefe69e5 100644 --- a/crates/socket-patch-core/src/vex/discover/yarn.rs +++ b/crates/socket-patch-core/src/vex/discover/yarn.rs @@ -138,44 +138,21 @@ fn stray_top_level_line(text: &str) -> Option<&str> { // ── classic ────────────────────────────────────────────────────────────── fn extract_classic(ctx: &DiscoverCtx<'_>, entries: Vec, out: &mut Discovery) { - // (purl, key) of every live git-fetched block: that copy installs the - // git bytes, so no wiring of the same package in this lock is attested. - let mut git_copies: Vec<(String, String)> = Vec::new(); for entry in entries { if entry.live && !entry.patterns.is_empty() { - classic_block(ctx, &entry, &mut git_copies, out); - } - } - if git_copies.is_empty() { - return; - } - let refs = std::mem::take(&mut out.refs); - for r in refs { - let git_key = (r.source_file == std::path::Path::new(YARN_LOCK)) - .then(|| git_copies.iter().find(|(purl, _)| *purl == r.purl)) - .flatten(); - match git_key { - Some((_, key)) => out.diag( - DIAG_REF_UNATTRIBUTABLE, - YARN_LOCK, - format!( - "{YARN_LOCK}: {} is wired to a Socket patch but lock entry `{key}` \ - installs from git, which yarn fetches from the git source rather than \ - a tarball; that copy stays UNPATCHED and nothing is attested", - r.purl - ), - ), - None => out.refs.push(r), + classic_block(ctx, &entry, out); } } } -fn classic_block( - ctx: &DiscoverCtx<'_>, - entry: &YarnEntry, - git_copies: &mut Vec<(String, String)>, - out: &mut Discovery, -) { +/// Classify one live classic block. A block of a package that yarn +/// installs from anything but a Socket wiring — git (#363) or a registry / +/// url tarball (#938) — is an unpatched copy of that `name@version` +/// ([`Discovery::unpatched_copy`]): yarn 1 installs ONE copy per +/// `name@version`, and which block it takes depends on which pattern it +/// resolves first, so no wiring of the same version in this lock is +/// attested beside it. +fn classic_block(ctx: &DiscoverCtx<'_>, entry: &YarnEntry, out: &mut Discovery) { let YarnEntry { block, patterns, .. } = entry; @@ -207,10 +184,12 @@ fn classic_block( ), ); } - if let Some(purl) = purl { - out.resolved_elsewhere(YARN_LOCK, Some(purl.clone())); - git_copies.push((purl, block.key.clone())); - } + out.unpatched_copy( + YARN_LOCK, + purl, + &block.key, + "installs from git, which yarn fetches from the git source rather than a tarball", + ); return; } let Some(resolved) = resolved else { @@ -228,13 +207,23 @@ fn classic_block( let names: Vec> = names.into_iter().collect(); let Some(wiring) = classify(ctx, resolved, YARN_LOCK, &block.key, out) else { // Not Socket's (a rejected Socket spelling was diagnosed instead): - // evidence against another lock's wiring of the same package. + // an unpatched copy of the package, which contests a wiring of the + // same version in this lock and in any other. if let ([Some(name)], Some(version), false) = ( names.as_slice(), classic_field(&block.lines, "version"), root_anchored_spelling(resolved), ) { - out.resolved_elsewhere(YARN_LOCK, npm_purl(name, version)); + out.unpatched_copy( + YARN_LOCK, + npm_purl(name, version), + &block.key, + &format!( + "installs it from {resolved:?}, not a Socket patch (yarn 1 \ + installs one copy per name@version, from whichever block it resolves \ + first)" + ), + ); } return; }; @@ -296,6 +285,7 @@ struct BerryHostedKeyed { async fn extract_berry(ctx: &DiscoverCtx<'_>, lock: BerryLock, out: &mut Discovery) { let mut vendored: Vec = Vec::new(); let mut hosted_keyed: Vec = Vec::new(); + let mut copies: Vec = Vec::new(); for entry in lock.entries.iter().filter(|e| e.live) { berry_block( ctx, @@ -303,9 +293,11 @@ async fn extract_berry(ctx: &DiscoverCtx<'_>, lock: BerryLock, out: &mut Discove lock.cache_key.as_deref(), &mut vendored, &mut hosted_keyed, + &mut copies, out, ); } + record_berry_copies(ctx, copies, out).await; confirm_berry_hosted_keyed(ctx, hosted_keyed, out).await; confirm_berry_vendored(ctx, vendored, out).await; } @@ -383,6 +375,7 @@ fn berry_block( cache_key: Option<&str>, vendored: &mut Vec, hosted_keyed: &mut Vec, + copies: &mut Vec, out: &mut Discovery, ) { let block = &entry.block; @@ -410,6 +403,17 @@ fn berry_block( if let (Some(v), false) = (locator_version, root_anchored_spelling(spec)) { let version = berry_field(&block.lines, "version").unwrap_or(v); out.resolved_elsewhere(YARN_LOCK, npm_purl(name, version)); + } else if locator_version.is_none() && !root_anchored_spelling(spec) { + // A user's `file:` / url copy: yarn keys it by the DEPENDENCY + // name (`lp2@file:…`), so which package it installs is read + // from the copy itself ([`record_berry_copies`], #939). + if let Some(version) = berry_field(&block.lines, "version") { + copies.push(BerryCopy { + key: block.key.clone(), + reference: reference.to_string(), + version: version.to_string(), + }); + } } return; }; @@ -692,6 +696,146 @@ fn emit( } } +/// A berry lock entry that installs a user's `file:` tarball / directory or +/// url tarball (not a Socket wiring), awaiting [`record_berry_copies`]. +struct BerryCopy { + key: String, + /// The locator's reference (`file:[#…][::…]` or a url). + reference: String, + version: String, +} + +/// Record each [`BerryCopy`] as an unpatched copy of the package it really +/// installs (#939). yarn keys a `file:` / url dependency by the name the +/// depender gave it, so `"lp2": "file:left-pad-1.3.0.tgz"` locks as `lp2@…` +/// while `node_modules/lp2` IS left-pad@1.3.0, and no `resolutions` pin of +/// `left-pad` reaches it. The real name comes from the copy itself: the +/// registry tarball path of a url (`…//-/-.tgz`), the +/// `package.json` inside a `file:` tarball, or a `file:` directory's +/// `package.json`. A copy whose name cannot be read is left alone. +async fn record_berry_copies(ctx: &DiscoverCtx<'_>, copies: Vec, out: &mut Discovery) { + for copy in copies { + let (name, how) = if let Some(path) = copy.reference.strip_prefix("file:") { + let path = path + .split(['#', ':']) + .next() + .unwrap_or_default() + .to_string(); + let Some(rel) = berry_file_copy_path(©.reference, &path) else { + continue; + }; + let name = if is_tarball_leaf(&rel) { + match ctx.read_advisory_bytes(&rel).await { + Some(bytes) => { + tokio::task::spawn_blocking(move || tarball_package_name(&bytes)) + .await + .ok() + .flatten() + } + None => None, + } + } else { + let manifest = if rel.is_empty() { + PACKAGE_JSON.to_string() + } else { + format!("{rel}/{PACKAGE_JSON}") + }; + match ctx.read_advisory_text(&manifest).await { + Some(text) => manifest_name(text.as_bytes()), + None => None, + } + }; + (name, format!("installs it from the user's file:{path}")) + } else { + let url = copy.reference.split(['#']).next().unwrap_or_default(); + ( + registry_tarball_name(url, ©.version), + format!("installs it from {url:?}"), + ) + }; + let Some(name) = name else { + continue; + }; + out.unpatched_copy( + YARN_LOCK, + npm_purl(&name, ©.version), + ©.key, + &format!( + "{how}, which no Socket wiring of {name} reaches (yarn keys it by the \ + dependency name)" + ), + ); + } +} + +/// The root-relative path a berry `file:` reference names: relative to the +/// workspace in its `locator=` binding (`b@workspace:packages/b`), or to +/// the root when it has none. `None` when it leaves the root. +fn berry_file_copy_path(reference: &str, path: &str) -> Option { + let workspace = reference + .split_once("::") + .and_then(|(_, bindings)| bindings.split('&').find_map(|b| b.strip_prefix("locator="))) + .map(crate::utils::purl::percent_decode_purl_component) + .and_then(|locator| { + locator + .split_once("@workspace:") + .map(|(_, ws)| ws.to_string()) + }) + .unwrap_or_default(); + let workspace = if workspace == "." { + String::new() + } else { + workspace + }; + crate::utils::cargo_workspace::normalize_rel(&workspace, path) +} + +fn is_tarball_leaf(path: &str) -> bool { + path.ends_with(".tgz") || path.ends_with(".tar.gz") +} + +/// `name` of a `package.json`. +fn manifest_name(bytes: &[u8]) -> Option { + let bytes = bytes.strip_prefix(b"\xef\xbb\xbf").unwrap_or(bytes); + serde_json::from_slice::(bytes) + .ok()? + .get("name")? + .as_str() + .map(str::to_string) +} + +/// `name` of the `package.json` inside an npm tarball. +fn tarball_package_name(bytes: &[u8]) -> Option { + let map = crate::patch::package::read_archive_bytes_to_map(bytes).ok()?; + manifest_name(map.get(PACKAGE_JSON)?) +} + +/// The package an npm registry tarball url serves, from its +/// `//-/-.tgz` path (`` may be `@scope/leaf`, +/// its `@` / `/` possibly percent-encoded); `None` for any other shape. +fn registry_tarball_name(url: &str, version: &str) -> Option { + let path = url.split_once("://").map_or(url, |(_, rest)| rest); + let path = path.split(['?']).next()?; + let (before, file) = path.rsplit_once("/-/")?; + let mut segs: Vec = before + .split('/') + .skip(1) // the host + .map(|seg| crate::utils::purl::percent_decode_purl_component(seg).into_owned()) + .collect(); + let leaf_name = segs.pop()?; + let (scope, leaf_name) = match leaf_name.split_once('/') { + Some((scope, leaf)) => (Some(scope.to_string()), leaf.to_string()), + None => (segs.pop().filter(|s| s.starts_with('@')), leaf_name), + }; + if file != format!("{leaf_name}-{version}.tgz") { + return None; + } + Some(match scope { + Some(scope) => format!("{scope}/{leaf_name}"), + None => leaf_name, + }) +} + #[cfg(test)] mod tests { use super::super::testing::*; @@ -1131,6 +1275,133 @@ mod tests { } } + /// #938: a registry block of the wired name@version beside the Socket + /// block in the SAME yarn.lock (e.g. after `yarn add -W left-pad + /// --exact`): yarn 1 installs one copy per name@version, from whichever + /// block it resolves first, so the wiring is not attested and the + /// diagnostic names the registry block. Control: another version's + /// registry block does not contest it. + #[tokio::test] + async fn issue_938_classic_registry_block_of_the_same_version_contests_the_ref() { + let lp = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); + let wired = classic_block("left-pad@^1.3.0", "1.3.0", &lp, Some(SRI)); + let registry = |version: &str| { + classic_block( + &format!("left-pad@{version}"), + version, + &format!("https://registry.yarnpkg.com/left-pad/-/left-pad-{version}.tgz#5b8a"), + Some("sha512-UPSTREAM=="), + ) + }; + let p = Project::new(); + p.write("yarn.lock", classic(&[registry("1.3.0"), wired.clone()])); + let out = run(&p).await; + assert!(out.refs.is_empty(), "{:#?}", out.refs); + assert!( + out.diagnostics + .iter() + .any(|d| d.code == DIAG_REF_UNATTRIBUTABLE + && d.detail.contains("left-pad@1.3.0") + && d.detail.contains("registry.yarnpkg.com")), + "{:#?}", + out.diagnostics + ); + + let p = Project::new(); + p.write("yarn.lock", classic(&[registry("1.2.0"), wired])); + assert_refs( + &run(&p).await, + &[("pkg:npm/left-pad@1.3.0", UUID_A, WiringMode::Hosted)], + ); + } + + /// #939: yarn berry keys a `file:` tarball / directory or url dependency + /// by the DEPENDENCY name (`lp2@file:…`), so a copy of the wired + /// left-pad@1.3.0 under another name escapes the `resolutions` pin and + /// installs unpatched. Its package is read from the copy itself (the + /// tarball's or directory's `package.json`, the registry url's path) and + /// it contests the wiring in the same lock. Control: a copy holding + /// another package is not one. + #[tokio::test] + async fn issue_939_berry_other_name_copy_contests_the_ref() { + let ws = "b%40workspace%3Apackages%2Fb"; + let wired = berry_vendored_block("left-pad", "1.3.0", UUID_A); + let copies = [ + ( + "file: tarball", + berry_block( + &format!("lp2@file:../../forks/left-pad-1.3.0.tgz::locator={ws}"), + "1.3.0", + &format!( + "lp2@file:../../forks/left-pad-1.3.0.tgz#../../forks/left-pad-1.3.0.tgz\ + ::hash=5c8e4c&locator={ws}" + ), + None, + ), + ), + ( + "file: directory", + berry_block( + &format!("lp2@file:../../forks/left-pad::locator={ws}"), + "1.3.0", + &format!("lp2@file:../../forks/left-pad#../../forks/left-pad::hash=1a2b3c&locator={ws}"), + None, + ), + ), + ( + "registry tarball url", + berry_block( + "lp2@https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "1.3.0", + "lp2@https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + None, + ), + ), + ]; + let project = |blocks: &[String], fork: &str| { + let p = Project::new(); + p.write("yarn.lock", berry(blocks)); + p.write( + "package.json", + package_json_with_resolutions(serde_json::json!({ + "left-pad": format!("file:./.socket/vendor/npm/{UUID_A}/left-pad-1.3.0.tgz"), + })), + ); + p.write("forks/left-pad-1.3.0.tgz", npm_tgz(fork, "1.3.0")); + p.write( + "forks/left-pad/package.json", + format!(r#"{{"name":"{fork}","version":"1.3.0"}}"#), + ); + p + }; + assert_refs( + &run(&project(std::slice::from_ref(&wired), "left-pad")).await, + &[("pkg:npm/left-pad@1.3.0", UUID_A, WiringMode::Vendored)], + ); + for (case, copy) in &copies { + let out = run(&project(&[wired.clone(), copy.clone()], "left-pad")).await; + assert!(out.refs.is_empty(), "{case}: {:#?}", out.refs); + assert!( + out.diagnostics + .iter() + .any(|d| d.code == DIAG_REF_UNATTRIBUTABLE + && d.detail.contains("lp2@") + && d.detail.contains("UNPATCHED")), + "{case}: {:#?}", + out.diagnostics + ); + } + // The `file:` copies hold another package: not a copy of left-pad. + for (case, copy) in &copies[..2] { + let out = run(&project(&[wired.clone(), copy.clone()], "other-pkg")).await; + assert_refs( + &out, + &[("pkg:npm/left-pad@1.3.0", UUID_A, WiringMode::Vendored)], + ); + assert!(out.refs.len() == 1, "{case}"); + } + } + /// The vendored berry pair exactly as `vendor::yarn_berry_lock` writes it /// — spike B3's lock entry plus the root `package.json` `resolutions` /// value — for a plain and a scoped package. 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 372837d60..e06e3fdb3 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 @@ -6859,7 +6859,21 @@ "file": "yarn.lock" } ], - "live_claims": [] + "live_claims": [], + "unpatched_copies": [ + { + "purl": "pkg:npm/left-pad@1.3.0", + "file": "yarn.lock", + "key": "\"safe-pad@npm:left-pad@^1.3.0\"", + "how": "installs it from \"https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915ec1972a5f1bb07e\", not a Socket patch (yarn 1 installs one copy per name@version, from whichever block it resolves first)" + }, + { + "purl": "pkg:npm/totally-other@1.3.0", + "file": "yarn.lock", + "key": "\"left-pad@npm:totally-other@^1.3.0\"", + "how": "installs it from \"https://registry.yarnpkg.com/totally-other/-/totally-other-1.3.0.tgz#aaaabbbbccccddddeeeeffff0000111122223333\", not a Socket patch (yarn 1 installs one copy per name@version, from whichever block it resolves first)" + } + ] }, "redirect/npm/yarn-classic/basic/expected": { "refs": [ @@ -6909,7 +6923,15 @@ "file": "yarn.lock" } ], - "live_claims": [] + "live_claims": [], + "unpatched_copies": [ + { + "purl": "pkg:npm/left-pad@1.3.0", + "file": "yarn.lock", + "key": "left-pad@1.3.0", + "how": "installs it from \"https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915ec1972a5f1bb07e\", not a Socket patch (yarn 1 installs one copy per name@version, from whichever block it resolves first)" + } + ] }, "redirect/npm/yarn-classic/crlf/expected": { "refs": [ @@ -6951,6 +6973,14 @@ "uuid": "22222222-2222-2222-2222-222222222222", "purl": "pkg:npm/left-pad@1.3.0" } + ], + "unpatched_copies": [ + { + "purl": "pkg:npm/abbrev@1.1.1", + "file": "yarn.lock", + "key": "abbrev@^1.0.0", + "how": "installs it from \"https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8\", not a Socket patch (yarn 1 installs one copy per name@version, from whichever block it resolves first)" + } ] }, "redirect/npm/yarn-classic/crlf/input": { @@ -6968,6 +6998,20 @@ "file": "yarn.lock" } ], - "live_claims": [] + "live_claims": [], + "unpatched_copies": [ + { + "purl": "pkg:npm/abbrev@1.1.1", + "file": "yarn.lock", + "key": "abbrev@^1.0.0", + "how": "installs it from \"https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8\", not a Socket patch (yarn 1 installs one copy per name@version, from whichever block it resolves first)" + }, + { + "purl": "pkg:npm/left-pad@1.3.0", + "file": "yarn.lock", + "key": "left-pad@1.3.0", + "how": "installs it from \"https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915ec1972a5f1bb07e\", not a Socket patch (yarn 1 installs one copy per name@version, from whichever block it resolves first)" + } + ] } } From 7eda8d888b1c16c3ba8f7e6ce0e121f01f6b160d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 18:24:21 +0000 Subject: [PATCH 03/11] Route Gradle digests through utils::digest main has failed socket-patch-core's lib tests since Gradle support (#646) and the digest helpers (#865) both landed. The guard test production_digests_go_through_the_helpers flags three files #646 added that still hash inline: crawlers/gradle_cache.rs, patch/jvm_jar.rs and patch/sidecars/maven.rs. That breaks test, test-release and coverage on every open PR. Each inline sha1/sha256 call now goes through sha1_hex_of or sha256_hex_of, which compute the same lowercase hex. Behaviour is unchanged. Assisted-by: Claude Code:claude-opus-5-5 (cherry picked from commit 659ac2c24e5c5904e743b4bc98ea4645da2ed6a1) --- crates/socket-patch-core/src/crawlers/gradle_cache.rs | 9 ++++----- crates/socket-patch-core/src/patch/jvm_jar.rs | 7 ++----- crates/socket-patch-core/src/patch/sidecars/maven.rs | 4 +--- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/gradle_cache.rs b/crates/socket-patch-core/src/crawlers/gradle_cache.rs index ef295ee27..afd7c4fba 100644 --- a/crates/socket-patch-core/src/crawlers/gradle_cache.rs +++ b/crates/socket-patch-core/src/crawlers/gradle_cache.rs @@ -70,8 +70,7 @@ pub fn hash_eq(dir_name: &str, sha1_hex: &str) -> bool { /// Whether `bytes` are the pristine download Gradle stored in the hash /// directory `dir_name` (their sha1 names it). pub fn pristine(dir_name: &str, bytes: &[u8]) -> bool { - use sha1::{Digest, Sha1}; - hash_eq(dir_name, &hex::encode(Sha1::digest(bytes))) + hash_eq(dir_name, &crate::utils::digest::sha1_hex_of(bytes)) } /// Whether `path` is a version directory of a `files-2.1` tree @@ -432,8 +431,6 @@ impl DerivedIndex { /// The [`DerivedCopies`] of the jar `jar_leaf` whose pristine bytes /// hash to `pristine_sha1`. pub fn query(&self, jar_leaf: &str, pristine_sha1: &str) -> DerivedCopies { - use sha1::{Digest, Sha1}; - let instrumented = format!("instrumented-{jar_leaf}"); let mut out = DerivedCopies { incomplete: self.incomplete, @@ -460,7 +457,9 @@ impl DerivedIndex { out.stale.push(path.clone()); } else if name == jar_leaf || name == instrumented { match crate::utils::fs::read_regular_to_bytes_sync(path) { - Ok(bytes) if hash_eq(&hex::encode(Sha1::digest(&bytes)), pristine_sha1) => { + Ok(bytes) + if hash_eq(&crate::utils::digest::sha1_hex_of(&bytes), pristine_sha1) => + { out.stale.push(path.clone()) } Ok(_) => out.unknown.push(path.clone()), diff --git a/crates/socket-patch-core/src/patch/jvm_jar.rs b/crates/socket-patch-core/src/patch/jvm_jar.rs index 82d679406..f38a84403 100644 --- a/crates/socket-patch-core/src/patch/jvm_jar.rs +++ b/crates/socket-patch-core/src/patch/jvm_jar.rs @@ -25,8 +25,6 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use crate::crawlers::gradle_cache; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; @@ -353,12 +351,11 @@ fn unpatched_members( } fn sha256_hex(bytes: &[u8]) -> String { - use sha2::Digest as _; - hex::encode(sha2::Sha256::digest(bytes)) + crate::utils::digest::sha256_hex_of(bytes) } fn sha1_hex(bytes: &[u8]) -> String { - hex::encode(sha1::Sha1::digest(bytes)) + crate::utils::digest::sha1_hex_of(bytes) } /// `/jvm-originals/.jar`. diff --git a/crates/socket-patch-core/src/patch/sidecars/maven.rs b/crates/socket-patch-core/src/patch/sidecars/maven.rs index f2f5a2466..8798bfce6 100644 --- a/crates/socket-patch-core/src/patch/sidecars/maven.rs +++ b/crates/socket-patch-core/src/patch/sidecars/maven.rs @@ -17,8 +17,6 @@ use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use super::{ SidecarAdvisory, SidecarAdvisoryCode, SidecarError, SidecarFile, SidecarFileAction, SidecarPayload, SidecarSeverity, @@ -44,7 +42,7 @@ impl Algo { fn digest(self, bytes: &[u8]) -> String { match self { - Algo::Sha1 => hex::encode(sha1::Sha1::digest(bytes)), + Algo::Sha1 => crate::utils::digest::sha1_hex_of(bytes), Algo::Md5 => hex::encode(md5(bytes)), } } From 980b7b628641e7195adfbc37609b978d766937a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 14:10:37 +0000 Subject: [PATCH 04/11] Drop quadratic dedup of same-lock copies Every non-Socket yarn classic block is now recorded as a possible unpatched copy, and each record scanned the whole list for a duplicate first. On a 3000-package lock that made hosted scans and rescans about 17% slower in the scan benchmark. The list is already sorted and deduplicated once when discovery finishes, so the per-record scan goes. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/vex/discover/mod.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index 7cc525045..b07db48b0 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -615,15 +615,14 @@ impl Discovery { return; }; self.resolved_elsewhere(file, Some(purl.clone())); - let copy = UnpatchedCopy { + // Deduplicated once, in `finalize`: a per-push scan is quadratic + // over a lock with thousands of registry blocks. + self.unpatched_copies.push(UnpatchedCopy { purl: canonical_base_purl(&purl), file: PathBuf::from(file), key: key.to_string(), how: how.to_string(), - }; - if !self.unpatched_copies.contains(©) { - self.unpatched_copies.push(copy); - } + }); } /// Drop every ref whose OWN lock also installs an unpatched copy of the From 176327d10bf4f31769475985487c767910f6823d Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 09:48:36 -0400 Subject: [PATCH 05/11] Stop vex attesting a hosted pin over a stale yarn PnP install (#519) Standalone vex excused a hosted npm purl the crawler could not find (package_not_found) by attesting it from the lock's integrity pin. Under yarn Plug'n'Play the crawler cannot look at all: packages are zips the .pnp loader resolves. A hosted lock pulled over an existing PnP install (the loader still resolving the registry copy) was attested not_affected while the running copy stayed unpatched. The excuse now treats a yarn PnP project like the pnpm out-of-project store: the lock basis applies only when the loader itself resolves the package through the patch. yarn berry keeps each locator's reference verbatim in .pnp.cjs (or .pnp.data.json), so the hosted url's uuid is in the text after a fresh install (checked against real yarn 4.12.0 and the real-yarn pnp-linker cell). yarn classic names the cache folder npm---, whose hash is the resolved url's #sha1 fragment. A stale loader names neither and the purl is omitted. PnP detection reuses detect_npm_pkg_manager, so the linker-aware detection in #978 applies here unchanged once it lands. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/src/commands/vex.rs | 21 ++- .../tests/e2e_vex_lockfile/yarn.rs | 80 ++++++++--- crates/socket-patch-core/src/crawlers/mod.rs | 2 +- .../src/crawlers/pkg_managers.rs | 130 ++++++++++++++++++ docs/testing/yarn-berry-compatibility.md | 5 +- 5 files changed, 218 insertions(+), 20 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 01402de52..53ba12bf3 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -687,7 +687,26 @@ async fn generate_vex( // unpatched transitive dep (#696). let npm_store_hidden = socket_patch_core::crawlers::npm_crawler::pnpm_store_outside_project(&common.cwd); - let hidden = |purl: &str| npm_store_hidden && purl.starts_with("pkg:npm/"); + // + // Nor can it look inside a yarn Plug'n'Play install: the packages + // are zips the loader resolves, so an npm purl not found may still + // run from the cache. The lock's pin is evidence only when the + // loader itself resolves the package through that patch (a fresh + // install of the hosted lock); a loader written before the lock was + // rewired runs the registry copy (#519). + let pnp_loader = socket_patch_core::crawlers::YarnPnpLoader::detect(&common.cwd); + let pnp_unconsumed = |purl: &str| { + pnp_loader.as_ref().is_some_and(|loader| { + !plan.hosted.get(purl).is_some_and(|wiring| { + loader.resolves_patch( + &wiring.uuid, + wiring.refs.iter().filter_map(|r| r.url.as_deref()), + ) + }) + }) + }; + let hidden = + |purl: &str| purl.starts_with("pkg:npm/") && (npm_store_hidden || pnp_unconsumed(purl)); let mut lockfile_attested = Vec::new(); outcome.failed.retain(|f| { let excused = f.reason == "package_not_found" diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs index d547e4d72..47694d8ef 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs @@ -1137,40 +1137,86 @@ fn pin_spellings_and_shadowed_blocks() { /// surface does with a PnP checkout that nonetheless carries Socket lock /// wiring (hand-made, or left behind by a linker switch): /// -/// * standalone `vex`, hosted: there is no crawlable installed tree, so the -/// ref is "not installed" and attests on the lock's integrity pin exactly -/// like a lockfile-only checkout (design D5 — yarn enforces the berry -/// `checksum:` on every fetch into the zip cache). Berry 2/3's bare-hex -/// checksum is no pin (module doc), so those are omitted -/// (`package_not_found`) — never a false attestation. +/// * standalone `vex`, hosted: there is no crawlable installed tree, so +/// "not found" does not mean "not installed". The lock's integrity pin +/// attests (design D5 — yarn enforces the berry `checksum:` on every +/// fetch into the zip cache) only when the PnP loader itself resolves the +/// package through the patch (a fresh install of the hosted lock: berry +/// names the hosted url, classic the resolved url's hash). A loader +/// written before the lock was rewired runs the registry copy, so the +/// purl is omitted (`package_not_found`, #519). Berry 2/3's bare-hex +/// checksum is no pin (module doc), so those are omitted either way — +/// never a false attestation. /// * standalone `vex`, vendored: the committed tarball is the evidence /// whatever the linker; PnP consumes the same `file:` artifact. /// * `apply --vex`: apply refuses a PnP layout outright, manifest or not /// (`yarn_pnp_unsupported`, exit 1, no document). #[test] fn pnp_layout_contract() { - for flavor in [Flavor::Berry2, Flavor::Berry3, Flavor::Berry4] { - for mode in ["hosted", "vendored"] { + for flavor in FLAVORS { + for mode in ["hosted", "hosted-stale", "vendored"] { + if mode == "vendored" && !flavor.is_berry() { + continue; + } let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); - if mode == "hosted" { + if mode == "vendored" { + vendored_checkout(cwd, flavor, PATCHED); + } else { hosted_checkout(cwd, flavor); + } + let fresh = mode != "hosted-stale"; + if flavor.is_berry() { + put( + cwd, + ".yarnrc.yml", + b"nodeLinker: pnp\nenableGlobalCache: false\n", + ); + // The package registry of `.pnp.cjs`: a fresh install of the + // hosted lock names the hosted locator, an install from + // before the rewire the registry one. + let reference = if fresh { + format!( + "npm:1.3.0::__archiveUrl={}", + encode_uri_component(&berry_hosted_url("https://patch.socket.dev", UUID)) + ) + } else { + "npm:1.3.0".to_string() + }; + put( + cwd, + ".pnp.cjs", + format!( + "/* yarn PnP loader */\n[\"left-pad\", [[\"{reference}\", \ + {{\"packageLocation\": \"./.yarn/cache/left-pad.zip/node_modules/left-pad/\"}}]]]\n" + ) + .as_bytes(), + ); } else { - vendored_checkout(cwd, flavor, PATCHED); + // yarn 1 (`installConfig.pnp`): `.pnp.js` names the cache + // folder, whose hash is the resolved url's fragment. + let hash = if fresh { + "abcdef0123456789abcdef0123456789abcdef01" + } else { + "5b8a3a7765dfe001261dde915589e782f8c94d1e" + }; + put( + cwd, + ".pnp.js", + format!( + "/* yarn PnP loader */\npackageLocation: \ + \"/home/u/.cache/yarn/v6/npm-left-pad-1.3.0-{hash}-integrity/node_modules/left-pad/\"\n" + ) + .as_bytes(), + ); } - put( - cwd, - ".yarnrc.yml", - b"nodeLinker: pnp\nenableGlobalCache: false\n", - ); - put(cwd, ".pnp.cjs", b"/* yarn PnP loader */\n"); assert!(!cwd.join("node_modules").exists()); let (_rt, server) = serve_left_pad(); let (code, env) = vex_json(cwd, &["--proxy-url", &server.uri()]); let cell = format!("{flavor:?} {mode} PnP vex"); if mode == "vendored" { assert_attested(cwd, code, &env, UUID, "vendored", &cell); - } else if flavor.hosted_pin_is_read() { + } else if fresh && flavor.hosted_pin_is_read() { assert_attested(cwd, code, &env, UUID, "redirected", &cell); } else { assert_omitted(cwd, code, &env, "package_not_found", &cell); diff --git a/crates/socket-patch-core/src/crawlers/mod.rs b/crates/socket-patch-core/src/crawlers/mod.rs index e85731c8d..25f437e24 100644 --- a/crates/socket-patch-core/src/crawlers/mod.rs +++ b/crates/socket-patch-core/src/crawlers/mod.rs @@ -30,7 +30,7 @@ pub use go_crawler::GoCrawler; pub use maven_crawler::MavenCrawler; pub use npm_crawler::NpmCrawler; pub use nuget_crawler::NuGetCrawler; -pub use pkg_managers::{detect_npm_pkg_manager, NpmPkgManager}; +pub use pkg_managers::{detect_npm_pkg_manager, NpmPkgManager, YarnPnpLoader}; pub use python_crawler::PythonCrawler; pub use ruby_crawler::RubyCrawler; pub use types::*; diff --git a/crates/socket-patch-core/src/crawlers/pkg_managers.rs b/crates/socket-patch-core/src/crawlers/pkg_managers.rs index 8ae536e64..cbc05fda5 100644 --- a/crates/socket-patch-core/src/crawlers/pkg_managers.rs +++ b/crates/socket-patch-core/src/crawlers/pkg_managers.rs @@ -191,10 +191,140 @@ pub(crate) fn pnpm_pnp_layout(project_root: &Path) -> bool { && !project_root.join("yarn.lock").is_file() } +/// The yarn Plug'n'Play loader of a project: the text of each loader file +/// the install wrote (the [`PNP_MARKERS`] plus berry's `.pnp.data.json`, +/// written when `pnpEnableInlining: false`). +/// +/// [`PNP_MARKERS`]: crate::constants::npm_family::PNP_MARKERS +/// +/// The npm crawler cannot look inside a PnP install (the packages are +/// zips in a cache), so "the crawler found no copy" says nothing there. +/// What the loader CAN say is which locator each package resolved to when +/// it was written — the evidence `vex` needs before it trusts a lock's +/// hosted pin over a PnP install (#519). +#[derive(Debug, Clone, Default)] +pub struct YarnPnpLoader { + texts: Vec, +} + +impl YarnPnpLoader { + /// The loader at `project_root`, or `None` unless the project is a + /// yarn PnP layout ([`NpmPkgManager::YarnBerryPnP`] — pnpm's own + /// `node-linker=pnp` tree is not). A loader file that cannot be read + /// contributes nothing, so it never vouches for a patch. + pub fn detect(project_root: &Path) -> Option { + if detect_npm_pkg_manager(project_root) != NpmPkgManager::YarnBerryPnP { + return None; + } + let texts = crate::constants::npm_family::PNP_MARKERS + .iter() + .chain(std::iter::once(&".pnp.data.json")) + .filter_map(|name| { + crate::utils::fs::read_regular_to_string_sync(&project_root.join(name)).ok() + }) + .collect(); + Some(YarnPnpLoader { texts }) + } + + /// Whether the loader resolves a package through Socket patch `uuid`, + /// fetched from the hosted `urls`. + /// + /// * yarn berry keeps each locator's reference verbatim in the package + /// registry, so a package installed from the hosted tarball names its + /// url (`/…//-.tgz`, or the legacy + /// percent-encoded `npm:::__archiveUrl=` binding): the uuid, which + /// encoding never changes (hex and `-`), is in the text. + /// * yarn classic names only the cache folder, + /// `npm---`, whose hash is the `#` + /// fragment of the lock's `resolved` url — the patched tarball's, not + /// the registry's. + /// + /// A loader written before the lock was rewired (a pulled hosted lock + /// over an old install) names the registry copy instead, so this is + /// `false` and the copy the loader runs is not the patched one. + pub fn resolves_patch<'a>(&self, uuid: &str, urls: impl IntoIterator) -> bool { + let mut marks = vec![uuid.to_string()]; + marks.extend(urls.into_iter().filter_map(|url| { + let (_, hash) = url.rsplit_once('#')?; + (hash.len() >= 40 && hash.bytes().all(|b| b.is_ascii_hexdigit())) + .then(|| format!("-{hash}")) + })); + !uuid.is_empty() + && self + .texts + .iter() + .any(|text| marks.iter().any(|mark| text.contains(mark.as_str()))) + } +} + #[cfg(test)] mod tests { use super::*; + /// A yarn PnP loader vouches for a patch only when it names it: berry + /// by the hosted url's uuid, classic by the resolved url's hash. A + /// stale loader (the registry locator), an unreadable or empty one, or + /// a non-PnP project never does (#519). + #[test] + fn yarn_pnp_loader_resolves_patch_only_when_it_names_it() { + const UUID: &str = "4d5e6f70-8192-4a3b-9c4d-5e6f70819243"; + const HASH: &str = "88e54a85256e9d1b6ff92cf972a12f91ba21d4da"; + let url = format!("https://patch.socket.dev/patch/npm/t/{UUID}/left-pad-1.3.0.tgz"); + let classic_url = format!("{url}#{HASH}"); + + let d = tempfile::tempdir().unwrap(); + assert!(YarnPnpLoader::detect(d.path()).is_none(), "not PnP"); + + // berry, fresh install: the registry names the hosted locator. + std::fs::write( + d.path().join(".pnp.cjs"), + format!("[\"left-pad\", [[\"{url}\", {{packageLocation: \"./.yarn/cache/x.zip\"}}]]]"), + ) + .unwrap(); + let loader = YarnPnpLoader::detect(d.path()).expect("berry PnP"); + assert!(loader.resolves_patch(UUID, [url.as_str()])); + assert!(!loader.resolves_patch("5e6f7081-92a3-4b4c-8d5e-6f7081920354", [])); + assert!(!loader.resolves_patch("", [])); + + // berry, stale install: the registry locator only. + std::fs::write( + d.path().join(".pnp.cjs"), + "[\"left-pad\", [[\"npm:1.3.0\", {packageLocation: \"./.yarn/cache/x.zip\"}]]]", + ) + .unwrap(); + let loader = YarnPnpLoader::detect(d.path()).unwrap(); + assert!(!loader.resolves_patch(UUID, [url.as_str()])); + + // berry with pnpEnableInlining: false — the data file names it. + std::fs::write( + d.path().join(".pnp.data.json"), + format!("{{\"r\":\"{url}\"}}"), + ) + .unwrap(); + assert!(YarnPnpLoader::detect(d.path()) + .unwrap() + .resolves_patch(UUID, [])); + + // classic: the cache folder carries the resolved url's hash. + let c = tempfile::tempdir().unwrap(); + std::fs::write( + c.path().join(".pnp.js"), + format!("packageLocation: \"/c/v6/npm-left-pad-1.3.0-{HASH}-integrity/\""), + ) + .unwrap(); + let loader = YarnPnpLoader::detect(c.path()).expect("classic PnP"); + assert!(loader.resolves_patch(UUID, [classic_url.as_str()])); + assert!( + !loader.resolves_patch(UUID, [url.as_str()]), + "no hash to match" + ); + let registry = "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e"; + assert!( + !loader.resolves_patch(UUID, [registry]), + "stale: another hash" + ); + } + #[test] fn unknown_for_empty_dir() { let d = tempfile::tempdir().unwrap(); diff --git a/docs/testing/yarn-berry-compatibility.md b/docs/testing/yarn-berry-compatibility.md index 4ea792d22..516313011 100644 --- a/docs/testing/yarn-berry-compatibility.md +++ b/docs/testing/yarn-berry-compatibility.md @@ -11,7 +11,10 @@ vendored (`vendor` wires the root linkers are covered end to end; Plug'n'Play keeps packages inside `.yarn/cache` zips, so `vendor` refuses it (`vendor_yarn_berry_unsupported`) and so does `apply` (`yarn_pnp_unsupported`), while standalone `vex` still -attests a hosted lock's `checksum:` pin. +attests a hosted lock's `checksum:` pin once the PnP loader (`.pnp.cjs`) +resolves the package through the hosted url. A loader written before the lock +was rewired still runs the registry copy, so `vex` omits the package +(`package_not_found`) until `yarn install` rewrites it (#519). ## Hosted pin shape and registry credentials From a0f253a5e0c692c1837ae03c8f31616c2829c26e Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 10:55:52 -0400 Subject: [PATCH 06/11] Contest a pnpm ref its lock also bundles (audit B04) pnpm unpacks a package's bundledDependencies from that package's own tarball into its store directory and never locks them, so no Socket wiring reaches that copy. The npm, bun and vlt VEX extractors already contest a ref beside a bundled copy; the pnpm extractor had no bundled handling at all, so a lockfile-only vex attested not_affected while the bundled copy installed unpatched. formats/pnpm/grammar gains entry_bundled, which reads the bundledDependencies field in the shapes real pnpm writes (block list, true; checked with pnpm 11.27) plus a flow list, failing closed on any other value. The pnpm extractor feeds each bundled name into the shared same-lock rule (Discovery::unpatched_copy, from #940). The lock does not record a bundled copy's version, so a ref of the same name is contested whatever its version (a missed attestation, never a false one), and bundledDependencies: true contests every ref of that lock. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/formats/pnpm/grammar.rs | 94 ++++++++++ .../socket-patch-core/src/formats/pnpm/mod.rs | 4 +- .../socket-patch-core/src/vex/discover/npm.rs | 168 +++++++++++++++++- 3 files changed, 261 insertions(+), 5 deletions(-) diff --git a/crates/socket-patch-core/src/formats/pnpm/grammar.rs b/crates/socket-patch-core/src/formats/pnpm/grammar.rs index 4b84251dd..b23e184ce 100644 --- a/crates/socket-patch-core/src/formats/pnpm/grammar.rs +++ b/crates/socket-patch-core/src/formats/pnpm/grammar.rs @@ -102,6 +102,56 @@ pub(crate) fn entry_field<'a>(entry: &Entry<'a>, field: &str) -> Option<&'a str> values.next().is_none().then_some(first) } +/// What a packages entry's `bundledDependencies:` field says the package +/// ships inside its own tarball (see [`entry_bundled`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Bundled<'a> { + /// The named dependencies (a block or flow list). + Names(Vec<&'a str>), + /// Every dependency (`bundledDependencies: true`), or a value the + /// grammar cannot read: pnpm does not lock bundled dependencies, so + /// which names these are is not in the lock. + All, +} + +/// The entry-level `bundledDependencies:` field of a packages entry, as +/// real pnpm writes it (checked with pnpm 11.27, lockfile 9.0): a block +/// list (` - left-pad`), a flow list, or `true`. pnpm never resolves a +/// bundled dependency, so the lock has no entry for it, and its version is +/// whatever the parent's tarball carries. `None` when the field is absent, +/// `false` or an empty list; a value this grammar cannot read is +/// [`Bundled::All`] (fail closed). +pub(crate) fn entry_bundled<'a>(entry: &Entry<'a>) -> Option> { + let mut lines = entry.body.lines().map(|line| line.trim_end_matches('\r')); + let value = lines.by_ref().find_map(|line| { + let rest = line.strip_prefix(" ").filter(|r| !r.starts_with(' '))?; + let (key, value) = rest.split_once(':')?; + matches!(key, "bundledDependencies" | "bundleDependencies").then(|| value.trim()) + })?; + let names: Vec<&str> = match value { + "false" => return None, + "true" => return Some(Bundled::All), + "" => lines + .take_while(|line| line.starts_with(" ")) + .map(|line| line.trim_start().strip_prefix("- ").map(str::trim)) + .collect::>() + .unwrap_or_else(|| vec![""]), + flow => match flow.strip_prefix('[').and_then(|f| f.strip_suffix(']')) { + Some(inner) => inner + .split(',') + .map(str::trim) + .filter(|name| !name.is_empty()) + .collect(), + None => vec![""], + }, + }; + let names: Vec<&str> = names.into_iter().map(unquote).collect(); + if names.iter().any(|name| name.is_empty()) { + return Some(Bundled::All); + } + (!names.is_empty()).then_some(Bundled::Names(names)) +} + /// Loose identity match, also used to refuse unsupported suffixes atomically. pub(crate) fn suffix<'a>(key: &'a str, name: &str, version: &str) -> Option<&'a str> { let key = unquote(key); @@ -355,3 +405,47 @@ pub(crate) fn resolution<'a>(entry: &Entry<'a>) -> Option> { } None } + +#[cfg(test)] +mod tests { + use super::*; + + /// `entry_bundled` of a lock whose first packages entry ends in `field`. + fn assert_bundled(field: &str, expected: Option>) { + let lock = format!( + "lockfileVersion: '9.0'\n\npackages:\n\n host@1.0.0:\n resolution: \ + {{integrity: sha512-AA==}}\n version: 1.0.0\n{field}\n other@1.0.0:\n \ + resolution: {{integrity: sha512-BB==}}\n" + ); + let entries = entries(&lock); + assert_eq!(entry_bundled(&entries[0]), expected, "{field:?}"); + } + + /// Every spelling of `bundledDependencies:` real pnpm writes (block + /// list, `true`) plus the flow list and quoted names; an unreadable + /// value fails closed to [`Bundled::All`]. + #[test] + fn entry_bundled_reads_every_spelling() { + let names = |n: Vec<&'static str>| Some(Bundled::Names(n)); + assert_bundled("", None); + assert_bundled( + " bundledDependencies:\n - left-pad\n - '@s/x'", + names(vec!["left-pad", "@s/x"]), + ); + assert_bundled( + " bundledDependencies:\r\n - left-pad\r", + names(vec!["left-pad"]), + ); + assert_bundled( + " bundledDependencies: [left-pad, '@s/x']", + names(vec!["left-pad", "@s/x"]), + ); + assert_bundled(" bundledDependencies: true", Some(Bundled::All)); + assert_bundled(" bundledDependencies: false", None); + assert_bundled(" bundledDependencies: []", None); + // Not a list: fail closed. + assert_bundled(" bundledDependencies: left-pad", Some(Bundled::All)); + // A nested field of the same name is not the entry's. + assert_bundled(" engines:\n bundledDependencies: true", None); + } +} diff --git a/crates/socket-patch-core/src/formats/pnpm/mod.rs b/crates/socket-patch-core/src/formats/pnpm/mod.rs index c7d47c1f2..9da1c83d6 100644 --- a/crates/socket-patch-core/src/formats/pnpm/mod.rs +++ b/crates/socket-patch-core/src/formats/pnpm/mod.rs @@ -29,7 +29,9 @@ pub(crate) mod hosted; pub(crate) mod lines; pub(crate) mod workspace; -pub(crate) use grammar::{entry_field, is_pnpm_lock_text, Entry, Resolution}; +pub(crate) use grammar::{ + entry_bundled, entry_field, is_pnpm_lock_text, Bundled, Entry, Resolution, +}; pub(crate) use hosted::plan_hosted; use std::collections::HashSet; diff --git a/crates/socket-patch-core/src/vex/discover/npm.rs b/crates/socket-patch-core/src/vex/discover/npm.rs index c130efa95..a16375fdb 100644 --- a/crates/socket-patch-core/src/vex/discover/npm.rs +++ b/crates/socket-patch-core/src/vex/discover/npm.rs @@ -45,13 +45,14 @@ use std::collections::{BTreeMap, BTreeSet}; use serde_json::Value; use super::{ - npm_purl, npm_vendored_tarball_names, parse_json, vendor_ref, DiscoverCtx, Discovery, - LocateOpts, Located, PatchedRef, VendorRef, DIAG_LOCKFILE_UNPARSEABLE, DIAG_REF_INVALID, - DIAG_REF_UNATTRIBUTABLE, + canonical_base_purl, npm_purl, npm_vendored_tarball_names, parse_json, vendor_ref, DiscoverCtx, + Discovery, LocateOpts, Located, PatchedRef, VendorRef, DIAG_LOCKFILE_UNPARSEABLE, + DIAG_REF_INVALID, DIAG_REF_UNATTRIBUTABLE, }; use crate::constants::npm_family::{NPM_LOCKS, PNPM_LOCK, PNPM_SHRINKWRAP_LEGACY}; use crate::formats::pnpm::{ - classify_pnpm_key, entry_field, pnpm_registry_key, PnpmKey, PnpmLock, PnpmPackage, + classify_pnpm_key, entry_bundled, entry_field, pnpm_registry_key, Bundled, PnpmKey, PnpmLock, + PnpmPackage, }; use crate::utils::digest::is_sri_pin; use crate::vendor::lock_inventory::pnpm::rush_lock_rels; @@ -573,10 +574,72 @@ async fn extract_pnpm_lock(ctx: &DiscoverCtx<'_>, file: &str, out: &mut Discover return; } let mut copies: Vec = Vec::new(); + let mut bundles: Vec<(&str, Bundled<'_>)> = Vec::new(); for package in lock.packages() { pnpm_entry_ref(ctx, file, package, &mut copies, out); + bundles.extend(entry_bundled(&package.entry).map(|b| (package.key, b))); } record_pnpm_file_copies(ctx, file, copies, out).await; + record_pnpm_bundled_copies(file, &bundles, out); +} + +/// Record the bundled copies a pnpm lock installs as unpatched copies of +/// every ref of the same lock they may hold. pnpm unpacks a package's +/// `bundledDependencies` from its own tarball into its store directory +/// (`node_modules/.pnpm//node_modules//node_modules/`) +/// and never resolves them, so no Socket wiring of the lock reaches that +/// copy — the npm, bun and vlt extractors already contest the same case +/// from their locks' bundled entries. The lock names the bundled package +/// but not its version (that lives in the parent's tarball), so a ref of +/// the same NAME is contested whatever its version: a missed attestation +/// when the bundled copy is another version, never a false one. +/// `bundledDependencies: true` bundles every dependency of the parent, +/// which the lock does not list, so it contests every ref of the lock. +fn record_pnpm_bundled_copies(file: &str, bundles: &[(&str, Bundled<'_>)], out: &mut Discovery) { + if bundles.is_empty() { + return; + } + let lock_refs: Vec = out + .refs + .iter() + .filter(|r| r.source_file == std::path::Path::new(file)) + .map(|r| r.purl.clone()) + .collect(); + for purl in lock_refs { + let Some((_, version)) = crate::utils::purl::purl_name_version(&purl) else { + continue; + }; + let target = canonical_base_purl(&purl); + for (parent, bundled) in bundles { + let names = match bundled { + Bundled::All => None, + Bundled::Names(names) => Some(names), + }; + let holds = names.is_none_or(|names| { + names.iter().any(|name| { + npm_purl(name, version).is_some_and(|p| canonical_base_purl(&p) == target) + }) + }); + if !holds { + continue; + } + let what = if names.is_some() { + "a bundled copy of it" + } else { + "every dependency as a bundled copy (`bundledDependencies: true`)" + }; + out.unpatched_copy( + file, + Some(purl.clone()), + parent, + &format!( + "ships {what}, which pnpm unpacks from that package's own tarball \ + (the lock does not record its version) and no Socket wiring reaches" + ), + ); + break; + } + } } /// A pnpm `packages:` entry installed from a user's `file:` directory or @@ -2159,6 +2222,103 @@ mod tests { ); } + /// pnpm unpacks a package's `bundledDependencies` from its own tarball + /// and never locks them, so a bundled copy of the wired package stays + /// unpatched beside the Socket wiring (audit B04; npm, bun and vlt + /// already contest it). The lock does not record the bundled version, + /// so a ref of the same name is contested; a bundle of another name + /// is not. Hosted and vendored refs alike, v9 and legacy keys. + #[tokio::test] + async fn pnpm_bundled_copy_contests_the_ref() { + let url = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); + let wired = + format!(" left-pad@1.3.0:\n resolution: {{integrity: {SRI}, tarball: {url}}}\n\n"); + let host = |key: &str, field: &str| { + format!(" {key}:\n resolution: {{integrity: sha512-HOST==}}\n{field}\n\n") + }; + let lock = |extra: &str| format!("lockfileVersion: '9.0'\n\npackages:\n\n{wired}{extra}"); + let hosted = [("pkg:npm/left-pad@1.3.0", UUID_A, WiringMode::Hosted)]; + + // Controls: no bundle, a bundle of another name, `false`. + for extra in [ + String::new(), + host( + "host-pkg@1.0.0", + " bundledDependencies:\n - right-pad", + ), + host("host-pkg@1.0.0", " bundledDependencies: false"), + ] { + let p = Project::new(); + p.write("pnpm-lock.yaml", lock(&extra)); + assert_refs(&run(&p).await, &hosted); + } + + for (case, text) in [ + ( + "v9 block list", + lock(&host( + "host-pkg@1.0.0", + " bundledDependencies:\n - left-pad", + )), + ), + ( + "v9 flow list", + lock(&host( + "host-pkg@1.0.0", + " bundledDependencies: [left-pad]", + )), + ), + ( + "v9 true", + lock(&host("host-pkg@1.0.0", " bundledDependencies: true")), + ), + ( + "v6 key", + format!( + "lockfileVersion: '6.0'\n\npackages:\n\n /left-pad@1.3.0:\n \ + resolution: {{integrity: {SRI}, tarball: {url}}}\n dev: false\n\n{}", + host( + "/host-pkg@1.0.0", + " bundledDependencies:\n - left-pad" + ) + ), + ), + ] { + let p = Project::new(); + p.write("pnpm-lock.yaml", text); + let out = run(&p).await; + assert!(out.refs.is_empty(), "{case}: {:#?}", out.refs); + assert!( + out.diagnostics + .iter() + .any(|d| d.code == DIAG_REF_UNATTRIBUTABLE + && d.detail.contains("host-pkg@1.0.0") + && d.detail.contains("bundled") + && d.detail.contains("UNPATCHED")), + "{case}: {:#?}", + out.diagnostics + ); + } + + // A vendored ref is contested the same way. + let p = Project::new(); + let rel = format!(".socket/vendor/npm/{UUID_A}/left-pad-1.3.0.tgz"); + p.write(&rel, npm_tgz("left-pad", "1.3.0")); + p.write( + "pnpm-lock.yaml", + format!( + "lockfileVersion: '9.0'\n\npackages:\n\n left-pad@file:{rel}:\n \ + resolution: {{integrity: {SRI}, tarball: file:{rel}}}\n version: 1.3.0\n\n{}", + host( + "host-pkg@1.0.0", + " bundledDependencies:\n - left-pad" + ) + ), + ); + let out = run(&p).await; + assert!(out.refs.is_empty(), "vendored: {:#?}", out.refs); + } + /// The committed golden (TS backend output — what a depscan PR leaves). #[tokio::test] async fn pnpm_golden_hosted_fixture() { From 5e5d88d700a54fe6d7177f27208b09b086cf1185 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 10:55:52 -0400 Subject: [PATCH 07/11] Read deno.lock as a contesting lock in VEX discovery (#406) Deno installs a package.json project's npm dependencies from deno.lock and never reads package-lock.json. The deno extractor was deliberately empty, so a hosted pin in package-lock.json beside a deno.lock registry entry of the same version attested not_affected from the lockfile basis while Deno ran the unpatched copy. The extractor now reads deno.lock's npm section (top-level npm in lockfile v4/v5, npm.packages in v2, packages.npm in v3; Deno's _peer suffix stripped) as resolved_elsewhere evidence, so the existing cross-lock contest drops the npm-family ref with a diagnostic naming both files. The read is advisory: deno.lock still never yields a ref, and an unreadable or unparseable lock contests nothing. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/vex/discover/deno.rs | 123 +++++++++++++++++- 1 file changed, 121 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-core/src/vex/discover/deno.rs b/crates/socket-patch-core/src/vex/discover/deno.rs index 9b680aaf2..9a3d248b9 100644 --- a/crates/socket-patch-core/src/vex/discover/deno.rs +++ b/crates/socket-patch-core/src/vex/discover/deno.rs @@ -9,15 +9,63 @@ //! a user's own import. The extractor exists so the per-ecosystem coverage is //! explicit and a future backend has an obvious home; Deno patches attest //! only through the manifest + installed tree (agent mode, `setup.manual`). +//! +//! It still reads `deno.lock`'s npm section as a CONTESTING lock: Deno +//! installs a `package.json` project's npm dependencies from `deno.lock` +//! and never reads `package-lock.json`, so a Socket pin in the npm lock +//! does not reach the copy Deno runs. Every `name@version` there is +//! [`Discovery::resolved_elsewhere`] evidence, and the cross-lock contest +//! drops an npm-family ref of the same version (#406). The npm section is +//! the top-level `npm` map in lockfile versions 4 and 5, `npm.packages` +//! in version 2 and `packages.npm` in version 3; a key may carry Deno's +//! peer suffix (`name@1.0.0_peer@2.0.0`). The read is advisory: an +//! unreadable or unparseable `deno.lock` contests nothing. + +use serde_json::Value; + +use super::{npm_purl, parse_json, DiscoverCtx, Discovery}; -use super::{DiscoverCtx, Discovery}; +const DENO_LOCK: &str = "deno.lock"; -pub(crate) async fn extract(_ctx: &DiscoverCtx<'_>, _out: &mut Discovery) {} +pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { + let Some(text) = ctx.read_advisory_text(DENO_LOCK).await else { + return; + }; + let Ok(lock) = parse_json(DENO_LOCK, text.as_bytes()) else { + return; + }; + for key in deno_npm_keys(&lock) { + out.resolved_elsewhere(DENO_LOCK, deno_npm_purl(key)); + } +} + +/// The keys of `deno.lock`'s npm package map, in any lockfile version. +fn deno_npm_keys(lock: &Value) -> Vec<&str> { + let npm = lock.get("npm"); + let map = npm + .and_then(|n| n.get("packages")) + .or(npm) + .or_else(|| lock.get("packages").and_then(|p| p.get("npm"))) + .and_then(Value::as_object); + map.map(|m| m.keys().map(String::as_str).collect()) + .unwrap_or_default() +} + +/// The purl of one npm package key (`name@version`, `@scope/name@version`, +/// either with Deno's `_peer@x` suffix). +fn deno_npm_purl(key: &str) -> Option { + let at = key.get(1..)?.find('@')? + 1; + let (name, version) = (&key[..at], &key[at + 1..]); + let version = version.split('_').next()?; + npm_purl(name, version) +} #[cfg(test)] mod tests { use super::super::testing::*; + const SRI: &str = "sha512-PATCHEDpatchedPATCHEDpatched0123456789=="; + /// Even a lock that names a patch-server url yields nothing. #[tokio::test] async fn deno_lock_never_yields_refs() { @@ -33,4 +81,75 @@ mod tests { let all = p.discover().await; assert!(all.refs.is_empty(), "{:?}", all.refs); } + + #[test] + fn deno_npm_keys_cover_every_lock_version() { + let keys = |text: &str| { + let lock: serde_json::Value = serde_json::from_str(text).unwrap(); + super::deno_npm_keys(&lock) + .into_iter() + .filter_map(super::deno_npm_purl) + .collect::>() + }; + let want = vec!["pkg:npm/left-pad@1.3.0".to_string()]; + // v4 / v5 (deno 2.9 writes this), v3, v2. + assert_eq!(keys(r#"{"version":"5","npm":{"left-pad@1.3.0":{}}}"#), want); + assert_eq!( + keys(r#"{"version":"3","packages":{"npm":{"left-pad@1.3.0":{}}}}"#), + want + ); + assert_eq!( + keys(r#"{"version":"2","npm":{"specifiers":{},"packages":{"left-pad@1.3.0":{}}}}"#), + want + ); + assert_eq!(keys(r#"{"version":"5"}"#), Vec::::new()); + assert_eq!( + super::deno_npm_purl("@types/node@20.0.0"), + super::super::npm_purl("@types/node", "20.0.0") + ); + assert_eq!( + super::deno_npm_purl("left-pad@1.3.0_react@18.2.0"), + super::super::npm_purl("left-pad", "1.3.0") + ); + assert_eq!(super::deno_npm_purl("nonsense"), None); + } + + /// REGRESSION (#406): Deno installs a `package.json` project's npm deps + /// from `deno.lock` and never reads `package-lock.json`, so a hosted pin + /// in the npm lock beside a deno.lock registry entry of the same version + /// is contested, not attested. Another version in deno.lock is not. + #[tokio::test] + async fn deno_lock_contests_an_npm_lock_pin_of_the_same_version() { + let url = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); + let npm_lock = format!( + r#"{{"name":"m","lockfileVersion":3,"packages":{{"":{{"name":"m"}}, + "node_modules/left-pad":{{"version":"1.3.0","resolved":"{url}","integrity":"{SRI}"}}}}}}"# + ); + for (deno_version, contested) in [("1.3.0", true), ("1.2.0", false)] { + let p = Project::new(); + p.write("package-lock.json", npm_lock.clone()); + p.write( + "deno.lock", + format!( + r#"{{"version":"5","npm":{{"left-pad@{deno_version}":{{"integrity":"sha512-X=="}}}}}}"# + ), + ); + let out = p.discover().await; + assert_eq!( + out.refs.is_empty(), + contested, + "{deno_version}: {:#?}", + out.refs + ); + if contested { + assert!( + out.diagnostics + .iter() + .any(|d| d.detail.contains("deno.lock")), + "{:#?}", + out.diagnostics + ); + } + } + } } From 7fb32e8fbc5d6b256364602679ab09ab769ced94 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 10:55:52 -0400 Subject: [PATCH 08/11] Document the PnP, pnpm bundled and deno.lock VEX rules Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 4cee4eac5..ebe74e821 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -373,13 +373,13 @@ Discovery is read-only, never touches the network, and never fails the run: a ma | maven | `pom.xml` (+ `.mvn/maven.config`, `.mvn/checksums/checksums.sha256`) | a dependency version `-socket.` matching exactly ONE `socket-patch-` repository on the patch host | `socket-patch-vendor-` repository + exactly one jar under `.socket/vendor/maven//` with a matching `.sha1` | Trusted Checksums line when enabled; not required (the suffixed version is the pin) | | gradle (v5.0) | `.socket/gradle/hosted-index.tsv`, the owned hosted script, and every settings script and lock file the script graph reaches | an index row whose repository URL is on the patch host and names the row's uuid, live only while the owned script is intact, every build's settings file applies it with the current index digest, every lock entry of the GA is the suffixed version, no `settings-gradle.lockfile` names the GA and no build script sets a custom `lockFile` (otherwise `patched_ref_invalid`) | none here: a vendored Gradle entry is gated by its ledger entry's wiring check | the suffixed copies installed in the Gradle cache; required (never the lock basis), because the script lets a higher upstream version resolve | | nuget | the first of `nuget.config` / `NuGet.config` / `NuGet.Config`, + `packages.lock.json` | source `socket-patch-` + its exclusive exact-id ``; version from `packages.lock.json` | the same mapping onto `.socket/vendor/nuget/`; version from the lock, else the feed's single nupkg | `contentHash`, required | -| deno | none | — (no hosted mode) | — (no vendored backend) | — | +| deno | `deno.lock`'s npm section, only as a contesting lock: Deno installs a `package.json` project's npm deps from it and never reads `package-lock.json`, so an npm-family pin of a `name@version` it also locks is contested (#406) | — (no hosted mode) | — (no vendored backend) | — | Recognition rules that hold for every ecosystem: * **Patch hosts.** A hosted reference counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin, with no userinfo. The uuid is the URL's LAST canonical-uuid path segment, because grant tokens may themselves be uuid-shaped. The Go module prefix is fixed. `socket-patch-` registry / repository / source names count only through a pin. For a URL on any other host, see **Patch hosts** above. * **Pins, not definitions.** A registry, index or source *definition* alone (cargo `[registries]`, nuget ``, pom ``, uv index tables, `.npmrc`) never makes a reference, because it survives a reverted pin. Sections the package manager ignores are not read: npm's v2 `dependencies` mirror, a `.cargo/config.toml` shadowed by `.cargo/config`. A Socket pin inside a maven `` is diagnosed, never a reference. -* **Contested locks.** When one lock wires a package to a patch and another lock resolves the same `name@version` from a non-Socket source, the build's bytes depend on which package manager runs. The reference is then dropped with a `patched_ref_unattributable` diagnostic naming both files. This applies across npm / pnpm / yarn / bun and across uv / pylock / poetry / pdm / Pipfile.lock / requirements. PEP 723 script locks neither contest nor are contested. A **bundled** npm copy (`inBundle: true`, or v1 `bundled: true`) of the same `name@version` contests the reference too, in the same lock, in the other npm lock of a shrinkwrap/package-lock pair, or in any other lock. npm unpacks it from the parent package's tarball, so no rewire reaches it and it stays unpatched. Bun and vlt unpack bundled copies the same way (#469, #471). For Bun, that is a `bun.lock` entry whose meta is `{ "bundled": true }`, or a `bun.lockb` record that a dependency edge with the `bundled` behavior bit reaches, including one Bun shares with a regular install. Such an entry is never a reference, and it contests the reference the same way. For vlt, the lock records no node for a bundled copy, so the copy is found in the installed store: a real package directory inside a store package's own `node_modules`. Hosted and vendored scans skip these copies with `redirect_bun_bundled_instance_skipped` / `redirect_vlt_bundled_instance_skipped` / `vendor_bundled_instance_skipped`. When a bundled copy is the only instance, vendoring refuses with `vendor_lock_entry_not_rewritable`. Another entry of the **same** npm or Bun lock that resolves the wired `name@version` from a non-Socket source (for example a workspace member added after the rewire, then `npm install` / `bun install`) contests the reference too (#588). The package manager installs both entries, and that copy stays unpatched. Re-running `scan` / `vendor` rewires every copy. +* **Contested locks.** When one lock wires a package to a patch and another lock resolves the same `name@version` from a non-Socket source, the build's bytes depend on which package manager runs. The reference is then dropped with a `patched_ref_unattributable` diagnostic naming both files. This applies across npm / pnpm / yarn / bun and across uv / pylock / poetry / pdm / Pipfile.lock / requirements. PEP 723 script locks neither contest nor are contested. A **bundled** npm copy (`inBundle: true`, or v1 `bundled: true`) of the same `name@version` contests the reference too, in the same lock, in the other npm lock of a shrinkwrap/package-lock pair, or in any other lock. npm unpacks it from the parent package's tarball, so no rewire reaches it and it stays unpatched. Bun and vlt unpack bundled copies the same way (#469, #471). So does pnpm: a `packages:` entry's `bundledDependencies:` names the copies it unpacks from its own tarball, but pnpm never locks them, so the bundled version is not in the lock. A pnpm reference whose package name a `bundledDependencies` list in the same lock names is contested whatever its version (a missed attestation when the bundled copy is another version, never a false one), and `bundledDependencies: true` contests every reference of that lock. For Bun, that is a `bun.lock` entry whose meta is `{ "bundled": true }`, or a `bun.lockb` record that a dependency edge with the `bundled` behavior bit reaches, including one Bun shares with a regular install. Such an entry is never a reference, and it contests the reference the same way. For vlt, the lock records no node for a bundled copy, so the copy is found in the installed store: a real package directory inside a store package's own `node_modules`. Hosted and vendored scans skip these copies with `redirect_bun_bundled_instance_skipped` / `redirect_vlt_bundled_instance_skipped` / `vendor_bundled_instance_skipped`. When a bundled copy is the only instance, vendoring refuses with `vendor_lock_entry_not_rewritable`. Another entry of the **same** npm or Bun lock that resolves the wired `name@version` from a non-Socket source (for example a workspace member added after the rewire, then `npm install` / `bun install`) contests the reference too (#588). The package manager installs both entries, and that copy stays unpatched. Re-running `scan` / `vendor` rewires every copy. * **Lockless pins.** With no lock to name a version, a `Cargo.toml` pin (every declaration on `socket-patch-`, that registry defined on the patch host for the same uuid) or an exclusive nuget exact-id mapping is never a reference on its own, so v5.0 does not attest it (nor does `list` show it, or `rollback` / `remove` restore it — restore those files from version control). Only a pre-v5 redirect-ledger record naming a version the pin admits keeps it live. The same holds for a gem wired only in the `Gemfile` (the pre-bundler-2.6 mixed state, lock not converged). **Record resolution.** A candidate's record must carry the patch uuid the lockfile actually **wires**. It is taken from the first source that has one: the manifest (matched qualifier-insensitively), the hosted records above (this run's, then a pre-v5 ledger's), then the vendor ledger's embedded records. If none has it and the run is online, `vex` fetches the patch view by uuid from the patch API — for a v5 hosted checkout this is the normal path. The fetch uses `get`'s API client: the public proxy when no token is configured, and a one-shot 401/403 fallback to the proxy (free patches only). At most 10 fetches run concurrently. Fetched records stay in memory: `vex` never writes the manifest. A candidate still has no record under `--offline`, after a transport error or a 404, or when the patch is refused (paid without an entitled token); it is then omitted as `record_unavailable`, and the run is not aborted. A record whose uuid or package disagrees with the wiring is omitted as `record_mismatch`. The informational `socket-patch.vendor.json` marker is never a record source. When the lockfile wires a package to patch U, a manifest or ledger record for that package under another uuid is superseded, and a human-mode `Note:` says so. @@ -389,7 +389,7 @@ Recognition rules that hold for every ecosystem: | Wiring | Evidence (verify mode) | Marker | |---|---|---| | Vendored: a lockfile/config wires a `.socket/vendor` artifact, or a live vendor ledger entry | The **committed artifact** is hashed against the record's `afterHash`. The ledger entry is used when it names the wired artifact (it carries the dir-artifact inventory); otherwise an entry is synthesized from the reference. A present installed tree with different bytes only warns `vendored_tree_out_of_sync`. | `(vendored)` | -| Hosted: a discovered patch-host reference (or a live pre-v5 redirect-ledger record) | The installed copies the build **consumes** through the hosted wiring are hash-verified when any exist: the Go replacement module, never the pristine `M@v` in the module cache; the Socket-registry cargo source dir; maven's suffixed version. Installed evidence wins: `hash_mismatch` / `not_applied` are omitted. With **nothing installed**, a discovered reference whose lock pins the artifact (or whose format's rewriter never writes a pin) attests from that pin, which is the same evidence as in-run `scan --mode hosted --vex`. A pre-v5 ledger-only record, or a reference whose required pin is missing, stays `package_not_found`. So do purls that `--ecosystems` kept out of the crawl, because "not installed" has to mean the crawler looked. The same goes for npm purls when an installed pnpm tree records its virtual store outside the project (`enableGlobalVirtualStore`, or a `virtualStoreDir` that climbs out): transitive deps there are invisible to the crawler. A pnpm `modulesDir` inside the project is crawled. | `(redirected)` | +| Hosted: a discovered patch-host reference (or a live pre-v5 redirect-ledger record) | The installed copies the build **consumes** through the hosted wiring are hash-verified when any exist: the Go replacement module, never the pristine `M@v` in the module cache; the Socket-registry cargo source dir; maven's suffixed version. Installed evidence wins: `hash_mismatch` / `not_applied` are omitted. With **nothing installed**, a discovered reference whose lock pins the artifact (or whose format's rewriter never writes a pin) attests from that pin, which is the same evidence as in-run `scan --mode hosted --vex`. A pre-v5 ledger-only record, or a reference whose required pin is missing, stays `package_not_found`. So do purls that `--ecosystems` kept out of the crawl, because "not installed" has to mean the crawler looked. The same goes for npm purls when an installed pnpm tree records its virtual store outside the project (`enableGlobalVirtualStore`, or a `virtualStoreDir` that climbs out): transitive deps there are invisible to the crawler. A pnpm `modulesDir` inside the project is crawled. A yarn Plug'n'Play project (`.pnp.cjs` / `.pnp.js`) has no tree to crawl: an npm purl there attests from the lock's pin only when the PnP loader itself resolves it through the patch (berry names the hosted url, yarn 1 the resolved url's `#` in its cache folder). A loader written before the lock was rewired runs the registry copy, so the purl stays omitted until `yarn install` rewrites it (#519). | `(redirected)` | | Agent: a manifest record with no live hosted/vendored wiring | The installed tree, unchanged. **Every** installed copy the crawler finds for the purl (npm nests duplicates of one `name@version`; pnpm, vlt, Bun and Deno stores add peer-variant copies and copies bundled inside other packages) must hash to the patched bytes, as `apply` patches every copy (Maven: every copy a build consumes, Gradle hash dirs included — see [Gradle builds](#gradle-builds-v50)). One unpatched copy omits the purl with that copy's tag (`not_applied` / `hash_mismatch`). | none | **Liveness gates.** These gates run before hashing, and `--no-verify` / `--vex-no-verify` skips only the hashing, never the gates: From 9ac96fc6c21b503bddf11b767af0bf8f4f02333a Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 12:33:09 -0400 Subject: [PATCH 09/11] Keep pnpm bundled and deno.lock refs live; omit them only from vex The pnpm bundled rule and the deno.lock read fed Discovery::unpatched_copy and resolved_elsewhere, which drop the ref. Its uuid stays recognized, so the shared liveness rule called the vendor-ledger entry or redirect record dead and vendor --check failed with a rewire remedy that could never clear it (any pnpm lock holding one bundledDependencies: true package failed every vendored npm entry in it). Both now record an UnwiredCopy. After every extractor has run, the orchestrator turns it into an Unattested mark on each ref it may stand beside, so the ref stays a ref (list, rollback, remove, ledger liveness) and only vex omits it. Unattested gains an UnattestedKind so vex reports vex_pnpm_bundled_copy / vex_deno_lock_copy with their own remedy instead of the Gradle code. The pnpm mark stays inside its own lock and is no longer cross-lock evidence. record_pnpm_bundled_copies no longer walks out.refs. Regression test: a pnpm vendored entry beside a bundledDependencies package still passes vendor --check, while vex omits it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/commands/vex_sources.rs | 48 ++++- .../socket-patch-cli/tests/e2e_vex_vendor.rs | 135 +++++++++++++ .../src/vex/discover/deno.rs | 91 ++++++--- .../src/vex/discover/gradle.rs | 11 +- .../socket-patch-core/src/vex/discover/mod.rs | 124 +++++++++++- .../socket-patch-core/src/vex/discover/npm.rs | 188 +++++++++++------- .../src/vex/discover/testing/golden.rs | 4 + crates/socket-patch-core/src/vex/mod.rs | 2 +- 8 files changed, 480 insertions(+), 123 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vex_sources.rs b/crates/socket-patch-cli/src/commands/vex_sources.rs index 370dc1cee..e1c17b2d7 100644 --- a/crates/socket-patch-cli/src/commands/vex_sources.rs +++ b/crates/socket-patch-cli/src/commands/vex_sources.rs @@ -84,7 +84,7 @@ use socket_patch_core::vendor::state::{VendorArtifact, VendorEntry, VendorState} use socket_patch_core::vex::discover::{ canonical_base_purl, vendor_ref, Discovery, LedgerLiveness, PatchedRef, WiringMode, }; -use socket_patch_core::vex::FailedPatch; +use socket_patch_core::vex::{FailedPatch, UnattestedKind}; use crate::args::GlobalArgs; use crate::ui::plural; @@ -195,6 +195,38 @@ pub(crate) const NOTE_API_AUTH_FALLBACK: &str = "api_auth_fallback"; /// records a release above its base, which that build resolves instead /// (`vex::Unattested`). pub(crate) const NOTE_LOCK_ABOVE_BASE: &str = "vex_gradle_lock_above_base"; +/// Omission tag and note: an npm pin is wired, but its pnpm lock names a +/// package that bundles a copy of it, which no wiring reaches +/// (`vex::Unattested`, `UnattestedKind::BundledCopy`). +pub(crate) const NOTE_PNPM_BUNDLED_COPY: &str = "vex_pnpm_bundled_copy"; +/// Omission tag and note: an npm pin is wired, but `deno.lock` locks the +/// same version, which `deno install` installs without the wiring +/// (`vex::Unattested`, `UnattestedKind::DenoLock`). +pub(crate) const NOTE_DENO_LOCK_COPY: &str = "vex_deno_lock_copy"; + +/// The omission tag of an [`Unattested`](socket_patch_core::vex::Unattested) +/// ref, and the remedy its note ends with. +fn unattested_note(kind: UnattestedKind) -> (&'static str, &'static str) { + match kind { + UnattestedKind::LockAboveBase => ( + NOTE_LOCK_ABOVE_BASE, + "not attested until that build resolves the patch (re-lock it, or roll the patch \ + back once upstream ships the fix)", + ), + UnattestedKind::BundledCopy => ( + NOTE_PNPM_BUNDLED_COPY, + "not attested while that package bundles it; the wiring itself is intact, so \ + re-running `scan` / `vendor` does not change this (drop or upgrade the bundling \ + package)", + ), + UnattestedKind::DenoLock => ( + NOTE_DENO_LOCK_COPY, + "not attested while deno.lock locks the same version; the wiring itself is \ + intact, so re-running `scan` / `vendor` does not change this (drop the entry from \ + deno.lock if Deno does not install this project's npm dependencies)", + ), + } +} fn note(code: &'static str, detail: String) -> PlanNote { PlanNote { code, detail } @@ -330,8 +362,9 @@ pub(crate) async fn plan(common: &GlobalArgs, sources: Sources, assume_live: &[S } let superseded = attach_discovered(&mut cands, &discovery, &vendor, &conflicts); // Wired, but a build bypasses the pin (`Unattested`: a Gradle lock - // above the hosted base resolves the newer upstream release): the ref - // keeps rollback, remove and list working, and the patch is omitted. + // above the hosted base resolves the newer upstream release, a pnpm + // bundled copy, a deno.lock copy): the ref keeps rollback, remove, + // list and the ledgers' liveness working, and the patch is omitted. cands.retain(|c| { let pkg = canonical_base_purl(&c.key); let Some(u) = discovery @@ -341,12 +374,12 @@ pub(crate) async fn plan(common: &GlobalArgs, sources: Sources, assume_live: &[S else { return true; }; - gated.push(failed(&c.key, NOTE_LOCK_ABOVE_BASE)); + let (code, remedy) = unattested_note(u.kind); + gated.push(failed(&c.key, code)); notes.push(note( - NOTE_LOCK_ABOVE_BASE, + code, format!( - "{}: patch {} is wired, but {}; not attested until that build resolves the \ - patch (re-lock it, or roll the patch back once upstream ships the fix)", + "{}: patch {} is wired, but {}; {remedy}", c.key, c.uuid, u.detail ), )); @@ -1559,6 +1592,7 @@ mod tests { uuid: U1.into(), file: "b/gradle.lockfile".into(), detail: "b/gradle.lockfile:1 locks it above the patched 1.10.0".into(), + kind: UnattestedKind::LockAboveBase, }); let sources = |discovery: Discovery| Sources { manifest: PatchManifest::new(), diff --git a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs index 0e5f46792..b44d90c04 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_vendor.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_vendor.rs @@ -2323,6 +2323,141 @@ fn reconstructed_ledger_entry_without_wiring_attests_from_the_root_lock() { ); } +/// REVIEW (#1033, audit B04): a pnpm lock that also holds a package with +/// `bundledDependencies` naming the vendored package (or `true`, bundling +/// every dependency) ships a copy pnpm unpacks from that package's own +/// tarball, which no wiring reaches and whose version the lock does not +/// record. `vex` must not attest the purl (`vex_pnpm_bundled_copy`), but +/// the wiring itself is intact and no re-vendor could clear the bundled +/// copy, so `vendor --check` must still verify the entry. Without the +/// bundling package both attest and verify (the control). +#[test] +fn pnpm_bundled_copy_blocks_vex_but_not_vendor_check() { + let uuid = "7c8d9e0f-1a2b-4a1b-8c2d-3e4f5a6b7c8d"; + let purl = "pkg:npm/left-pad@1.3.0"; + let patched = b"patched left-pad\n"; + for (label, bundling) in [ + ("control", ""), + ( + "bundledDependencies: true", + " host-pkg@1.0.0:\n resolution: {integrity: sha512-HOST==}\n \ + bundledDependencies: true\n", + ), + ( + "bundledDependencies list", + " host-pkg@1.0.0:\n resolution: {integrity: sha512-HOST==}\n \ + bundledDependencies:\n - left-pad\n", + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + let rel = format!(".socket/vendor/npm/{uuid}/left-pad-1.3.0.tgz"); + let sha256 = sha256_hex(&write_member_tgz( + &cwd.join(&rel), + "package/index.js", + patched, + )); + std::fs::write( + cwd.join("pnpm-lock.yaml"), + format!( + "lockfileVersion: '9.0'\n\nimporters:\n .:\n dependencies:\n \ + left-pad:\n specifier: file:{rel}\n version: file:{rel}\n\n\ + packages:\n left-pad@file:{rel}:\n resolution: {{integrity: sha512-xyz==, \ + tarball: file:{rel}}}\n version: 1.3.0\n{bundling}" + ), + ) + .unwrap(); + std::fs::write( + cwd.join("package.json"), + format!( + r#"{{"name":"app","version":"1.0.0","dependencies":{{"left-pad":"file:{rel}"}}}}"# + ), + ) + .unwrap(); + let mut state = VendorState::new(); + state.entries.insert( + purl.to_string(), + VendorEntry { + ecosystem: "npm".to_string(), + base_purl: purl.to_string(), + uuid: uuid.to_string(), + artifact: VendorArtifact { + yarn_berry10c0: None, + path: rel.clone(), + sha256, + size: None, + platform_locked: None, + file_inventory: None, + }, + wiring: Vec::new(), + lock: None, + took_over_go_patches: false, + detached: true, + record: Some(make_record( + uuid, + "package/index.js", + &compute_git_sha256_from_bytes(patched), + "GHSA-bndl-aaaa", + &["CVE-2026-1033"], + )), + flavor: Some("pnpm".to_string()), + uv: None, + pnpm: None, + poetry: None, + pdm: None, + pipenv: None, + }, + ); + std::fs::create_dir_all(cwd.join(".socket/vendor")).unwrap(); + std::fs::write( + cwd.join(".socket/vendor/state.json"), + serde_json::to_string_pretty(&state).unwrap(), + ) + .unwrap(); + + let check = cli() + .args([ + "vendor", + "--check", + "--cwd", + cwd.to_str().unwrap(), + "--json", + ]) + .output() + .expect("invoke vendor --check"); + let check_env: Value = serde_json::from_slice(&check.stdout).unwrap_or_else(|e| { + panic!( + "{label}: vendor --check envelope JSON on stdout ({e}): {}", + String::from_utf8_lossy(&check.stdout) + ) + }); + assert!( + check.status.success(), + "{label}: the wiring is intact: {check_env}" + ); + + let (code, env) = vex_json(cwd, &["--offline"]); + if bundling.is_empty() { + assert_eq!(code, Some(0), "{label}: {env}"); + continue; + } + assert_eq!(code, Some(1), "{label}: {env}"); + assert!( + !cwd.join("out.vex.json").exists(), + "{label}: no document may attest the purl: {env}" + ); + let event = skipped_event(&env, purl); + assert_eq!( + event["errorCode"], "vex_pnpm_bundled_copy", + "{label}: {env}" + ); + assert!( + env.to_string().contains("host-pkg@1.0.0"), + "{label}: the run names the bundling entry: {env}" + ); + } +} + // ────────────────────────────────────────────────────────────────────── // 9. Core discover rule 11: a vendor ledger entry whose artifact the // lockfiles still MENTION, but only in a shape the package manager does not diff --git a/crates/socket-patch-core/src/vex/discover/deno.rs b/crates/socket-patch-core/src/vex/discover/deno.rs index 9a3d248b9..92fdb939e 100644 --- a/crates/socket-patch-core/src/vex/discover/deno.rs +++ b/crates/socket-patch-core/src/vex/discover/deno.rs @@ -10,20 +10,31 @@ //! explicit and a future backend has an obvious home; Deno patches attest //! only through the manifest + installed tree (agent mode, `setup.manual`). //! -//! It still reads `deno.lock`'s npm section as a CONTESTING lock: Deno -//! installs a `package.json` project's npm dependencies from `deno.lock` -//! and never reads `package-lock.json`, so a Socket pin in the npm lock -//! does not reach the copy Deno runs. Every `name@version` there is -//! [`Discovery::resolved_elsewhere`] evidence, and the cross-lock contest -//! drops an npm-family ref of the same version (#406). The npm section is -//! the top-level `npm` map in lockfile versions 4 and 5, `npm.packages` -//! in version 2 and `packages.npm` in version 3; a key may carry Deno's -//! peer suffix (`name@1.0.0_peer@2.0.0`). The read is advisory: an -//! unreadable or unparseable `deno.lock` contests nothing. +//! It still reads `deno.lock`'s npm section as evidence AGAINST an +//! npm-family wiring: `deno install` installs a `package.json` project's +//! npm dependencies from `deno.lock` and never reads `package-lock.json` / +//! `pnpm-lock.yaml` / `yarn.lock`, so a Socket pin there does not reach +//! the copy Deno installs (#406). Every `name@version` of that section is +//! an [`UnwiredCopy`]: a ref of the same version in any lock is marked +//! [`Unattested`](super::Unattested) (`vex` omits it) but stays a ref. +//! Whether Deno or another package manager populates `node_modules` +//! (`nodeModulesDir: "manual"` allows either) is not in the files, so +//! this is a missed attestation at worst; and since re-running `scan` / +//! `vendor` cannot clear it, the ledgers' liveness gates (`vendor +//! --check`, `scan`) are left alone. The npm section is the top-level +//! `npm` map in lockfile versions 4 and 5, `npm.packages` in version 2 +//! and `packages.npm` in version 3; a key may carry Deno's peer suffix +//! (`name@1.0.0_peer@2.0.0`). The read is advisory: an unreadable or +//! unparseable `deno.lock` marks nothing. use serde_json::Value; -use super::{npm_purl, parse_json, DiscoverCtx, Discovery}; +use std::path::PathBuf; + +use super::{ + canonical_base_purl, npm_purl, parse_json, CopyTarget, DiscoverCtx, Discovery, UnattestedKind, + UnwiredCopy, +}; const DENO_LOCK: &str = "deno.lock"; @@ -34,8 +45,17 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { let Ok(lock) = parse_json(DENO_LOCK, text.as_bytes()) else { return; }; - for key in deno_npm_keys(&lock) { - out.resolved_elsewhere(DENO_LOCK, deno_npm_purl(key)); + for purl in deno_npm_keys(&lock).into_iter().filter_map(deno_npm_purl) { + out.unwired_copy(UnwiredCopy { + scope: None, + target: CopyTarget::Purl(canonical_base_purl(&purl)), + file: PathBuf::from(DENO_LOCK), + detail: format!( + "{DENO_LOCK} also locks it, and `deno install` installs this project's npm \ + dependencies from {DENO_LOCK}, where no Socket wiring reaches" + ), + kind: UnattestedKind::DenoLock, + }); } } @@ -63,6 +83,7 @@ fn deno_npm_purl(key: &str) -> Option { #[cfg(test)] mod tests { use super::super::testing::*; + use super::super::WiringMode; const SRI: &str = "sha512-PATCHEDpatchedPATCHEDpatched0123456789=="; @@ -114,18 +135,20 @@ mod tests { assert_eq!(super::deno_npm_purl("nonsense"), None); } - /// REGRESSION (#406): Deno installs a `package.json` project's npm deps - /// from `deno.lock` and never reads `package-lock.json`, so a hosted pin - /// in the npm lock beside a deno.lock registry entry of the same version - /// is contested, not attested. Another version in deno.lock is not. + /// REGRESSION (#406): `deno install` installs a `package.json` + /// project's npm deps from `deno.lock` and never reads + /// `package-lock.json`, so a hosted pin in the npm lock beside a + /// deno.lock entry of the same version is marked unattested — but stays + /// a ref with a live claim (no rewire could clear it). Another version + /// in deno.lock marks nothing. #[tokio::test] - async fn deno_lock_contests_an_npm_lock_pin_of_the_same_version() { + async fn deno_lock_marks_an_npm_lock_pin_of_the_same_version_unattested() { let url = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); let npm_lock = format!( r#"{{"name":"m","lockfileVersion":3,"packages":{{"":{{"name":"m"}}, "node_modules/left-pad":{{"version":"1.3.0","resolved":"{url}","integrity":"{SRI}"}}}}}}"# ); - for (deno_version, contested) in [("1.3.0", true), ("1.2.0", false)] { + for (deno_version, marked) in [("1.3.0", true), ("1.2.0", false)] { let p = Project::new(); p.write("package-lock.json", npm_lock.clone()); p.write( @@ -135,20 +158,28 @@ mod tests { ), ); let out = p.discover().await; + assert_refs( + &out, + &[("pkg:npm/left-pad@1.3.0", UUID_A, WiringMode::Hosted)], + ); + assert_eq!( + out.hosted_claim("pkg:npm/left-pad@1.3.0", UUID_A), + Some(true), + "{deno_version}" + ); + assert!(out.contested.is_empty(), "{:#?}", out.contested); assert_eq!( - out.refs.is_empty(), - contested, + out.unattested.len(), + usize::from(marked), "{deno_version}: {:#?}", - out.refs + out.unattested ); - if contested { - assert!( - out.diagnostics - .iter() - .any(|d| d.detail.contains("deno.lock")), - "{:#?}", - out.diagnostics - ); + if marked { + let u = &out.unattested[0]; + assert_eq!(u.kind, super::super::UnattestedKind::DenoLock); + assert_eq!(u.uuid, UUID_A); + assert_eq!(u.file, std::path::Path::new("deno.lock")); + assert!(u.detail.contains("deno.lock"), "{}", u.detail); } } } diff --git a/crates/socket-patch-core/src/vex/discover/gradle.rs b/crates/socket-patch-core/src/vex/discover/gradle.rs index 95fe73d82..248e01d0a 100644 --- a/crates/socket-patch-core/src/vex/discover/gradle.rs +++ b/crates/socket-patch-core/src/vex/discover/gradle.rs @@ -50,7 +50,8 @@ use std::collections::BTreeMap; use super::{ - maven_purl, DiscoverCtx, Discovery, PatchedRef, DIAG_LOCKFILE_UNPARSEABLE, DIAG_REF_INVALID, + maven_purl, DiscoverCtx, Discovery, PatchedRef, UnattestedKind, DIAG_LOCKFILE_UNPARSEABLE, + DIAG_REF_INVALID, }; use crate::gradle::eol::eol_eq; use crate::gradle::locks; @@ -192,7 +193,13 @@ pub(crate) async fn extract(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { continue; }; if let Some((rel, detail)) = bypass { - out.unattested(&purl, &row.uuid, &rel, detail); + out.unattested( + &purl, + &row.uuid, + &rel, + detail, + UnattestedKind::LockAboveBase, + ); } out.push(PatchedRef::hosted( purl, diff --git a/crates/socket-patch-core/src/vex/discover/mod.rs b/crates/socket-patch-core/src/vex/discover/mod.rs index ba855e20b..bae6d3b4f 100644 --- a/crates/socket-patch-core/src/vex/discover/mod.rs +++ b/crates/socket-patch-core/src/vex/discover/mod.rs @@ -441,12 +441,31 @@ pub struct ContestedRef { pub other: PathBuf, } -/// A ref discovery emits (so rollback, remove and list find the wiring) -/// that must not be attested: the files show a build that resolves the -/// package from somewhere the pin does not reach. Today: a Gradle lock -/// entry above the hosted pin's base (the owned script lets that newer -/// upstream release resolve), so that build consumes no patch. The CLI's -/// VEX plan omits every candidate of `(purl, uuid)` with `detail`. +/// Why an [`Unattested`] ref's wiring does not reach the copy a build runs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum UnattestedKind { + /// A Gradle lock entry above the hosted pin's base: the owned script + /// lets that newer upstream release resolve. + LockAboveBase, + /// The ref's own pnpm lock names a package that bundles a copy of it + /// (`bundledDependencies`): pnpm unpacks that copy from the parent's + /// tarball, where no wiring reaches, and does not lock its version. + BundledCopy, + /// `deno.lock` locks the same `name@version`: `deno install` installs + /// a `package.json` project's npm deps from it, never from the + /// npm-family lock that carries the wiring. + DenoLock, +} + +/// A ref discovery emits (so rollback, remove and list find the wiring, +/// and the ledgers' liveness gates still see it wired) that must not be +/// attested: the files show a build that may run a copy the pin does not +/// reach ([`UnattestedKind`]). The CLI's VEX plan omits every candidate of +/// `(purl, uuid)` with `detail`. Unlike a contest +/// ([`Discovery::unpatched_copy`], [`Discovery::contest_across_locks`]), +/// this never drops the ref, so `vendor --check` and `scan` keep treating +/// the wiring as live — the right answer when re-running `scan` / `vendor` +/// could never clear the evidence. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct Unattested { /// Canonical base purl ([`canonical_base_purl`]). @@ -455,6 +474,49 @@ pub struct Unattested { /// Root-relative file that shows the bypass. pub file: PathBuf, pub detail: String, + pub kind: UnattestedKind, +} + +/// Which refs an [`UnwiredCopy`] may stand beside. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum CopyTarget { + /// Exactly this canonical base purl. + Purl(String), + /// An npm package of this name, whatever its version. + NpmName(String), + /// Every ref. + Any, +} + +/// A copy some build installs where no Socket wiring reaches, recorded by +/// an extractor ([`Discovery::unwired_copy`]) and turned into +/// [`Unattested`] marks for the refs it may stand beside once every +/// extractor has run ([`Discovery::unattest_unwired_copies`]). +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct UnwiredCopy { + /// Only refs of this root-relative file; `None` = refs of any file. + pub(crate) scope: Option, + pub(crate) target: CopyTarget, + /// Root-relative file that shows the copy. + pub(crate) file: PathBuf, + /// Completes "patch is wired, but …"; names the file. + pub(crate) detail: String, + pub(crate) kind: UnattestedKind, +} + +impl UnwiredCopy { + fn covers(&self, r: &PatchedRef) -> bool { + if self.scope.as_ref().is_some_and(|f| *f != r.source_file) { + return false; + } + match &self.target { + CopyTarget::Purl(purl) => *purl == r.purl, + CopyTarget::NpmName(name) => crate::utils::purl::purl_name_version(&r.purl) + .and_then(|(_, version)| npm_purl(name, version)) + .is_some_and(|p| canonical_base_purl(&p) == r.purl), + CopyTarget::Any => true, + } + } } /// Everything [`discover_patched_refs`] found. @@ -479,6 +541,9 @@ pub struct Discovery { pub unpatched_copies: Vec, /// Refs in `refs` whose wiring a build bypasses ([`Unattested`]). pub unattested: Vec, + /// Copies no wiring reaches, pending [`Discovery::unattest_unwired_copies`]; + /// always empty once discovery returns (folded into `unattested`). + pub unwired_copies: Vec, /// Refs dropped because another lock contests them ([`ContestedRef`]). pub contested: Vec, } @@ -821,15 +886,55 @@ impl Discovery { /// Record that the ref `(purl, uuid)` is wired but bypassed by the /// build `file` shows ([`Unattested`]). Pushed beside the ref itself. - pub(crate) fn unattested(&mut self, purl: &str, uuid: &str, file: &str, detail: String) { + pub(crate) fn unattested( + &mut self, + purl: &str, + uuid: &str, + file: &str, + detail: String, + kind: UnattestedKind, + ) { self.unattested.push(Unattested { purl: canonical_base_purl(purl), uuid: uuid.to_string(), file: PathBuf::from(file), detail, + kind, }); } + /// Record a copy some build installs where no Socket wiring reaches, + /// when the files cannot tie it to a ref's exact `name@version` or + /// cannot say the build runs it ([`UnwiredCopy`]). Every ref it may + /// stand beside is marked [`Unattested`] — a missed attestation at + /// worst, never a false one — and stays a ref, so no ledger claim dies + /// over evidence a rewire could never clear. + pub(crate) fn unwired_copy(&mut self, copy: UnwiredCopy) { + self.unwired_copies.push(copy); + } + + /// Mark every surviving ref an [`UnwiredCopy`] covers [`Unattested`] + /// (the first covering copy names the cause), once every extractor has + /// run. + fn unattest_unwired_copies(&mut self) { + let copies = std::mem::take(&mut self.unwired_copies); + let marks: Vec = self + .refs + .iter() + .filter_map(|r| { + let c = copies.iter().find(|c| c.covers(r))?; + Some(Unattested { + purl: r.purl.clone(), + uuid: r.uuid.clone(), + file: c.file.clone(), + detail: c.detail.clone(), + kind: c.kind, + }) + }) + .collect(); + self.unattested.extend(marks); + } + fn finalize(&mut self) { self.elsewhere.sort(); self.elsewhere.dedup(); @@ -911,6 +1016,7 @@ async fn discover_with_ctx(ctx: DiscoverCtx<'_>) -> Discovery { deno::extract(&ctx, &mut out).await; out.contest_within_locks(); out.contest_across_locks(); + out.unattest_unwired_copies(); out.recognized.extend(ctx.take_recognized()); out.finalize(); out @@ -2326,8 +2432,10 @@ pub(crate) mod testing { let ctx = self.ctx(); let mut out = Discovery::default(); extract(&ctx, &mut out).await; - // Same-lock copies contest within one extractor's own locks. + // Same-lock copies contest within one extractor's own locks, + // and its unwired copies mark its own refs. out.contest_within_locks(); + out.unattest_unwired_copies(); let swept = ctx.take_recognized(); assert_recognition_covers_refs(&out, &swept, "the ctx sweep", Some(self.root())); out.recognized.extend(swept); diff --git a/crates/socket-patch-core/src/vex/discover/npm.rs b/crates/socket-patch-core/src/vex/discover/npm.rs index a16375fdb..1412b1b3b 100644 --- a/crates/socket-patch-core/src/vex/discover/npm.rs +++ b/crates/socket-patch-core/src/vex/discover/npm.rs @@ -41,13 +41,14 @@ //! the entry is not Socket-written and is diagnosed, not trusted. use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; use serde_json::Value; use super::{ - canonical_base_purl, npm_purl, npm_vendored_tarball_names, parse_json, vendor_ref, DiscoverCtx, - Discovery, LocateOpts, Located, PatchedRef, VendorRef, DIAG_LOCKFILE_UNPARSEABLE, - DIAG_REF_INVALID, DIAG_REF_UNATTRIBUTABLE, + npm_purl, npm_vendored_tarball_names, parse_json, vendor_ref, CopyTarget, DiscoverCtx, + Discovery, LocateOpts, Located, PatchedRef, UnattestedKind, UnwiredCopy, VendorRef, + DIAG_LOCKFILE_UNPARSEABLE, DIAG_REF_INVALID, DIAG_REF_UNATTRIBUTABLE, }; use crate::constants::npm_family::{NPM_LOCKS, PNPM_LOCK, PNPM_SHRINKWRAP_LEGACY}; use crate::formats::pnpm::{ @@ -580,64 +581,51 @@ async fn extract_pnpm_lock(ctx: &DiscoverCtx<'_>, file: &str, out: &mut Discover bundles.extend(entry_bundled(&package.entry).map(|b| (package.key, b))); } record_pnpm_file_copies(ctx, file, copies, out).await; - record_pnpm_bundled_copies(file, &bundles, out); + record_pnpm_bundled_copies(file, bundles, out); } -/// Record the bundled copies a pnpm lock installs as unpatched copies of -/// every ref of the same lock they may hold. pnpm unpacks a package's -/// `bundledDependencies` from its own tarball into its store directory +/// Record the bundled copies a pnpm lock installs ([`UnwiredCopy`]). +/// pnpm unpacks a package's `bundledDependencies` from its own tarball +/// into its store directory /// (`node_modules/.pnpm//node_modules//node_modules/`) /// and never resolves them, so no Socket wiring of the lock reaches that -/// copy — the npm, bun and vlt extractors already contest the same case -/// from their locks' bundled entries. The lock names the bundled package -/// but not its version (that lives in the parent's tarball), so a ref of -/// the same NAME is contested whatever its version: a missed attestation -/// when the bundled copy is another version, never a false one. -/// `bundledDependencies: true` bundles every dependency of the parent, -/// which the lock does not list, so it contests every ref of the lock. -fn record_pnpm_bundled_copies(file: &str, bundles: &[(&str, Bundled<'_>)], out: &mut Discovery) { - if bundles.is_empty() { - return; - } - let lock_refs: Vec = out - .refs - .iter() - .filter(|r| r.source_file == std::path::Path::new(file)) - .map(|r| r.purl.clone()) - .collect(); - for purl in lock_refs { - let Some((_, version)) = crate::utils::purl::purl_name_version(&purl) else { - continue; +/// copy — the npm, bun and vlt extractors contest the same case from their +/// locks' bundled entries. Unlike theirs, the pnpm lock names the bundled +/// package but not its version (that lives in the parent's tarball), so +/// the copy cannot be tied to a ref's `name@version`: every ref of the same +/// NAME in this lock is marked unattested whatever its version (a missed +/// attestation when the bundled copy is another version, never a false +/// one), and `bundledDependencies: true` — every dependency of the parent, +/// which the lock does not list — marks every ref of the lock. The refs +/// stay refs: the wiring is intact and no rewire could clear the bundled +/// copy, so the ledgers' liveness gates (`vendor --check`, `scan`) keep +/// seeing them live, and the copy is no cross-lock evidence either. +fn record_pnpm_bundled_copies(file: &str, bundles: Vec<(&str, Bundled<'_>)>, out: &mut Discovery) { + for (parent, bundled) in bundles { + let (targets, what) = match bundled { + Bundled::All => ( + vec![CopyTarget::Any], + "bundles every dependency (`bundledDependencies: true`)", + ), + Bundled::Names(names) => ( + names + .into_iter() + .map(|name| CopyTarget::NpmName(name.to_string())) + .collect(), + "bundles a copy of it (`bundledDependencies`)", + ), }; - let target = canonical_base_purl(&purl); - for (parent, bundled) in bundles { - let names = match bundled { - Bundled::All => None, - Bundled::Names(names) => Some(names), - }; - let holds = names.is_none_or(|names| { - names.iter().any(|name| { - npm_purl(name, version).is_some_and(|p| canonical_base_purl(&p) == target) - }) - }); - if !holds { - continue; - } - let what = if names.is_some() { - "a bundled copy of it" - } else { - "every dependency as a bundled copy (`bundledDependencies: true`)" - }; - out.unpatched_copy( - file, - Some(purl.clone()), - parent, - &format!( - "ships {what}, which pnpm unpacks from that package's own tarball \ - (the lock does not record its version) and no Socket wiring reaches" + for target in targets { + out.unwired_copy(UnwiredCopy { + scope: Some(PathBuf::from(file)), + target, + file: PathBuf::from(file), + detail: format!( + "{file} entry `{parent}` {what}, which pnpm unpacks from that package's \ + own tarball without locking its version, so no Socket wiring reaches it" ), - ); - break; + kind: UnattestedKind::BundledCopy, + }); } } } @@ -2226,10 +2214,12 @@ mod tests { /// and never locks them, so a bundled copy of the wired package stays /// unpatched beside the Socket wiring (audit B04; npm, bun and vlt /// already contest it). The lock does not record the bundled version, - /// so a ref of the same name is contested; a bundle of another name - /// is not. Hosted and vendored refs alike, v9 and legacy keys. + /// so a ref of the same name is marked unattested; a bundle of another + /// name is not. The ref itself STAYS a ref (the wiring is intact and no + /// rewire could clear the bundled copy), so the ledgers' liveness gates + /// keep it live. Hosted and vendored refs alike, v9 and legacy keys. #[tokio::test] - async fn pnpm_bundled_copy_contests_the_ref() { + async fn pnpm_bundled_copy_marks_the_ref_unattested() { let url = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); let wired = format!(" left-pad@1.3.0:\n resolution: {{integrity: {SRI}, tarball: {url}}}\n\n"); @@ -2250,7 +2240,9 @@ mod tests { ] { let p = Project::new(); p.write("pnpm-lock.yaml", lock(&extra)); - assert_refs(&run(&p).await, &hosted); + let out = run(&p).await; + assert_refs(&out, &hosted); + assert!(out.unattested.is_empty(), "{:#?}", out.unattested); } for (case, text) in [ @@ -2287,20 +2279,33 @@ mod tests { let p = Project::new(); p.write("pnpm-lock.yaml", text); let out = run(&p).await; - assert!(out.refs.is_empty(), "{case}: {:#?}", out.refs); + assert_refs(&out, &hosted); + assert_eq!( + out.hosted_claim("pkg:npm/left-pad@1.3.0", UUID_A), + Some(true), + "{case}" + ); + assert_eq!(out.unattested.len(), 1, "{case}: {:#?}", out.unattested); + let u = &out.unattested[0]; + assert_eq!( + (u.purl.as_str(), u.uuid.as_str(), u.kind), + ( + "pkg:npm/left-pad@1.3.0", + UUID_A, + UnattestedKind::BundledCopy + ), + "{case}" + ); + assert_eq!(u.file, std::path::Path::new("pnpm-lock.yaml"), "{case}"); assert!( - out.diagnostics - .iter() - .any(|d| d.code == DIAG_REF_UNATTRIBUTABLE - && d.detail.contains("host-pkg@1.0.0") - && d.detail.contains("bundled") - && d.detail.contains("UNPATCHED")), - "{case}: {:#?}", - out.diagnostics + u.detail.contains("host-pkg@1.0.0") && u.detail.contains("bundl"), + "{case}: {}", + u.detail ); } - // A vendored ref is contested the same way. + // A vendored ref is marked the same way, and its ledger claim stays + // live: `vendor --check` must not fail over a copy no rewire clears. let p = Project::new(); let rel = format!(".socket/vendor/npm/{UUID_A}/left-pad-1.3.0.tgz"); p.write(&rel, npm_tgz("left-pad", "1.3.0")); @@ -2309,14 +2314,47 @@ mod tests { format!( "lockfileVersion: '9.0'\n\npackages:\n\n left-pad@file:{rel}:\n \ resolution: {{integrity: {SRI}, tarball: file:{rel}}}\n version: 1.3.0\n\n{}", - host( - "host-pkg@1.0.0", - " bundledDependencies:\n - left-pad" - ) + host("host-pkg@1.0.0", " bundledDependencies: true") ), ); - let out = run(&p).await; - assert!(out.refs.is_empty(), "vendored: {:#?}", out.refs); + let out = p.discover().await; + assert_refs( + &out, + &[("pkg:npm/left-pad@1.3.0", UUID_A, WiringMode::Vendored)], + ); + assert_eq!( + out.vendored_claim("pkg:npm/left-pad@1.3.0", UUID_A, &rel), + Some(true) + ); + assert_eq!(out.unattested.len(), 1, "vendored: {:#?}", out.unattested); + } + + /// The pnpm bundled mark stays in its own lock: a same-version ref in + /// another lock (a `package-lock.json` twin) is neither marked nor + /// contested by it — the pnpm lock does not record the bundled version, + /// so it is no cross-lock evidence. + #[tokio::test] + async fn pnpm_bundled_copy_does_not_reach_another_lock() { + 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", + format!( + r#"{{"name":"m","lockfileVersion":3,"packages":{{"":{{"name":"m"}}, + "node_modules/left-pad":{{"version":"1.3.0","resolved":"{url}","integrity":"{SRI}"}}}}}}"# + ), + ); + p.write( + "pnpm-lock.yaml", + "lockfileVersion: '9.0'\n\npackages:\n\n host-pkg@1.0.0:\n \ + resolution: {integrity: sha512-HOST==}\n bundledDependencies: true\n\n", + ); + let out = p.discover().await; + assert_refs( + &out, + &[("pkg:npm/left-pad@1.3.0", UUID_A, WiringMode::Hosted)], + ); + assert!(out.unattested.is_empty(), "{:#?}", out.unattested); } /// The committed golden (TS backend output — what a depscan PR leaves). diff --git a/crates/socket-patch-core/src/vex/discover/testing/golden.rs b/crates/socket-patch-core/src/vex/discover/testing/golden.rs index 36b15632e..03e6a9066 100644 --- a/crates/socket-patch-core/src/vex/discover/testing/golden.rs +++ b/crates/socket-patch-core/src/vex/discover/testing/golden.rs @@ -123,6 +123,8 @@ fn render(out: &Discovery, root: &Path) -> Value { unpatched_copies, unattested, contested, + // Already folded into `unattested` by the time a run returns. + unwired_copies: _, } = out; let refs: Vec = refs .iter() @@ -217,12 +219,14 @@ fn render(out: &Discovery, root: &Path) -> Value { uuid, file, detail, + kind, } = u; json!({ "purl": purl, "uuid": uuid, "file": path_str(file), "detail": normalize(detail, &roots), + "kind": format!("{kind:?}"), }) }) .collect::>() diff --git a/crates/socket-patch-core/src/vex/mod.rs b/crates/socket-patch-core/src/vex/mod.rs index 4abef38b8..0ce4b58ea 100644 --- a/crates/socket-patch-core/src/vex/mod.rs +++ b/crates/socket-patch-core/src/vex/mod.rs @@ -28,7 +28,7 @@ pub use build::{build_document, BuildOptions}; pub use discover::{ canonical_base_purl, discover_patched_refs, discover_patched_refs_in, discover_patched_refs_with, Diag, DiscoverOptions, Discovery, PatchedRef, Recognized, - Unattested, UnlockedPin, WiringMode, + Unattested, UnattestedKind, UnlockedPin, WiringMode, }; pub use product::{detect_product, DetectResult}; pub use schema::{ From 98d9b0b260c2a564901ef0dd43b5307cc122fc8b Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 12:33:11 -0400 Subject: [PATCH 10/11] Contest a yarn berry ref beside a registry locator of the same version A berry registry locator (left-pad@npm:1.3.0) beside the hosted __archiveUrl one of the same name@version was only cross-lock evidence, so the same lock never contested the hosted ref. yarn installs both, and vex's PnP loader check matches the patch anywhere in the loader text, so a loader naming both locators attested the hosted pin. Plain and custom-registry berry entries now go through Discovery::unpatched_copy, as yarn classic registry blocks already do (#938). Adds a core test and a PnP e2e test (berry 4 and yarn 1) where the loader names both locators: nothing attests. The golden only gains the new unpatched_copies rows. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/e2e_vex_lockfile/yarn.rs | 71 ++++++++++++++ .../src/vex/discover/yarn.rs | 63 ++++++++++++- .../vex-discover-golden/redirect-npm.json | 94 +++++++++++++++++-- 3 files changed, 217 insertions(+), 11 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs index 47694d8ef..f40652d88 100644 --- a/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs +++ b/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs @@ -1250,6 +1250,77 @@ fn pnp_layout_contract() { } } +/// #1033 review: the PnP loader check matches the patch anywhere in the +/// loader text, so a loader that resolves BOTH the hosted locator and a +/// registry locator of the same `name@version` (scoped `resolutions`, a +/// workspace member added after the rewire) still "names the patch". What +/// keeps that from attesting is the yarn same-lock rule: the lock's +/// registry entry of the same version contests the hosted ref. Pinned here +/// for berry 4 and yarn 1, the two flavors whose hosted pin is read. +#[test] +fn pnp_loader_naming_hosted_and_registry_copies_is_not_attested() { + for flavor in [Flavor::Classic, Flavor::Berry4] { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + write_project(cwd, flavor, None); + let hosted = hosted_lock(flavor, "https://patch.socket.dev", UUID); + let (lock, loader_name, loader) = if flavor.is_berry() { + put( + cwd, + ".yarnrc.yml", + b"nodeLinker: pnp\nenableGlobalCache: false\n", + ); + let archive = format!( + "npm:1.3.0::__archiveUrl={}", + encode_uri_component(&berry_hosted_url("https://patch.socket.dev", UUID)) + ); + ( + format!( + "{hosted}\n\"left-pad@npm:^1.3.0\":\n version: 1.3.0\n resolution: \ + \"left-pad@npm:1.3.0\"\n checksum: {}\n languageName: node\n \ + linkType: hard\n", + flavor.berry_checksum('3') + ), + ".pnp.cjs", + format!( + "/* yarn PnP loader */\n[\"left-pad\", [[\"{archive}\", \ + {{\"packageLocation\": \"./.yarn/cache/a.zip/node_modules/left-pad/\"}}], \ + [\"npm:1.3.0\", {{\"packageLocation\": \ + \"./.yarn/cache/b.zip/node_modules/left-pad/\"}}]]]\n" + ), + ) + } else { + ( + format!( + "{hosted}\nleft-pad@^1.3.0:\n version \"1.3.0\"\n resolved \ + \"https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e\"\n \ + integrity sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==\n" + ), + ".pnp.js", + "/* yarn PnP loader */\n\ + packageLocation: \"/c/v6/npm-left-pad-1.3.0-abcdef0123456789abcdef0123456789abcdef01-integrity/\"\n\ + packageLocation: \"/c/v6/npm-left-pad-1.3.0-5b8a3a7765dfe001261dde915589e782f8c94d1e-integrity/\"\n" + .to_string(), + ) + }; + put(cwd, "yarn.lock", lock.as_bytes()); + put(cwd, loader_name, loader.as_bytes()); + let (_rt, server) = serve_left_pad(); + let (code, env) = vex_json(cwd, &["--proxy-url", &server.uri()]); + let cell = format!("{flavor:?} PnP, hosted + registry copies"); + assert_ne!(code, Some(0), "{cell}: {env}"); + let doc = read_doc(&cwd.join("out.vex.json")); + assert!( + doc.as_ref().is_none_or(|d| !d.to_string().contains(PURL)), + "{cell}: nothing may attest left-pad: {doc:?}" + ); + assert!( + env.to_string().contains("patched_ref_unattributable"), + "{cell}: the run says why: {env}" + ); + } +} + // ────────────────────────────────────────────────────────────────────── // EMBEDDED — scan --vex / scan --mode hosted --vex / scan --vendor --vex / // apply --vex, manifest-less diff --git a/crates/socket-patch-core/src/vex/discover/yarn.rs b/crates/socket-patch-core/src/vex/discover/yarn.rs index 8ea1cc8c8..a9000bcfc 100644 --- a/crates/socket-patch-core/src/vex/discover/yarn.rs +++ b/crates/socket-patch-core/src/vex/discover/yarn.rs @@ -402,9 +402,20 @@ fn berry_block( // locator itself encodes (npm: locators only). let (spec, locator_version) = if let Some((version, _)) = locator.npm() { let Some(archive) = locator.archive_url() else { - // A plain registry entry. + // A plain registry entry: an unpatched copy, which contests a + // wiring of the same version in this lock (berry installs every + // locator the lock resolves, so a registry locator beside a + // hosted or vendored one of the same `name@version` — scoped + // `resolutions`, a workspace member added after the rewire — + // ships the registry bytes too) and in any other. let version = berry_field(&block.lines, "version").unwrap_or(version); - out.resolved_elsewhere(YARN_LOCK, npm_purl(name, version)); + out.unpatched_copy( + YARN_LOCK, + npm_purl(name, version), + &block.key, + "installs it from the registry, not a Socket patch (yarn berry installs \ + every locator the lock resolves)", + ); return; }; (archive, Some(version)) @@ -417,7 +428,12 @@ fn berry_block( // A custom-registry `__archiveUrl`: the registry package. if let (Some(v), false) = (locator_version, root_anchored_spelling(spec)) { let version = berry_field(&block.lines, "version").unwrap_or(v); - out.resolved_elsewhere(YARN_LOCK, npm_purl(name, version)); + out.unpatched_copy( + YARN_LOCK, + npm_purl(name, version), + &block.key, + &format!("installs it from {spec:?}, not a Socket patch"), + ); } else if locator_version.is_none() && !root_anchored_spelling(spec) { // A user's `file:` / url copy: yarn keys it by the DEPENDENCY // name (`lp2@file:…`), so which package it installs is read @@ -1464,6 +1480,47 @@ mod tests { } } + /// A berry registry locator beside a hosted one of the same + /// `name@version` (scoped `resolutions`, or a workspace member added + /// after the rewire, then `yarn install`) installs the registry bytes + /// too, so the hosted ref is contested in the same lock — the rule + /// `vex`'s yarn PnP loader check relies on, since a loader that names + /// both locators still names the patch (#1033 review). A registry + /// locator of another version contests nothing. + #[tokio::test] + async fn berry_registry_locator_beside_a_hosted_one_contests_it() { + let url = hosted_url("npm", "left-pad", "1.3.0", UUID_A, "left-pad-1.3.0.tgz"); + let hosted = berry_block( + "left-pad@npm:1.3.0", + "1.3.0", + &format!("left-pad@npm:1.3.0::__archiveUrl={}", archive(&url)), + Some("10c0/aaaa"), + ); + for (version, contested) in [("1.3.0", true), ("1.2.0", false)] { + let registry = berry_block( + &format!("left-pad@npm:^{version}"), + version, + &format!("left-pad@npm:{version}"), + Some("10c0/bbbb"), + ); + let p = Project::new(); + p.write("yarn.lock", berry(&[hosted.clone(), registry])); + let out = run(&p).await; + assert_eq!(out.refs.is_empty(), contested, "{version}: {:#?}", out.refs); + if contested { + assert!( + out.diagnostics + .iter() + .any(|d| d.code == DIAG_REF_UNATTRIBUTABLE + && d.detail.contains("left-pad@npm:^1.3.0") + && d.detail.contains("UNPATCHED")), + "{:#?}", + out.diagnostics + ); + } + } + } + /// The vendored berry pair exactly as `vendor::yarn_berry_lock` writes it /// — spike B3's lock entry plus the root `package.json` `resolutions` /// value — for a plain and a scoped package. 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 32a8dedda..6b78afbec 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 @@ -6516,7 +6516,15 @@ "file": "yarn.lock" } ], - "live_claims": [] + "live_claims": [], + "unpatched_copies": [ + { + "purl": "pkg:npm/left-pad@1.3.0", + "file": "yarn.lock", + "key": "\"left-pad@npm:^1.3.0\"", + "how": "installs it from the registry, not a Socket patch (yarn berry installs every locator the lock resolves)" + } + ] }, "redirect/npm/yarn-berry/cachekey-mismatch-refusal/input": { "refs": [], @@ -6529,7 +6537,15 @@ "file": "yarn.lock" } ], - "live_claims": [] + "live_claims": [], + "unpatched_copies": [ + { + "purl": "pkg:npm/left-pad@1.3.0", + "file": "yarn.lock", + "key": "\"left-pad@npm:^1.3.0\"", + "how": "installs it from the registry, not a Socket patch (yarn berry installs every locator the lock resolves)" + } + ] }, "redirect/npm/yarn-berry/existing-archive-url/expected": { "refs": [ @@ -6589,7 +6605,15 @@ "file": "yarn.lock" } ], - "live_claims": [] + "live_claims": [], + "unpatched_copies": [ + { + "purl": "pkg:npm/left-pad@1.3.0", + "file": "yarn.lock", + "key": "\"left-pad@npm:^1.3.0\"", + "how": "installs it from \"https%3A%2F%2Fregistry.corp.example%2Fleft-pad-1.3.0.tgz\", not a Socket patch" + } + ] }, "redirect/npm/yarn-berry/missing-berry-checksum/input": { "refs": [], @@ -6602,7 +6626,15 @@ "file": "yarn.lock" } ], - "live_claims": [] + "live_claims": [], + "unpatched_copies": [ + { + "purl": "pkg:npm/left-pad@1.3.0", + "file": "yarn.lock", + "key": "\"left-pad@npm:^1.3.0\"", + "how": "installs it from the registry, not a Socket patch (yarn berry installs every locator the lock resolves)" + } + ] }, "redirect/npm/yarn-berry/multi-descriptor-key/expected": { "refs": [ @@ -6662,7 +6694,15 @@ "file": "yarn.lock" } ], - "live_claims": [] + "live_claims": [], + "unpatched_copies": [ + { + "purl": "pkg:npm/left-pad@1.3.0", + "file": "yarn.lock", + "key": "\"left-pad@npm:^1.0.0, left-pad@npm:^1.3.0\"", + "how": "installs it from the registry, not a Socket patch (yarn berry installs every locator the lock resolves)" + } + ] }, "redirect/npm/yarn-berry/multiple-versions/expected": { "refs": [ @@ -6714,6 +6754,14 @@ "uuid": "77777777-7777-7777-7777-777777777777", "purl": "pkg:npm/left-pad@1.3.0" } + ], + "unpatched_copies": [ + { + "purl": "pkg:npm/left-pad@1.0.0", + "file": "yarn.lock", + "key": "\"left-pad@npm:^1.0.0\"", + "how": "installs it from the registry, not a Socket patch (yarn berry installs every locator the lock resolves)" + } ] }, "redirect/npm/yarn-berry/multiple-versions/input": { @@ -6731,7 +6779,21 @@ "file": "yarn.lock" } ], - "live_claims": [] + "live_claims": [], + "unpatched_copies": [ + { + "purl": "pkg:npm/left-pad@1.0.0", + "file": "yarn.lock", + "key": "\"left-pad@npm:^1.0.0\"", + "how": "installs it from the registry, not a Socket patch (yarn berry installs every locator the lock resolves)" + }, + { + "purl": "pkg:npm/left-pad@1.3.0", + "file": "yarn.lock", + "key": "\"left-pad@npm:^1.3.0\"", + "how": "installs it from the registry, not a Socket patch (yarn berry installs every locator the lock resolves)" + } + ] }, "redirect/npm/yarn-berry/rerun-noop/input": { "refs": [ @@ -6838,7 +6900,15 @@ "file": "yarn.lock" } ], - "live_claims": [] + "live_claims": [], + "unpatched_copies": [ + { + "purl": "pkg:npm/@babel/core@7.0.0", + "file": "yarn.lock", + "key": "\"@babel/core@npm:^7.0.0\"", + "how": "installs it from the registry, not a Socket patch (yarn berry installs every locator the lock resolves)" + } + ] }, "redirect/npm/yarn-berry/yarnrc-compression-refusal/input": { "refs": [], @@ -6851,7 +6921,15 @@ "file": "yarn.lock" } ], - "live_claims": [] + "live_claims": [], + "unpatched_copies": [ + { + "purl": "pkg:npm/left-pad@1.3.0", + "file": "yarn.lock", + "key": "\"left-pad@npm:^1.3.0\"", + "how": "installs it from the registry, not a Socket patch (yarn berry installs every locator the lock resolves)" + } + ] }, "redirect/npm/yarn-classic/alias-guard/input": { "refs": [], From e9a33b8515d9efaf6b4650c2ae42f342acc933e1 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Wed, 7 Oct 2026 12:33:12 -0400 Subject: [PATCH 11/11] Document unattested pnpm bundled and deno.lock refs in CLI_CONTRACT Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/socket-patch-cli/CLI_CONTRACT.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index d921244f3..76f78ea75 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -373,13 +373,14 @@ Discovery is read-only, never touches the network, and never fails the run: a ma | maven | `pom.xml` (+ `.mvn/maven.config`, `.mvn/checksums/checksums.sha256`) | a dependency version `-socket.` matching exactly ONE `socket-patch-` repository on the patch host | `socket-patch-vendor-` repository + exactly one jar under `.socket/vendor/maven//` with a matching `.sha1` | Trusted Checksums line when enabled; not required (the suffixed version is the pin) | | gradle (v5.0) | `.socket/gradle/hosted-index.tsv`, the owned hosted script, and every settings script and lock file the script graph reaches | an index row whose repository URL is on the patch host and names the row's uuid, live only while the owned script is intact, every build's settings file applies it with the current index digest, every lock entry of the GA is the suffixed version, no `settings-gradle.lockfile` names the GA and no build script sets a custom `lockFile` (otherwise `patched_ref_invalid`) | none here: a vendored Gradle entry is gated by its ledger entry's wiring check | the suffixed copies installed in the Gradle cache; required (never the lock basis), because the script lets a higher upstream version resolve | | nuget | the first of `nuget.config` / `NuGet.config` / `NuGet.Config`, + `packages.lock.json` | source `socket-patch-` + its exclusive exact-id ``; version from `packages.lock.json` | the same mapping onto `.socket/vendor/nuget/`; version from the lock, else the feed's single nupkg | `contentHash`, required | -| deno | `deno.lock`'s npm section, only as a contesting lock: Deno installs a `package.json` project's npm deps from it and never reads `package-lock.json`, so an npm-family pin of a `name@version` it also locks is contested (#406) | — (no hosted mode) | — (no vendored backend) | — | +| deno | `deno.lock`'s npm section, only as evidence against an npm-family pin of a `name@version` it also locks, which `vex` then omits (see **Unattested references**, #406) | — (no hosted mode) | — (no vendored backend) | — | Recognition rules that hold for every ecosystem: * **Patch hosts.** A hosted reference counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin, with no userinfo. The uuid is the URL's LAST canonical-uuid path segment, because grant tokens may themselves be uuid-shaped. The Go module prefix is fixed. `socket-patch-` registry / repository / source names count only through a pin. For a URL on any other host, see **Patch hosts** above. * **Pins, not definitions.** A registry, index or source *definition* alone (cargo `[registries]`, nuget ``, pom ``, uv index tables, `.npmrc`) never makes a reference, because it survives a reverted pin. Sections the package manager ignores are not read: npm's v2 `dependencies` mirror, a `.cargo/config.toml` shadowed by `.cargo/config`. A Socket pin inside a maven `` is diagnosed, never a reference. -* **Contested locks.** When one lock wires a package to a patch and another lock resolves the same `name@version` from a non-Socket source, the build's bytes depend on which package manager runs. The reference is then dropped with a `patched_ref_unattributable` diagnostic naming both files. This applies across npm / pnpm / yarn / bun and across uv / pylock / poetry / pdm / Pipfile.lock / requirements. PEP 723 script locks neither contest nor are contested. A **bundled** npm copy (`inBundle: true`, or v1 `bundled: true`) of the same `name@version` contests the reference too, in the same lock, in the other npm lock of a shrinkwrap/package-lock pair, or in any other lock. npm unpacks it from the parent package's tarball, so no rewire reaches it and it stays unpatched. Bun and vlt unpack bundled copies the same way (#469, #471). So does pnpm: a `packages:` entry's `bundledDependencies:` names the copies it unpacks from its own tarball, but pnpm never locks them, so the bundled version is not in the lock. A pnpm reference whose package name a `bundledDependencies` list in the same lock names is contested whatever its version (a missed attestation when the bundled copy is another version, never a false one), and `bundledDependencies: true` contests every reference of that lock. For Bun, that is a `bun.lock` entry whose meta is `{ "bundled": true }`, or a `bun.lockb` record that a dependency edge with the `bundled` behavior bit reaches, including one Bun shares with a regular install. Such an entry is never a reference, and it contests the reference the same way. For vlt, the lock records no node for a bundled copy, so the copy is found in the installed store: a real package directory inside a store package's own `node_modules`. Hosted and vendored scans skip these copies with `redirect_bun_bundled_instance_skipped` / `redirect_vlt_bundled_instance_skipped` / `vendor_bundled_instance_skipped`. When a bundled copy is the only instance, vendoring refuses with `vendor_lock_entry_not_rewritable`. Another entry of the **same** npm or Bun lock that resolves the wired `name@version` from a non-Socket source (for example a workspace member added after the rewire, then `npm install` / `bun install`) contests the reference too (#588). The package manager installs both entries, and that copy stays unpatched. Re-running `scan` / `vendor` rewires every copy. +* **Contested locks.** When one lock wires a package to a patch and another lock resolves the same `name@version` from a non-Socket source, the build's bytes depend on which package manager runs. The reference is then dropped with a `patched_ref_unattributable` diagnostic naming both files. This applies across npm / pnpm / yarn / bun and across uv / pylock / poetry / pdm / Pipfile.lock / requirements. PEP 723 script locks neither contest nor are contested. A **bundled** npm copy (`inBundle: true`, or v1 `bundled: true`) of the same `name@version` contests the reference too, in the same lock, in the other npm lock of a shrinkwrap/package-lock pair, or in any other lock. npm unpacks it from the parent package's tarball, so no rewire reaches it and it stays unpatched. Bun and vlt unpack bundled copies the same way (#469, #471). pnpm unpacks bundled copies too, but its lock cannot tie one to a reference (see **Unattested references** below). For Bun, that is a `bun.lock` entry whose meta is `{ "bundled": true }`, or a `bun.lockb` record that a dependency edge with the `bundled` behavior bit reaches, including one Bun shares with a regular install. Such an entry is never a reference, and it contests the reference the same way. For vlt, the lock records no node for a bundled copy, so the copy is found in the installed store: a real package directory inside a store package's own `node_modules`. Hosted and vendored scans skip these copies with `redirect_bun_bundled_instance_skipped` / `redirect_vlt_bundled_instance_skipped` / `vendor_bundled_instance_skipped`. When a bundled copy is the only instance, vendoring refuses with `vendor_lock_entry_not_rewritable`. Another entry of the **same** npm, Bun or yarn lock that resolves the wired `name@version` from a non-Socket source (for example a workspace member added after the rewire, then `npm install` / `bun install` / `yarn install`; for yarn berry, a registry locator such as `left-pad@npm:1.3.0` beside the hosted `…::__archiveUrl=` one) contests the reference too (#588). The package manager installs both entries, and that copy stays unpatched. Re-running `scan` / `vendor` rewires every copy. +* **Unattested references.** Some evidence shows a build may run a copy no wiring reaches, but cannot be tied to the reference's exact `name@version` or cannot say the build runs it. The reference then stays a reference: `list`, `rollback` and `remove` find it, and the ledgers' liveness gates (`vendor --check`, `scan`) keep treating the wiring as live, because re-running `scan` / `vendor` could never clear the evidence. Only `vex` omits it, as a run warning and as the `failed[].reason`. Two cases besides Gradle's (`vex_gradle_lock_above_base`): **pnpm bundled copies** (`vex_pnpm_bundled_copy`): a `packages:` entry's `bundledDependencies:` names the copies pnpm unpacks from that package's own tarball, but pnpm never locks them, so the bundled version is not in the lock. A pnpm reference whose package name a `bundledDependencies` list in the same lock names is omitted whatever its version (a missed attestation when the bundled copy is another version, never a false one), and `bundledDependencies: true` (or a value that cannot be read) omits every reference of that lock. It reaches no other lock. **deno.lock** (`vex_deno_lock_copy`): `deno install` installs a `package.json` project's npm dependencies from `deno.lock` and never reads `package-lock.json` / `pnpm-lock.yaml` / `yarn.lock`, so an npm-family reference whose `name@version` the `deno.lock` npm section also locks is omitted (#406). Whether Deno or another package manager populates `node_modules` (`nodeModulesDir: "manual"` allows either) is not in the files, so this holds whatever `nodeModulesDir` says. * **Lockless pins.** With no lock to name a version, a `Cargo.toml` pin (every declaration on `socket-patch-`, that registry defined on the patch host for the same uuid) or an exclusive nuget exact-id mapping is never a reference on its own, so v5.0 does not attest it (nor does `list` show it, or `rollback` / `remove` restore it — restore those files from version control). Only a pre-v5 redirect-ledger record naming a version the pin admits keeps it live. The same holds for a gem wired only in the `Gemfile` (the pre-bundler-2.6 mixed state, lock not converged). **Record resolution.** A candidate's record must carry the patch uuid the lockfile actually **wires**. It is taken from the first source that has one: the manifest (matched qualifier-insensitively), the hosted records above (this run's, then a pre-v5 ledger's), then the vendor ledger's embedded records. If none has it and the run is online, `vex` fetches the patch view by uuid from the patch API — for a v5 hosted checkout this is the normal path. The fetch uses `get`'s API client: the public proxy when no token is configured, and a one-shot 401/403 fallback to the proxy (free patches only). At most 10 fetches run concurrently. Fetched records stay in memory: `vex` never writes the manifest. A candidate still has no record under `--offline`, after a transport error or a 404, or when the patch is refused (paid without an entitled token); it is then omitted as `record_unavailable`, and the run is not aborted. A record whose uuid or package disagrees with the wiring is omitted as `record_mismatch`. The informational `socket-patch.vendor.json` marker is never a record source. When the lockfile wires a package to patch U, a manifest or ledger record for that package under another uuid is superseded, and a human-mode `Note:` says so. @@ -2134,6 +2135,8 @@ match it, withholds the statement: | `vex_gradle_unpatched_copy` | run warning | A copy a build may load does not carry the patch; no statement until every copy does. | | `gradle_unpatched_copy` | `failed[].reason` | The purl the warning above withheld. | | `vex_gradle_lock_above_base` | run warning and `failed[].reason` | A hosted pin is wired, but a lock file records a release above its base, which that build resolves instead of the patch; no statement until it is re-locked or the patch is rolled back. | +| `vex_pnpm_bundled_copy` | run warning and `failed[].reason` | An npm pin is wired, but its pnpm lock names a package whose `bundledDependencies` may ship an unpatched copy of it (the lock does not record that copy's version); no statement while that package bundles it. `vendor --check` and `scan` still treat the wiring as live. | +| `vex_deno_lock_copy` | run warning and `failed[].reason` | An npm pin is wired, but `deno.lock` locks the same `name@version`, which `deno install` installs without the wiring; no statement while it does. `vendor --check` and `scan` still treat the wiring as live. | | `vex_gradle_derived_cache_unchecked` | run warning | The derived-cache walk was cut short (a very large transforms cache); the statement is still emitted, the copies the walk did reach were checked. | A vendored Gradle entry attests only while its wiring is live (apply line, index rows,