diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md
index b7f8c64f6..b987b6fe8 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 | none | — (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). 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.
@@ -389,7 +390,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:
@@ -2150,6 +2151,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,
diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs
index 909766f82..b2ce4cd02 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/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_lockfile/yarn.rs b/crates/socket-patch-cli/tests/e2e_vex_lockfile/yarn.rs
index d547e4d72..f40652d88 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);
@@ -1204,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-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/crawlers/mod.rs b/crates/socket-patch-core/src/crawlers/mod.rs
index 7d7b1b74c..d10399d62 100644
--- a/crates/socket-patch-core/src/crawlers/mod.rs
+++ b/crates/socket-patch-core/src/crawlers/mod.rs
@@ -31,7 +31,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 dddbad4a7..4351f540c 100644
--- a/crates/socket-patch-core/src/crawlers/pkg_managers.rs
+++ b/crates/socket-patch-core/src/crawlers/pkg_managers.rs
@@ -313,10 +313,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"
+ );
+ }
+
/// #975: yarn 4 keeps a Yarn 2 `.pnp.js` after a switch to the
/// node-modules or pnpm linker; yarn ignores it, so must detection.
#[test]
diff --git a/crates/socket-patch-core/src/formats/pnpm/grammar.rs b/crates/socket-patch-core/src/formats/pnpm/grammar.rs
index c6b435bfe..40fd21fba 100644
--- a/crates/socket-patch-core/src/formats/pnpm/grammar.rs
+++ b/crates/socket-patch-core/src/formats/pnpm/grammar.rs
@@ -107,6 +107,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::