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
2 changes: 1 addition & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion crates/socket-patch-cli/src/commands/vex_consumed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
//! | maven | `<repo>/<g>/<a>/<base>-socket.<hex8>/` (the version the pom or the hosted Gradle wiring pins) in `~/.m2` and every Gradle `files-2.1` holding it (hash dirs expanded), its artifact files matched under the suffixed name | the `<base>` version dir |
//! | npm | every `node_modules` copy the crawler finds (pnpm and vlt store copies included), every peer / modifier / registry variant of those in the same `.pnpm` / `.vlt` store, alias installs (`node_modules/<alias>` holding the package) in the root's and every workspace member's tree included | — each serves some dependent: ALL must verify |
//! | pypi | every copy in the crawler's environment set (the project's venvs when it has any, else the interpreters) | — any may be the one that runs the project: ALL must verify |
//! | gem | every copy in bundler's gem path | — bundler loads whichever `Gem.path` home it hits first: ALL must verify |
//! | gem | every copy in bundler's gem path; under an explicit or deployment `path` the `gem env` homes hold only default gems bundler loads (#1098) | a non-default gem's `gem env` copy when bundler doesn't use system gems; otherwise bundler loads whichever `Gem.path` home it hits first: ALL must verify |
//!
//! composer and nuget have ONE install location shared by every source
//! (`vendor/`, the global packages folder — which restore reuses whatever
Expand Down
20 changes: 20 additions & 0 deletions crates/socket-patch-cli/src/ecosystem_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,26 @@ pub async fn find_manifest_package_copies_reusing(
.map(|(_, paths)| paths),
)
.await;
// A `gem env` home Bundler never loads this project's gems from (an
// explicit or deployment `path`) holds no copy the project runs, so an
// unpatched one there must not block the attestation (#1098). Default
// gems stay: Bundler loads those from the system home under any path.
if partitioned.contains_key(&Ecosystem::Gem) {
let unused = RubyCrawler
.bundler_unused_system_gem_homes(&crawler_options)
.await;
if !unused.is_empty() {
for (_, paths) in copies
.iter_mut()
.filter(|(purl, _)| purl.starts_with("pkg:gem/"))
{
paths.retain(|path| {
!unused.iter().any(|home| path.starts_with(home))
|| socket_patch_core::crawlers::ruby_crawler::is_default_gem_copy(path)
});
}
}
}
copies.retain(|_, paths| !paths.is_empty());
// Verification also READS a `.bundle/config` bundle path the crawler
// refused as a write root (it resolves outside the project): bundler
Expand Down
4 changes: 2 additions & 2 deletions crates/socket-patch-cli/tests/e2e_hosted_production.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
//!
//! | Ecosystem | PURL | Patch UUID | Advisory |
//! |-----------|------|------------|----------|
//! | npm | `pkg:npm/minimist@1.2.2` | `80630680-4da6-45f9-bba8-b888e0ffd58c` | GHSA-xvch-5gv4-984h (CVE-2021-44906) |
//! | npm | `pkg:npm/minimist@1.2.2` | `642d7f02-ebc1-4ab0-99e2-07f5dd8463cb` | GHSA-xvch-5gv4-984h (CVE-2021-44906) |
//! | PyPI | `pkg:pypi/urllib3@1.26.18` | *any of three* (see [`PYPI_UUIDS`]) | GHSA-gm62-xv2j-4w53 &co |
//! | gem | `pkg:gem/activestorage@6.0.3` | *any of* [`GEM_UUIDS`] (six today; the sixth merges three advisories) | GHSA-m42x-37p3-fv5w (CVE-2020-8162), GHSA-w749-p3v6-hccq (CVE-2022-21831), GHSA-9xrj-h377-fr87 (CVE-2026-33195), GHSA-r4mg-4433-c7g3 (CVE-2025-24293), GHSA-xr9x-r78c-5hrm (CVE-2026-66066) |
//!
Expand Down Expand Up @@ -123,7 +123,7 @@ const PATCH_HOST: &str = "patch.socket.dev";
const NPM_PURL: &str = "pkg:npm/minimist@1.2.2";
const NPM_NAME: &str = "minimist";
const NPM_VERSION: &str = "1.2.2";
const NPM_UUID: &str = "80630680-4da6-45f9-bba8-b888e0ffd58c";
const NPM_UUID: &str = "642d7f02-ebc1-4ab0-99e2-07f5dd8463cb";

