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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,15 @@ limits, and required install commands.
fetches honor `GOPROXY` and private-module settings.
- Yarn Berry preserves supported line endings and checksum spellings. Mode
preflights, including Bun's, run before discarding existing protection.
- Yarn Berry hosted references no longer send npm registry credentials to the
patch server. The old `npm:` locator made yarn attach `npmAuthToken` /
`YARN_NPM_AUTH_TOKEN` to scoped packages (and to every package under
`npmAlwaysAuth`). Hosted mode now pins the way yarn does for a root
`resolutions` entry: `package.json` routes the locked descriptor to the
hosted tarball and the lock entry is keyed by it, which also passes yarn's
hardened mode (on by default for public pull request CI). A user-authored
`resolutions` entry for the package is never overwritten. Locks pinned by
earlier releases are re-pinned on the next hosted `scan`.
- Composer hosted references remove upstream source fallbacks and mirrors;
RubyGems hosted locks preserve source order; NuGet edits use the active config
and survive `<clear />` entries.
Expand Down
4 changes: 2 additions & 2 deletions crates/socket-patch-cli/CLI_CONTRACT.md

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions crates/socket-patch-cli/src/commands/scan/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4343,6 +4343,32 @@ mod tests {
assert!(takeover.vendored.is_empty(), "{takeover:?}");
}

#[tokio::test]
async fn hosted_direction_provable_for_berry_tarball_locator() {
// Today's berry pin is the plain tarball-URL locator (#404).
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write_vendor_ledger_wired(root, &["pkg:npm/minimist@1.2.2"]).await;
tokio::fs::write(
root.join("yarn.lock"),
format!(
"__metadata:\n version: 8\n cacheKey: 10c0\n\n\
\"minimist@npm:1.2.2\":\n version: 1.2.2\n \
resolution: \"minimist@https://patch.socket.dev/patch/npm/{TAKEOVER_TOKEN}/{TAKEOVER_UUID}/minimist-1.2.2.tgz\"\n"
),
)
.await
.unwrap();

let takeover = classify_overlap_takeover(&common_at(root), root).await;
assert_eq!(
takeover.redirect,
vec!["pkg:npm/minimist@1.2.2".to_string()],
"a berry tarball locator must prove hosted is live"
);
assert!(takeover.vendored.is_empty(), "{takeover:?}");
}

