Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -2023,7 +2023,10 @@ wiring: an entry whose lockfile or config no longer references its
`npm install` re-resolved it) fails by the same liveness rule as `vex`'s
`vendor_unwired`. The reason names the cause: another lock resolving the same
version from elsewhere (`wiring contested`, naming both locks; delete the one
the project does not install from), or, for npm and PyPI, a dependency no lock
the project does not install from), an unpatched copy of the same version
that no rewire reaches installing beside the wiring, such as a bundled copy or
an npm git / url / `file:` dependency (`copy unpatched`, naming that lock
entry; re-vendoring cannot reach it, #1511), or, for npm and PyPI, a dependency no lock
resolves any more (`dependency removed`; `scan --mode vendored --prune` reverts
the entry), or, for PyPI, a project file that still names the artifact while
nothing installs from it (`wiring incomplete`, naming the file; re-lock, then
Expand Down
17 changes: 17 additions & 0 deletions crates/socket-patch-cli/src/commands/vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,23 @@ async fn unwired_check_failure(
c.other.display(),
);
}
if let Some((r, diag)) = discovery.vendored_shadow(&entry.base_purl, &entry.uuid) {
// Re-vendoring cannot reach that copy: name it instead (#1511).
let copy = diag.map_or_else(
|| {
format!(
"{} wires {dir}, but the build also installs an unpatched copy of the \
same version",
r.source_file.display()
)
},
|d| d.detail.clone(),
);
return format!(
"copy unpatched: {copy}; re-vendoring cannot reach that copy, so depend on the \
registry release there (or remove that copy) to have it patched"
);
}
// Only the npm-family and Python extractors record every lock entry
// (`resolved_elsewhere`), so only there does "no lock resolves it"
// prove the dependency is gone rather than unreadable.
Expand Down
154 changes: 154 additions & 0 deletions crates/socket-patch-cli/tests/in_process_vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3123,6 +3123,160 @@ async fn vendor_json_vex_warnings_ride_in_envelope() {
);
}

/// REGRESSION (#1511): vendored npm's half of #1481. Under
/// `install-strategy=nested` one workspace member's registry copy is
/// vendored while another member depends on a `file:` directory holding
/// the same release. npm links that directory in place of a registry
/// copy, so it stays unpatched: neither the run's `--vex` nor a standalone
/// `vex` may attest the purl.
#[tokio::test]
async fn vendor_npm_file_directory_copy_is_not_attested() {
let fx = npm_fixture();
// VEX refuses a metadata-less patch, so give it a vulnerability.
let mut manifest: Value =
serde_json::from_slice(&std::fs::read(fx.manifest_path()).unwrap()).unwrap();
for (_, patch) in manifest["patches"].as_object_mut().unwrap() {
patch["vulnerabilities"] = json!({
"GHSA-file-dir-copy": {
"cves": ["CVE-2024-15110"],
"summary": "file: directory copy fixture",
"severity": "high",
"description": "d",
}
});
}
std::fs::write(
fx.manifest_path(),
serde_json::to_vec_pretty(&manifest).unwrap(),
)
.unwrap();
// npm 10's nested-workspace lock: `a`'s registry copy, and `b`'s
// versionless link to `lpdir`, whose own entry carries the release.
let mut lock = fx.lock_value();
let packages = lock["packages"].as_object_mut().unwrap();
let registry = packages.remove("node_modules/left-pad").unwrap();
packages.insert(
"".into(),
json!({
"name": "fixture", "version": "1.0.0", "workspaces": ["packages/*"]
}),
);
packages.insert(
"lpdir".into(),
json!({ "name": "left-pad", "version": "1.3.0" }),
);
packages.insert(
"node_modules/a".into(),
json!({ "resolved": "packages/a", "link": true }),
);
packages.insert(
"node_modules/b".into(),
json!({ "resolved": "packages/b", "link": true }),
);
packages.insert(
"packages/a".into(),
json!({
"name": "a", "version": "1.0.0", "dependencies": { "left-pad": "1.3.0" }
}),
);
packages.insert("packages/a/node_modules/left-pad".into(), registry);
packages.insert(
"packages/b".into(),
json!({
"name": "b", "version": "1.0.0", "dependencies": { "left-pad": "file:../../lpdir" }
}),
);
packages.insert(
"packages/b/node_modules/left-pad".into(),
json!({ "resolved": "lpdir", "link": true }),
);
std::fs::write(fx.lock_path(), serde_json::to_vec_pretty(&lock).unwrap()).unwrap();
// The installed tree: `a`'s registry copy, `b`'s link target.
for dir in ["packages/a/node_modules/left-pad", "lpdir"] {
let pkg = fx.root().join(dir);
std::fs::create_dir_all(&pkg).unwrap();
std::fs::write(
pkg.join("package.json"),
br#"{"name":"left-pad","version":"1.3.0"}"#,
)
.unwrap();
std::fs::write(pkg.join("index.js"), ORIG_INDEX).unwrap();
}

let vex_path = fx.root().join("vendored.vex.json");
let (code, env) = vendor_cli(
fx.root(),
&["--vex", vex_path.to_str().expect("vex path is UTF-8")],
);
let wired = fx.lock_value();
assert_eq!(
wired["packages"]["packages/a/node_modules/left-pad"]["resolved"],
json!(format!("file:{}", rel_tgz())),
"the registry copy is still vendored: {env}"
);
// The in-run VEX ran and withheld the ref, naming the unpatched copy;
// with nothing left to attest it fails closed, as for a `file:`
// tarball or URL copy.
assert_eq!(code, 1, "{env}");
assert_eq!(env["error"]["code"], "no_applicable_patches", "{env}");
assert!(
env["warnings"]
.as_array()
.is_some_and(|w| w.iter().any(|w| {
w["code"] == "patched_ref_unattributable"
&& w["detail"]
.as_str()
.is_some_and(|d| d.contains("`packages/b/node_modules/left-pad`"))
})),
"{env}"
);
// No document is written when no statement survives, so a missing
// file is the expected "attests nothing" outcome.
let attested = |path: &Path| {
std::fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str::<Value>(&text).ok())
.is_some_and(|doc| doc.to_string().contains(PURL))
};
assert!(
!attested(&vex_path),
"in-run VEX must not attest a purl whose file: directory copy stays unpatched: {env}"
);