const PYPI_PURL: &str = "pkg:pypi/urllib3@1.26.18";
const PYPI_NAME: &str = "urllib3";
Expand Down
6 changes: 3 additions & 3 deletions crates/socket-patch-cli/tests/e2e_npm.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! End-to-end tests for the npm patch lifecycle.
//!
//! These tests exercise the full CLI against the real Socket API, using the
//! **minimist@1.2.2** patch (UUID `80630680-4da6-45f9-bba8-b888e0ffd58c`),
//! **minimist@1.2.2** patch (UUID `642d7f02-ebc1-4ab0-99e2-07f5dd8463cb`),
//! which fixes CVE-2021-44906 (Prototype Pollution).
//!
//! # Prerequisites
Expand All @@ -26,14 +26,14 @@ use common::cache_env;
// Constants
// ---------------------------------------------------------------------------

const NPM_UUID: &str = "80630680-4da6-45f9-bba8-b888e0ffd58c";
const NPM_UUID: &str = "642d7f02-ebc1-4ab0-99e2-07f5dd8463cb";
const NPM_PURL: &str = "pkg:npm/minimist@1.2.2";

/// Git SHA-256 of the *unpatched* `index.js` shipped with minimist 1.2.2.
const BEFORE_HASH: &str = "311f1e893e6eac502693fad8617dcf5353a043ccc0f7b4ba9fe385e838b67a10";

/// Git SHA-256 of the *patched* `index.js` after the security fix.
const AFTER_HASH: &str = "043f04d19e884aa5f8371428718d2a3f27a0d231afe77a2620ac6312f80aaa28";
const AFTER_HASH: &str = "ec956dcafb886f14315570bf3981d44aa12c561716abb46eed8b067aaa1f6bdf";

// ---------------------------------------------------------------------------
// Helpers
Expand Down
278 changes: 278 additions & 0 deletions crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1541,3 +1541,281 @@ async fn gem_hosted_default_cwd_keeps_project_local_remedy() {
);
}
}

/// #1109: `deployment` (local config, env or global config) stops Bundler
/// using system gems without an explicit `path`: it installs into
/// `vendor/bundle`. On a fresh checkout that store doesn't exist yet, but
/// `bundle install` still fetches into it and never reuses the `gem env`
/// copy, so it is not stale: no warning, and the same run's `--vex`
/// attests the purl.
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn gem_hosted_deployment_ignores_system_home_copy() {
let server = MockServer::start().await;
mount_api(&server, None).await;
let cases: &[(&str, Option<&str>, &[(&str, &str)])] = &[
("local deployment", Some("BUNDLE_DEPLOYMENT: \"true\""), &[]),
("env deployment", None, &[("BUNDLE_DEPLOYMENT", "true")]),
(
"global deployment",
None,
&[("BUNDLE_USER_CONFIG", "../user-bundle-config")],
),
];
for (case, local, extra) in cases {
let tmp = tempfile::tempdir().unwrap();
let proj = tmp.path().join("proj");
std::fs::create_dir_all(&proj).unwrap();
write_manifest_pair(&proj);
if let Some(line) = local {
std::fs::create_dir_all(proj.join(".bundle")).unwrap();
std::fs::write(
proj.join(".bundle").join("config"),
format!("---\n{line}\n"),
)
.unwrap();
}
std::fs::write(
tmp.path().join("user-bundle-config"),
"---\nBUNDLE_DEPLOYMENT: \"true\"\n",
)
.unwrap();
let bin_dir = tmp.path().join("fake-bin");
let system_copy = stage_system_home_copy(&tmp.path().join("system-home"), &bin_dir);

let (code, env, stderr, vex_path) =
hosted_vex_scan_with_gem_on_path(&proj, &server.uri(), &bin_dir, extra);
assert!(
stale_warnings(&env).is_empty(),
"{case}: bundler never reuses {}: {env}",
system_copy.display()
);
assert_eq!(
code, 0,
"{case}: the run must attest, not fail.\nenvelope: {env}\nstderr:\n{stderr}"
);
let doc = std::fs::read_to_string(&vex_path).expect("VEX written");
assert!(doc.contains(PURL), "{case}: purl not attested:\n{doc}");
}
}