#[tokio::test]
async fn vendored_path_uuid_is_not_a_hosted_pin() {
// The vendored wiring embeds the SAME patch uuid in its
Expand Down
26 changes: 18 additions & 8 deletions crates/socket-patch-cli/tests/e2e_hosted_production.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1467,16 +1467,19 @@ fn yarn_berry_hosted_install_proof() {
}
assert_pristine(&minimist_entry(&fx.proj), PATCH_MARKER, LEG);
let registry_lock = std::fs::read(fx.proj.join("yarn.lock")).expect("registry yarn.lock");
// The hosted pin also routes through package.json `resolutions` (#404),
// so a revert to the registry restores both files.
let registry_pkg = std::fs::read(fx.proj.join("package.json")).expect("registry package.json");

let env_json = scan_hosted(&fx.proj, &[]);
assert_redirected(&env_json, "yarn.lock");
let lock = read(&fx.proj.join("yarn.lock"));
// Berry pins the hosted artifact through a percent-encoded `__archiveUrl`
// resolution field, so the plain host string is encoded — check both the
// encoded host and the (unencoded) patch UUID.
// Berry pins the hosted artifact as a plain tarball-URL locator — never
// an `npm:` one (`::__archiveUrl=`), whose fetcher would send the npm
// registry token to the patch host (#404).
assert!(
lock.contains("__archiveUrl") && lock.contains("patch.socket.dev"),
"{LEG}: berry lock carries no __archiveUrl pointing at the patch \
lock.contains("@https://patch.socket.dev/") && !lock.contains("__archiveUrl"),
"{LEG}: berry lock carries no tarball locator pointing at the patch \
host:\n{lock}"
);
assert!(
Expand All @@ -1497,7 +1500,7 @@ fn yarn_berry_hosted_install_proof() {
);
assert_patched(&minimist_entry(&fx.proj), PATCH_MARKER, LEG);

yarn_berry_hosted_manifestless_vex(&fx, &registry_lock, &env);
yarn_berry_hosted_manifestless_vex(&fx, &registry_lock, &registry_pkg, &env);
}

/// One `vex --json --output <tmp>/berry.vex.json` run in `proj` (production
Expand Down Expand Up @@ -1582,7 +1585,12 @@ fn berry_skip_code(env: &serde_json::Value) -> String {
/// `--offline` (`record_unavailable`), and not once the lock is reverted to
/// the registry and reinstalled (nothing names the patch, even with
/// `--no-verify`).
fn yarn_berry_hosted_manifestless_vex(fx: &NpmFixture, registry_lock: &[u8], env: &[(&str, &str)]) {
fn yarn_berry_hosted_manifestless_vex(
fx: &NpmFixture,
registry_lock: &[u8],
registry_pkg: &[u8],
env: &[(&str, &str)],
) {
const LEG: &str = "yarn_berry_hosted_install_proof (manifest-less vex)";
let proj = &fx.proj;
assert!(
Expand All @@ -1600,8 +1608,10 @@ fn yarn_berry_hosted_manifestless_vex(fx: &NpmFixture, registry_lock: &[u8], env
assert_eq!(berry_skip_code(&env_json), "record_unavailable", "{LEG}");
assert!(doc.is_none(), "{LEG}: no document");

// Revert the lock to the registry and reinstall.
// Revert the lock and the package.json `resolutions` pin to the
// registry and reinstall.
std::fs::write(proj.join("yarn.lock"), registry_lock).unwrap();
std::fs::write(proj.join("package.json"), registry_pkg).unwrap();
std::fs::remove_dir_all(proj.join("node_modules")).ok();
let reinstall = tool(proj, "yarn", &["install", "--immutable"], env);
assert!(
Expand Down
118 changes: 103 additions & 15 deletions crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
//! `e2e_redirect_npm_build.rs`.
//!
//! `scan --mode hosted` never lands patched bytes in the repo: it rewrites
//! `yarn.lock` so the patched dependency resolves via
//! `npm:<v>::__archiveUrl=<hosted-tgz>` with `checksum: 10c0/<hex>` (yarn's
//! `yarn.lock` so the patched dependency resolves via the tarball-URL
//! locator `<name>@<hosted-tgz>` with `checksum: 10c0/<hex>` (yarn's
//! cache-zip sha512); v5 keeps no redirect ledger — the lock pin is the
//! whole hosted state. This test proves every link against the REAL
//! `corepack yarn@4.12.0`:
Expand All @@ -16,16 +16,19 @@
//! extract the EXACT `10c0/<hex>` checksum yarn computes for that
//! tarball's cache zip — the value the redirect mock must hand back
//! (yarn recomputes the same zip checksum whether the locator is `file:`
//! or `::__archiveUrl=`, so `--check-cache` will accept it).
//! or a tarball URL, so `--check-cache` will accept it).
//! 3. `scan --mode hosted --json --vex` (the real binary): yarn.lock now
//! pins the hosted `__archiveUrl` + the `10c0` checksum, NO ledger is
//! pins the hosted tarball URL + the `10c0` checksum, NO ledger is
//! written, the in-run VEX is the `(redirected)` attestation.
//! 4. FRESH-CHECKOUT PROOF: only package.json + yarn.lock + .yarnrc.yml +
//! .socket/ travel; `yarn install --immutable --check-cache` (offline
//! from the registry, `unsafeHttpWhitelist` for the wiremock host) MUST
//! install the patched bytes from the hosted tarball.
//! install the patched bytes from the hosted tarball — with an npm
//! registry token configured (`YARN_NPM_AUTH_TOKEN` + `npmAlwaysAuth`)
//! that the patch host must never receive (#404: an `npm:` locator made
//! yarn's npm fetcher send it).
//!
//! The negative twin serves a DIFFERENT tarball at the archiveUrl while the
//! The negative twin serves a DIFFERENT tarball at the hosted URL while the
//! lock keeps the real `10c0` checksum: the fresh `--check-cache` install MUST
//! fail with a YN0018 checksum error — the lock pin is enforcement.
//!
Expand Down Expand Up @@ -267,6 +270,9 @@ struct BerryRedirectFixture {
host: String,
/// `yarn.lock` as the real yarn wrote it, BEFORE the hosted rewrite.
registry_lock: Vec<u8>,
/// The root `package.json` BEFORE the hosted rewrite (#404 option C
/// pins through its `resolutions`, so a revert restores both files).
registry_pkg: Vec<u8>,
_server: MockServer,
}

Expand All @@ -286,7 +292,7 @@ enum HostedDriver {
/// Steps 1–3: real install, patched tarball + bootstrap checksum + API mocks,
/// the hosted rewrite (per `driver`: `scan --mode hosted --vex` or
/// `get <uuid> --mode hosted`), and the envelope/lockfile/ledger assertions.
/// `tamper_served_tarball` serves DIFFERENT bytes at the archiveUrl than the
/// `tamper_served_tarball` serves DIFFERENT bytes at the hosted URL than the
/// checksum pins. `None` = skip (message printed).
async fn berry_hosted_project(
tag: &str,
Expand Down Expand Up @@ -347,6 +353,7 @@ async fn berry_hosted_project(
let installed_dir = proj.join("node_modules").join(DEP);
let orig = std::fs::read(installed_dir.join("index.js")).expect("installed index.js");
let registry_lock = std::fs::read(proj.join("yarn.lock")).expect("registry yarn.lock");
let registry_pkg = std::fs::read(proj.join("package.json")).expect("registry package.json");
assert!(
!orig.starts_with(MARKER.as_bytes()),
"pristine install must not carry the marker"
Expand Down Expand Up @@ -549,12 +556,33 @@ async fn berry_hosted_project(
);
}

// Lockfile pin: the encoded __archiveUrl + the 10c0 checksum.
// Lockfile pin: the tarball-URL locator + the 10c0 checksum. Never an
// `npm:` locator (`::__archiveUrl=`): yarn's npm fetcher sends registry
// auth to whatever host that locator names (#404).
let lock = std::fs::read_to_string(proj.join("yarn.lock")).unwrap();
let encoded = socket_patch_core::utils::uri::encode_uri_component(&hosted_url);
assert!(
lock.contains("::__archiveUrl=") && lock.contains(&encoded),
"yarn.lock must carry the encoded __archiveUrl; got:\n{lock}"
lock.contains(&format!("\n resolution: \"{DEP}@{hosted_url}\"")),
"yarn.lock must pin the hosted tarball locator; got:\n{lock}"
);
// #404 option C: the entry is keyed by the tarball descriptor, and the
// root package.json routes the locked descriptor there.
assert!(
lock.lines()
.any(|l| l.trim_end_matches('\r') == format!("\"{DEP}@{hosted_url}\":")),
"yarn.lock entry must be keyed by the tarball descriptor; got:\n{lock}"
);
let root_pkg = std::fs::read_to_string(proj.join("package.json")).unwrap();
let root_pkg: serde_json::Value = serde_json::from_str(&root_pkg).unwrap();
assert!(
root_pkg["resolutions"]
.as_object()
.is_some_and(|r| r.iter().any(|(sel, v)| sel.starts_with(&format!("{DEP}@npm:"))
&& v.as_str() == Some(hosted_url.as_str()))),
"package.json must route {DEP} to the hosted tarball: {root_pkg}"
);
assert!(
!lock.contains("__archiveUrl"),
"the hosted pin must not be an npm: locator; got:\n{lock}"
);
let checksum_line = yarn_berry_common::expected_checksum_line(
&String::from_utf8_lossy(&registry_lock),
Expand All @@ -579,6 +607,7 @@ async fn berry_hosted_project(
patched,
host,
registry_lock,
registry_pkg,
_server: server,
})
}
Expand All @@ -598,7 +627,11 @@ fn fresh_yarnrc(fx: &BerryRedirectFixture) -> String {

/// Fresh dir with only the committable files, then `yarn install --immutable
/// --check-cache` offline-from-registry (the wiremock host is whitelisted for
/// http). Returns the fresh dir + the install output.
/// http). The install runs with an npm registry token that yarn must apply to
/// every registry request (`YARN_NPM_AUTH_TOKEN` + `YARN_NPM_ALWAYS_AUTH`),
/// the CI shape #404 leaked to the patch host: [`assert_patch_host_got_no_auth`]
/// checks the hosted tarball request carried none. Returns the fresh dir +
/// the install output.
fn fresh_checkout_yarn_install(fx: &BerryRedirectFixture) -> (PathBuf, Output) {
let fresh = fx.tmp.path().join("fresh");
std::fs::create_dir_all(&fresh).unwrap();
Expand All @@ -619,19 +652,73 @@ fn fresh_checkout_yarn_install(fx: &BerryRedirectFixture) -> (PathBuf, Output) {
&[
("YARN_GLOBAL_FOLDER", fresh_global.to_str().unwrap()),
("YARN_ENABLE_GLOBAL_CACHE", "false"),
("YARN_NPM_AUTH_TOKEN", REGISTRY_TOKEN),
("YARN_NPM_ALWAYS_AUTH", "true"),
// Hardened mode (yarn enables it on its own for public-PR CI)
// re-validates every lock resolution against its descriptor; a
// tarball locator under an `npm:` key fails it with YN0078 —
// why the pin routes through `resolutions` (#404).
("YARN_ENABLE_HARDENED_MODE", "true"),
],
);
(fresh, ci)
}

/// The npm registry token the fresh install is configured with.
const REGISTRY_TOKEN: &str = "SOCKET-E2E-REGISTRY-TOKEN";

/// #404: the patch host fetched the hosted tarball, and no request it
/// received carried an `Authorization` header (or the registry token in any
/// header) — the hosted pin must never hand registry credentials to it.
async fn assert_patch_host_got_no_auth(fx: &BerryRedirectFixture) {
let requests = fx
._server
.received_requests()
.await
.expect("wiremock request recording is on");
let tarball_gets: Vec<_> = requests
.iter()
.filter(|r| r.url.path().ends_with(".tgz"))
.collect();
assert!(
!tarball_gets.is_empty(),
"the fresh install must fetch the hosted tarball from the patch host"
);
// The same wiremock also plays the Socket API, whose requests carry the
// CLI's own API token — only the yarn-made tarball fetches are judged
// for an Authorization header; the registry token must appear nowhere.
for r in &tarball_gets {
assert!(
!r.headers.contains_key("authorization"),
"{} {} carried an Authorization header to the patch host: {:?}",
r.method,
r.url,
r.headers.get("authorization")
);
}
for r in &requests {
for (name, value) in r.headers.iter() {
assert!(
!value.to_str().unwrap_or("").contains(REGISTRY_TOKEN),
"{} {} leaked the registry token in header {name}",
r.method,
r.url
);
}
}
}

/// The manifest-less VEX matrix over the hosted rewrite `driver` produced
/// (see `yarn_berry_common`): fresh checkouts without the manifest, without
/// the ledgers, offline, tampered, reverted to the registry and installed
/// under PnP — each installed by the REAL yarn and attested (or refused) by
/// the REAL binary against a mock patch API.
fn hosted_manifestless_vex_matrix(fx: &BerryRedirectFixture, driver: HostedDriver) {
let yarnrc = fresh_yarnrc(fx);
let registry_state = [("yarn.lock", fx.registry_lock.clone())];
let registry_state = [
("yarn.lock", fx.registry_lock.clone()),
("package.json", fx.registry_pkg.clone()),
];
let yarn =
|cwd: &Path, args: &[&str], env: &[(&str, &str)]| corepack(cwd, yarn_berry(), args, env);
let api_url = fx._server.uri();
Expand Down Expand Up @@ -698,6 +785,7 @@ async fn berry_redirect_fresh_checkout_installs_patched_bytes() {
installed, fx.patched,
"fresh install must be byte-identical to the patched content"
);
assert_patch_host_got_no_auth(&fx).await;

hosted_manifestless_vex_matrix(&fx, HostedDriver::Scan);
}
Expand All @@ -706,7 +794,7 @@ async fn berry_redirect_fresh_checkout_installs_patched_bytes() {
/// routes through the SAME hosted engine as `scan --mode hosted`, so the
/// berry chain must hold unchanged — including the `10c0` cacheKey bootstrap
/// (the fixture still resolves the patched tarball with a real yarn to pin
/// the exact cache-zip checksum) and the lock's `::__archiveUrl=` +
/// the exact cache-zip checksum) and the lock's tarball-URL locator +
/// `checksum: 10c0/<hex>` splice — and the fresh `yarn install --immutable
/// --check-cache` pulls the patched bytes from the hosted tarball. The uuid
/// identifier path is exempt from installed narrowing, so only the view +
Expand Down Expand Up @@ -740,7 +828,7 @@ async fn berry_get_uuid_hosted_fresh_checkout_installs() {
hosted_manifestless_vex_matrix(&fx, HostedDriver::GetUuid);
}

/// Negative twin: the archiveUrl serves a DIFFERENT tarball while the lock
/// Negative twin: the hosted URL serves a DIFFERENT tarball while the lock
/// pins the real `10c0` checksum — the fresh `--check-cache` install must fail
/// with YN0018.
#[tokio::test(flavor = "multi_thread")]
Expand Down
Loading
Loading