// `vendor --check` fails, naming the copy rather than claiming nothing
// wires the artifact and prescribing a re-vendor that cannot reach it.
let (check, check_env) = vendor_cli(fx.root(), &["--check"]);
assert_eq!(check, 1, "{check_env}");
let reason = check_env["events"][0]["reason"]
.as_str()
.unwrap_or_default();
assert!(
reason.starts_with("copy unpatched:")
&& reason.contains("`packages/b/node_modules/left-pad`"),
"{check_env}"
);
let standalone = fx.root().join("standalone.vex.json");
let (_, stdout, stderr) = run_cli(
fx.root(),
&[
"vex",
"--cwd",
fx.root().to_str().unwrap(),
"--output",
standalone.to_str().unwrap(),
],
&[],
);
assert!(
!attested(&standalone),
"standalone vex must not attest it either\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
assert!(
stderr.contains("`packages/b/node_modules/left-pad`") && stderr.contains("UNPATCHED"),
"the unpatched copy is named\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
}

// ─────────────────────────────────────────────────────────────────────
// 12. fail-closed --vendor-source=service refuses --offline
// ─────────────────────────────────────────────────────────────────────
Expand Down
30 changes: 5 additions & 25 deletions crates/socket-patch-core/src/patch/redirect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ use crate::utils::line_endings::LineEndings;
use crate::vendor::common::{parse_json_text, JsonLayout};
use crate::vendor::lock_inventory::{npm_lock_entries, NpmLockEntry, NpmLockSection};
use crate::vendor::npm_origin::{
npm_non_registry_entries, npm_shrinkwrapped_entries, NpmOverrides,
npm_link_source_identity, npm_non_registry_entries, npm_shrinkwrapped_entries, NpmOverrides,
};

mod bun_binary;
Expand Down Expand Up @@ -1404,29 +1404,6 @@ impl From<NpmLockEntry<'_>> for NpmLockTarget {
}
}