/// #1109 controls: a falsy `deployment`, or a higher tier that decides
/// the path with system gems on (`path.system`, or a falsy
/// `disable_shared_gems`), leaves Bundler on the system gems, so the stale
/// `gem env` copy still warns and stays out of the VEX. So do the
/// `.bundle`-default flags: Bundler 2.x honors only
/// `default_install_uses_path` and Bundler 4.x only `simulate_version 5`,
/// and a scan can't tell which Bundler runs, so it keeps judging the
/// system home rather than risk skipping a copy Bundler loads.
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn gem_hosted_system_gems_settings_still_flag_system_home_copy() {
let server = MockServer::start().await;
mount_api(&server, None).await;
let cases: &[(&str, Option<&str>, &[(&str, &str)])] = &[
(
"local deployment false over env deployment",
Some("BUNDLE_DEPLOYMENT: \"false\""),
&[("BUNDLE_DEPLOYMENT", "true")],
),
(
"local path.system over env deployment",
Some("BUNDLE_PATH__SYSTEM: \"true\""),
&[("BUNDLE_DEPLOYMENT", "true")],
),
(
"local disable_shared_gems false over env deployment",
Some("BUNDLE_DISABLE_SHARED_GEMS: \"false\""),
&[("BUNDLE_DEPLOYMENT", "true")],
),
(
"local simulate_version 5",
Some("BUNDLE_SIMULATE_VERSION: \"5\""),
&[],
),
(
"local default_install_uses_path",
Some("BUNDLE_DEFAULT_INSTALL_USES_PATH: \"true\""),
&[],
),
];
for (case, local, extra) in cases {
let tmp = tempfile::tempdir().unwrap();
let proj = tmp.path().join("proj");
std::fs::create_dir_all(&proj).unwrap();
write_manifest_pair(&proj);
if let Some(line) = local {
std::fs::create_dir_all(proj.join(".bundle")).unwrap();
std::fs::write(
proj.join(".bundle").join("config"),
format!("---\n{line}\n"),
)
.unwrap();
}
let bin_dir = tmp.path().join("fake-bin");
let system_copy = stage_system_home_copy(&tmp.path().join("system-home"), &bin_dir);

let (code, env, stderr, vex_path) =
hosted_vex_scan_with_gem_on_path(&proj, &server.uri(), &bin_dir, extra);
let warnings = stale_warnings(&env);
assert_eq!(warnings.len(), 1, "{case}: {env}");
assert!(
warnings[0].contains(&system_copy.display().to_string()),
"{case}: {}",
warnings[0]
);
if let Ok(doc) = std::fs::read_to_string(&vex_path) {
assert!(!doc.contains(PURL), "{case}: stale purl attested:\n{doc}");
}
assert_ne!(code, 0, "{case}: stderr:\n{stderr}");
}
}

/// The lock `bundle install` writes once the hosted pin is installed
/// (bundler >= 2.2: a separate patch-registry `GEM` section).
fn converged_lock(api: &str) -> String {
let index_url = format!("{api}/patch-registry/gem/{TOKEN}/{UUID}/");
format!(
"GEM\n remote: {index_url}\n specs:\n {DEP} ({DEP_VERSION})\n\n\
GEM\n remote: https://rubygems.org/\n specs:\n\n\
PLATFORMS\n ruby\n\nDEPENDENCIES\n {DEP} (= {DEP_VERSION})!\n\n\
BUNDLED WITH\n 2.6.9\n"
)
}

/// #1098 (and #1109 for standalone `vex`): when Bundler doesn't use system
/// gems for the project, standalone `vex` must not judge the unused,
/// unpatched copy in the `gem env` home. Each case scans, converges the
/// lock the way `bundle install` would, then runs a manifest-less `vex`
/// with the same fake `gem` on `PATH`:
///
/// - a fresh checkout under an explicit `path` (env, local or global
/// config) or `deployment`, nothing installed yet: attested from the
/// lock;
/// - an installed `BUNDLE_PATH: gems` holding the PATCHED copy: verified.
///
/// The control, where Bundler does use system gems, still refuses the
/// unpatched system copy with `not_applied`.
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread")]
async fn gem_hosted_standalone_vex_ignores_unused_system_home_copy() {
use vex_e2e_common::{
assert_attested, assert_not_attested, run_vex, strip_ledgers, strip_manifest, Marker,
VexRun,
};
let server = MockServer::start().await;
mount_api(&server, None).await;
let bin = vex_e2e_common::binary();
let vulns: &[(&str, &[&str])] = &[(GHSA, &["CVE-2026-4444"])];

enum Config {
None,
Local(&'static str),
Env(&'static str, &'static str),
Global(&'static str),
}
let cases: &[(&str, Config, bool, bool)] = &[
// (case, config, install the patched copy under `gems/`, attests)
(
"fresh, local path",
Config::Local("BUNDLE_PATH: \"vendor/bundle\""),
false,
true,
),
(
"fresh, env path",
Config::Env("BUNDLE_PATH", "vendor/bundle"),
false,
true,
),
(
"fresh, global path",
Config::Global("BUNDLE_PATH: \"vendor/bundle\""),
false,
true,
),
(
"fresh, local deployment",
Config::Local("BUNDLE_DEPLOYMENT: \"true\""),
false,
true,
),
(
"installed, local path gems",
Config::Local("BUNDLE_PATH: \"gems\""),
true,
true,
),
("control, system gems", Config::None, false, false),
];
for (case, config, install, attests) in cases {
let tmp = tempfile::tempdir().unwrap();
let proj = tmp.path().join("proj");
std::fs::create_dir_all(&proj).unwrap();
write_manifest_pair(&proj);
let bin_dir = tmp.path().join("fake-bin");
stage_system_home_copy(&tmp.path().join("system-home"), &bin_dir);
let global_config = tmp.path().join("user-bundle-config");
let mut envs: Vec<(String, std::ffi::OsString)> =
vec![("PATH".into(), bin_dir.clone().into_os_string())];
match config {
Config::None => {}
Config::Local(line) => {
std::fs::create_dir_all(proj.join(".bundle")).unwrap();
std::fs::write(
proj.join(".bundle").join("config"),
format!("---\n{line}\n"),
)
.unwrap();
}
Config::Env(k, v) => envs.push(((*k).into(), (*v).into())),
Config::Global(line) => {
std::fs::write(&global_config, format!("---\n{line}\n")).unwrap();
envs.push(("BUNDLE_USER_CONFIG".into(), global_config.clone().into()));
}
}
let scan_env: Vec<(&str, &str)> = envs
.iter()
.filter(|(k, _)| k != "PATH")
.map(|(k, v)| (k.as_str(), v.to_str().unwrap()))
.collect();
let (code, env, stderr, _) =
hosted_vex_scan_with_gem_on_path(&proj, &server.uri(), &bin_dir, &scan_env);
assert_eq!(
code == 0,
*attests,
"{case}: hosted scan --vex.\nenvelope: {env}\nstderr:\n{stderr}"
);
strip_manifest(&proj);
strip_ledgers(&proj);
std::fs::write(proj.join("Gemfile.lock"), converged_lock(&server.uri())).unwrap();
if *install {
let gem_dir = proj
.join("gems")
.join("ruby")
.join("3.3.0")
.join("gems")
.join(format!("{DEP}-{DEP_VERSION}"));
std::fs::create_dir_all(gem_dir.join("lib")).unwrap();
std::fs::write(gem_dir.join("lib").join("stale_probe_gem.rb"), PATCHED_LIB).unwrap();
}

let run = VexRun {
api_url: Some(server.uri()),
api_token: Some("fake".into()),
org: Some(ORG.into()),
patch_server_url: Some(server.uri()),
product: Some("pkg:gem/app@1.0.0".into()),
envs,
..VexRun::default()
};
let out = run_vex(&bin, &proj, &run);
if *attests {
assert_eq!(out.code, Some(0), "{case}: {out}");
assert_attested(out.doc(), PURL, UUID, Marker::Redirected, vulns);
} else {
assert_eq!(out.code, Some(1), "{case}: {out}");
assert_not_attested(&out.envelope, PURL, "not_applied");
}
}
}
6 changes: 3 additions & 3 deletions crates/socket-patch-cli/tests/e2e_safety_pnpm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
//! view and the store entry byte-identical.
//!
//! Fixture: minimist@1.2.2 + its Socket patch (UUID
//! `80630680-4da6-45f9-bba8-b888e0ffd58c`, CVE-2021-44906) — same
//! `642d7f02-ebc1-4ab0-99e2-07f5dd8463cb`, CVE-2021-44906) — same
//! pair `e2e_npm.rs` uses, so the BEFORE/AFTER hashes are known.
//!
//! Network: yes (pnpm install + socket-patch get). Toolchain: pnpm.
Expand All @@ -24,12 +24,12 @@ mod common;

use common::{assert_run_ok, git_sha256_file, has_command, pnpm_run, write_package_json};

const NPM_UUID: &str = "80630680-4da6-45f9-bba8-b888e0ffd58c";
const NPM_UUID: &str = "642d7f02-ebc1-4ab0-99e2-07f5dd8463cb";

/// Git-SHA-256 of the *unpatched* `index.js` shipped with minimist 1.2.2.
const BEFORE_HASH: &str = "311f1e893e6eac502693fad8617dcf5353a043ccc0f7b4ba9fe385e838b67a10";
/// Git-SHA-256 of the *patched* `index.js` after the security fix.
const AFTER_HASH: &str = "043f04d19e884aa5f8371428718d2a3f27a0d231afe77a2620ac6312f80aaa28";
const AFTER_HASH: &str = "ec956dcafb886f14315570bf3981d44aa12c561716abb46eed8b067aaa1f6bdf";

// ── Setup helpers ─────────────────────────────────────────────────────

Expand Down
Loading
Loading