/// The `(name, version)` of the source directory an npm `packages` link
/// entry (at `pointer`) points at: the entry its `resolved` path names,
/// by that entry's `name` field, else its folder name, which keeps an
/// `@scope/` parent as npm's `nameFromFolder` does (npm omits `name` when
/// they agree). `None` when the lock records no version.
fn npm_link_source_identity(lock: &Value, pointer: &str) -> Option<(String, String)> {
let resolved = lock.pointer(pointer)?.get("resolved")?.as_str()?;
let source = lock.get("packages")?.get(resolved)?;
let version = source.get("version")?.as_str()?;
let name = match source.get("name").and_then(Value::as_str) {
Some(name) => name.to_string(),
None => {
let mut segments = resolved.rsplit('/');
let base = segments.next().unwrap_or(resolved);
match segments.next() {
Some(scope) if scope.starts_with('@') => format!("{scope}/{base}"),
_ => base.to_string(),
}
}
};
Some((name, version.to_string()))
}

/// Rewrite a single npm lockfile (`package-lock.json` or `npm-shrinkwrap.json`)
/// in place. Factored out of `rewrite_npm_lock` so co-present locks each get
/// the identical override rewrite (see the dual-lock note there).
Expand Down Expand Up @@ -1470,7 +1447,10 @@ fn rewrite_one_npm_lock(
.iter_mut()
.filter(|t| t.link && t.version.is_none() && non_registry.contains_key(&t.key))
{
if let Some((name, version)) = npm_link_source_identity(&lock, &target.pointer) {
if let Some((name, version)) = lock
.pointer(&target.pointer)
.and_then(|link| npm_link_source_identity(&lock, link))
{
target.name = name;
target.version = Some(version);
}
Expand Down
23 changes: 23 additions & 0 deletions crates/socket-patch-core/src/vendor/npm_origin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,29 @@ pub(crate) fn npm_non_registry_entries(
out
}

/// The `(name, version)` of the source directory an npm `packages` link
/// entry points at: the entry its `resolved` path names,
/// by that entry's `name` field, else its folder name, which keeps an
/// `@scope/` parent as npm's `nameFromFolder` does (npm omits `name` when
/// they agree). `None` when the lock records no version.
pub(crate) fn npm_link_source_identity(lock: &Value, link: &Value) -> Option<(String, String)> {
let resolved = link.get("resolved")?.as_str()?;
let source = lock.get("packages")?.get(resolved)?;
let version = source.get("version")?.as_str()?;
let name = match source.get("name").and_then(Value::as_str) {
Some(name) => name.to_string(),
None => {
let mut segments = resolved.rsplit('/');
let base = segments.next().unwrap_or(resolved);
match segments.next() {
Some(scope) if scope.starts_with('@') => format!("{scope}/{base}"),
_ => base.to_string(),
}
}
};
Some((name, version.to_string()))
}

/// Every `packages` key installed beneath a package that ships its own
/// `npm-shrinkwrap.json` (the lock marks that ancestor `"hasShrinkwrap":
/// true`, e.g. `firebase-tools`, `netlify-cli`), mapped to the outermost
Expand Down
20 changes: 20 additions & 0 deletions crates/socket-patch-core/src/vex/discover/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1048,6 +1048,26 @@ impl Discovery {
})
}

/// The withholding that killed a VENDORED ledger claim because the
/// build also installs an unpatched copy of the same version that no
/// rewire reaches (a bundled copy, or one npm installs from a git /
/// url / `file:` spec): the wiring ref moved to [`Discovery::shadowed`],
/// with the [`DIAG_REF_UNATTRIBUTABLE`] diagnostic naming that copy
/// when one does. Lets a reader of a dead claim name the copy instead
/// of saying nothing wires the artifact (#1511).
pub fn vendored_shadow(&self, purl: &str, uuid: &str) -> Option<(&PatchedRef, Option<&Diag>)> {
let key = canonical_base_purl(purl);
let shadowed = self.shadowed.iter().find(|r| {
r.uuid == uuid && r.mode == WiringMode::Vendored && PurlKey::same(&r.purl, &key)
})?;
let diag = self.diagnostics.iter().find(|d| {
d.code == DIAG_REF_UNATTRIBUTABLE
&& d.file == shadowed.source_file
&& d.detail.contains(shadowed.purl.as_str())
});
Some((shadowed, diag))
}

/// Whether any lock discovery read resolves `purl` (any spelling) at
/// all: wired to a Socket patch, contested, or from elsewhere
/// ([`Discovery::resolved_elsewhere`]).
Expand Down
Loading
Loading