Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c7d668e
feat(cli): add `scan --redirect` hosted-vendored-patch mode
mikolalysenko Jul 1, 2026
8fcd7b3
feat(vex): attest redirected patches with a (redirected) provenance m…
mikolalysenko Jul 1, 2026
7b23bc8
Merge remote-tracking branch 'origin/main' into feat/scan-redirect-mode
mikolalysenko Jul 1, 2026
b47b9d4
style: cargo fmt
mikolalysenko Jul 1, 2026
554b486
test(vex): real-install npm redirect capstone + docker gem/composer V…
mikolalysenko Jul 1, 2026
baedf28
fix(redirect,vex): fail-closed attestation, idempotent rewriters, sur…
mikolalysenko Jul 1, 2026
be23ae2
fix(ci): check the redirect golden fixtures out byte-exact on Windows
mikolalysenko Jul 1, 2026
e93f21c
chore: exclude test fixtures from Socket dependency scanning
mikolalysenko Jul 1, 2026
0c925d1
feat(cli): three-mode selector, maven hosted rewriter, nuget config f…
mikolalysenko Jul 2, 2026
a8dfb7e
feat(vendor): NuGet and Maven vendored backends with fragment-level r…
mikolalysenko Jul 2, 2026
1cfd02c
test: three-mode × ecosystem behavioral matrix
mikolalysenko Jul 2, 2026
a85f454
docs: three-mode README, CHANGELOG, CLI_CONTRACT + support matrix
mikolalysenko Jul 2, 2026
8399416
test(vendor): jsr is the unsupported-ecosystem exemplar now that nuge…
mikolalysenko Jul 2, 2026
56f3550
fix(cli): mode-conflict errors match the clap contract phrasing
mikolalysenko Jul 2, 2026
d9aa32f
docs: spell out Maven's checksum-failure fallback for hosted mode
mikolalysenko Jul 2, 2026
7ee80d7
refactor(core): factor bun text-lock grammar + share uri encode; gene…
mikolalysenko Jul 2, 2026
1db0d6d
feat(patches): Rush monorepo support — vendored refusal, scan invento…
mikolalysenko Jul 2, 2026
2716592
feat(core): yarn-berry + bun hosted registry-redirect rewriters + sha…
mikolalysenko Jul 2, 2026
20534a0
feat(cli): berry/bun redirect plumbing + bun.lockb auto-migration
mikolalysenko Jul 2, 2026
8154b18
feat(cli): scan --redirect discovers Rush pnpm locks + stale repo-sta…
mikolalysenko Jul 2, 2026
d6f736b
test(redirect,repair): pnpm hosted-lock legs + flavor repair matrix
mikolalysenko Jul 2, 2026
29bbb80
test(apply): agent-mode legs for bun, yarn-berry node-modules, rush farm
mikolalysenko Jul 2, 2026
58decbc
feat(redirect): fail-closed maven suffixing + trusted checksums
mikolalysenko Jul 2, 2026
bd317b8
test(redirect): real-install berry + bun hosted capstones
mikolalysenko Jul 2, 2026
dea45e6
test(docker): yarn berry 4.x agent + vendored e2e legs
mikolalysenko Jul 2, 2026
9063242
test(redirect): Rush hosted-mode redirect capstone (sim + gated real …
mikolalysenko Jul 2, 2026
bd76bcb
test(redirect): clippy cleanups in berry + rush redirect legs
mikolalysenko Jul 2, 2026
bf74543
docs: berry/bun + Rush hosted support, maven fail-closed, de-document…
mikolalysenko Jul 2, 2026
8f591de
style(core): satisfy clippy cloned_ref_to_slice_refs in rewriter tests
mikolalysenko Jul 2, 2026
14088c9
test(cli): fix windows + release CI failures in in-process suites
mikolalysenko Jul 2, 2026
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
Next Next commit
feat(cli): add scan --redirect hosted-vendored-patch mode
Adds a new patch-apply mode that rewrites lockfiles/manifests so ONLY
patched dependencies resolve from Socket's hosted vendored patches
(patch.socket.dev), instead of vendoring local artifact bytes or
writing .socket/manifest.json.

- `scan --redirect` flag (conflicts_with_all apply/sync/vendor)
- `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` global arg,
  defaulting to https://patch.socket.dev (DEFAULT_PATCH_SERVER_URL)
- api/client.rs `fetch_registry_references` (authed
  /v0/orgs/{org}/patches/package, or proxy /patch/package)
- patch/redirect/mod.rs rewriters for 9 ecosystems (npm package-lock,
  pnpm, yarn-classic, pypi requirements, uv, cargo, composer, nuget,
  gem); golang documented as a limitation (no per-dependency remote
  redirect without a global GOPROXY)

Shared golden fixtures (tests/fixtures/redirect/**) are consumed by
BOTH this crate's redirect_golden.rs and the depscan backend's
golden.test.ts, keeping the two rewriter implementations
byte-identical. Behavioral coverage: tests/in_process_redirect.rs.

Assisted-by: Claude Code:opus-4-8
  • Loading branch information
mikolalysenko committed Jul 1, 2026
commit c7d668eeb6e2647a11994cfc2cd477d2a2252d6e
15 changes: 14 additions & 1 deletion crates/socket-patch-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ use clap::Args;

use socket_patch_core::api::client::ApiClientEnvOverrides;
use socket_patch_core::constants::{
DEFAULT_PATCH_API_PROXY_URL, DEFAULT_PATCH_MANIFEST_PATH, DEFAULT_SOCKET_API_URL,
DEFAULT_PATCH_API_PROXY_URL, DEFAULT_PATCH_MANIFEST_PATH, DEFAULT_PATCH_SERVER_URL,
DEFAULT_SOCKET_API_URL,
};
use socket_patch_core::crawlers::Ecosystem;

Expand Down Expand Up @@ -113,6 +114,16 @@ pub struct GlobalArgs {
)]
pub proxy_url: String,

/// Patch-server base URL that hosts the vendored patches, used by
/// `scan --redirect` to build the per-dependency override URLs. Defaults to
/// the production patch host; override for local dev / testing.
#[arg(
long = "patch-server-url",
env = "SOCKET_PATCH_SERVER_URL",
default_value = DEFAULT_PATCH_SERVER_URL,
)]
pub patch_server_url: String,

/// Restrict to these ecosystems (comma-separated). Names not supported
/// by this build (e.g. `maven`/`nuget` unless compiled in) are rejected.
#[arg(
Expand Down Expand Up @@ -328,6 +339,7 @@ pub const GLOBAL_ARG_ENV_VARS: &[&str] = &[
"SOCKET_API_TOKEN",
"SOCKET_ORG_SLUG",
"SOCKET_PROXY_URL",
"SOCKET_PATCH_SERVER_URL",
"SOCKET_ECOSYSTEMS",
"SOCKET_DOWNLOAD_MODE",
"SOCKET_OFFLINE",
Expand Down Expand Up @@ -388,6 +400,7 @@ impl Default for GlobalArgs {
api_token: None,
org: None,
proxy_url: String::new(),
patch_server_url: String::new(),
ecosystems: None,
download_mode: "diff".to_string(),
offline: false,
Expand Down
210 changes: 210 additions & 0 deletions crates/socket-patch-cli/src/commands/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,17 @@ pub struct ScanArgs {
#[arg(long, default_value_t = false, requires = "vendor")]
pub detached: bool,

/// Redirect every patched dependency to Socket's HOSTED vendored patches
/// by rewriting lockfiles/registry configs so ONLY the patched dependency
/// points at the patch-server (`--patch-server-url`), instead of applying
/// patches in place or ejecting local artifacts. This is the remote
/// counterpart of `--vendor`: no artifact bytes land in the repo — the
/// lockfile pins the hosted URL + integrity (npm/pypi/composer) or a
/// per-dependency registry override (cargo/nuget/gem/…). Conflicts with
/// `--apply`/`--sync`/`--vendor`.
#[arg(long, default_value_t = false, conflicts_with_all = ["apply", "sync", "vendor"])]
pub redirect: bool,

/// Download patches for every release/distribution variant of a
/// matched package, not just the one(s) matching the locally-
/// installed distribution. Affects ecosystems with per-release
Expand Down Expand Up @@ -1298,6 +1309,191 @@ async fn track_outcomes_for_vendor(
}
}

/// Candidate lockfiles / registry configs the redirect rewriters may touch —
/// read from the project when present and handed to `rewrite_registry_redirect`.
const REDIRECT_CANDIDATE_FILES: &[&str] = &[
"package-lock.json",
"npm-shrinkwrap.json",
"pnpm-lock.yaml",
"yarn.lock",
"requirements.txt",
"uv.lock",
"Cargo.toml",
"Cargo.lock",
".cargo/config.toml",
"composer.lock",
"nuget.config",
"packages.lock.json",
"Gemfile",
"Gemfile.lock",
];

/// `pkg:<type>/<coordinate>@<version>` → `(type, coordinate, version)`. The
/// coordinate keeps its full slash-bearing form (npm `@scope/name`, composer
/// `vendor/pkg`, golang module path) — the rewriters treat that as the `name`
/// (their `full_name()` is `name` when `namespace` is `None`).
fn parse_purl_simple(purl: &str) -> Option<(String, String, String)> {
let stripped = socket_patch_core::utils::purl::strip_purl_qualifiers(purl);
let rest = stripped.strip_prefix("pkg:")?;
let (typ, after) = rest.split_once('/')?;
let (coord, version) = after.rsplit_once('@')?;
let name = socket_patch_core::utils::purl::percent_decode_purl_component(coord)
.into_owned();
Some((typ.to_string(), name, version.to_string()))
}

/// `scan --redirect`: resolve hosted-patch references for the selected patches,
/// then rewrite ONLY those dependencies' lockfile/registry-config entries to
/// point at the hosted vendored patches (the byte-identical counterpart of the
/// GitHub-app registry mode). No artifact bytes land in the repo.
async fn run_redirect(
args: &ScanArgs,
api_client: &socket_patch_core::api::client::ApiClient,
effective_org_slug: Option<&str>,
all_packages_with_patches: &[BatchPackagePatches],
can_access_paid_patches: bool,
) -> i32 {
use socket_patch_core::patch::redirect::{rewrite_registry_redirect, DepOverride};

// Same discovery/selection as `--apply`/`--vendor`.
let mut all_search_results: Vec<PatchSearchResult> = Vec::new();
for pkg in all_packages_with_patches {
if let Ok(response) = api_client
.search_patches_by_package(effective_org_slug, &pkg.purl)
.await
{
all_search_results.extend(response.patches);
}
}
let selected = if all_search_results.is_empty() {
Vec::new()
} else {
match select_patches(&all_search_results, can_access_paid_patches, false) {
Ok(s) => s,
Err(code) => return code,
}
};

let mut skipped: Vec<serde_json::Value> = Vec::new();
let mut overrides: Vec<DepOverride> = Vec::new();

if !selected.is_empty() {
let uuids: Vec<String> = selected.iter().map(|s| s.uuid.clone()).collect();
let references = match api_client.fetch_registry_references(&uuids).await {
Ok(r) => r,
Err(e) => {
eprintln!("failed to resolve patch references: {e}");
return 1;
}
};
for sel in &selected {
let Some(reference) = references.get(&sel.uuid) else {
skipped.push(serde_json::json!({ "purl": sel.purl, "uuid": sel.uuid, "reason": "not_found" }));
continue;
};
if reference.status != "granted" && reference.status != "reused" {
skipped.push(serde_json::json!({ "purl": sel.purl, "uuid": sel.uuid, "reason": reference.status }));
continue;
}
let purl = reference.purl.as_deref().unwrap_or(&sel.purl);
let Some((ecosystem, name, version)) = parse_purl_simple(purl) else {
skipped.push(serde_json::json!({ "purl": purl, "uuid": sel.uuid, "reason": "bad_purl" }));
continue;
};
let Some(url) = reference.url.clone() else {
skipped.push(serde_json::json!({ "purl": purl, "uuid": sel.uuid, "reason": "no_url" }));
continue;
};
let integrity = reference
.artifacts
.iter()
.find(|a| a.kind == "tarball")
.map(|a| a.integrity.clone())
.unwrap_or_default();
overrides.push(DepOverride {
ecosystem,
name,
namespace: None,
version,
token: String::new(),
patch_uuid: sel.uuid.clone(),
artifact_url: url,
berry_zip_url: None,
registry_override: reference.registry_override.clone(),
integrity,
});
}
}

// Read the project's candidate files, run the rewriters.
let mut files: std::collections::BTreeMap<String, String> =
std::collections::BTreeMap::new();
for name in REDIRECT_CANDIDATE_FILES {
if let Ok(content) = std::fs::read_to_string(args.common.cwd.join(name)) {
files.insert((*name).to_string(), content);
}
}
let rewrite = rewrite_registry_redirect(&files, &overrides);
let rewritten: Vec<String> = rewrite.files.keys().cloned().collect();

if !args.common.dry_run {
for (rel, content) in &rewrite.files {
let path = args.common.cwd.join(rel);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = std::fs::write(&path, content) {
eprintln!("failed to write {rel}: {e}");
return 1;
}
}
// Ledger for a future revert (mirrors the vendor state.json shape).
if !rewrite.edits.is_empty() {
let vendor_dir = args.common.cwd.join(".socket").join("vendor");
let _ = std::fs::create_dir_all(&vendor_dir);
let ledger = serde_json::json!({
"version": 1,
"mode": "redirect",
"edits": rewrite.edits,
});
let _ = std::fs::write(
vendor_dir.join("redirect-state.json"),
format!("{}\n", serde_json::to_string_pretty(&ledger).unwrap()),
);
}
}

if args.common.json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"status": "success",
"redirect": {
"redirected": overrides.len(),
"rewrittenFiles": rewritten,
"skipped": skipped,
"warnings": rewrite.warnings.iter().map(|w| serde_json::json!({
"code": w.code, "detail": w.detail,
})).collect::<Vec<_>>(),
"dryRun": args.common.dry_run,
}
}))
.unwrap()
);
} else if !args.common.silent {
let verb = if args.common.dry_run { "would rewrite" } else { "rewrote" };
println!(
"Redirected {} package(s); {verb} {} file(s).",
overrides.len(),
rewritten.len()
);
for s in &skipped {
eprintln!(" skipped {} ({})", s["purl"], s["reason"]);
}
}
0
}

pub async fn run(args: ScanArgs) -> i32 {
apply_env_toggles(&args.common);

Expand Down Expand Up @@ -1685,6 +1881,20 @@ pub async fn run(args: ScanArgs) -> i32 {
)
.await;

// Registry-redirect mode is a distinct, self-contained flow (rewrite
// lockfiles → hosted vendored patches). It reuses discovery above, then
// returns — it must NOT fall through to the apply/vendor branches.
if args.redirect {
return run_redirect(
&args,
&api_client,
effective_org_slug,
&all_packages_with_patches,
can_access_paid_patches,
)
.await;
}

// Read existing manifest once for update detection. Used by both the
// JSON-mode emission (always includes an `updates` array) and the
// non-JSON table-print path (counts `updates_available`).
Expand Down
1 change: 1 addition & 0 deletions crates/socket-patch-cli/tests/cli_global_args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ fn global_flag_cases_cover_every_global_field() {
api_token: _,
org: _,
proxy_url: _,
patch_server_url: _,
ecosystems: _,
download_mode: _,
offline: _,
Expand Down
3 changes: 3 additions & 0 deletions crates/socket-patch-cli/tests/in_process_cargo_apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ async fn cargo_fetch_scan_sync_patches_real_file() {
sync: true,
vendor: false,
detached: false,
redirect: false,
all_releases: false,
vex: Default::default(),
};
Expand Down Expand Up @@ -358,6 +359,7 @@ async fn cargo_apply_refuses_on_before_hash_mismatch() {
sync: true,
vendor: false,
detached: false,
redirect: false,
all_releases: false,
vex: Default::default(),
};
Expand Down Expand Up @@ -453,6 +455,7 @@ async fn cargo_crawler_finds_real_fetched_crate() {
sync: false,
vendor: false,
detached: false,
redirect: false,
all_releases: false,
vex: Default::default(),
};
Expand Down
2 changes: 2 additions & 0 deletions crates/socket-patch-cli/tests/in_process_gem_apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ async fn gem_install_scan_sync_patches_real_file() {
sync: true,
vendor: false,
detached: false,
redirect: false,
all_releases: false,
vex: Default::default(),
};
Expand Down Expand Up @@ -320,6 +321,7 @@ async fn gem_crawler_finds_real_installed_gem() {
sync: false,
vendor: false,
detached: false,
redirect: false,
all_releases: false,
vex: Default::default(),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ fn scan_args(cwd: &Path, api_url: String, all_releases: bool) -> ScanArgs {
sync: false,
vendor: false,
detached: false,
redirect: false,
all_releases,
vex: Default::default(),
}
Expand Down
4 changes: 4 additions & 0 deletions crates/socket-patch-cli/tests/in_process_pypi_apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ async fn pypi_install_scan_sync_patches_real_file() {
sync: true,
vendor: false,
detached: false,
redirect: false,
all_releases: false,
vex: Default::default(),
};
Expand Down Expand Up @@ -314,6 +315,7 @@ async fn pypi_scan_then_apply_force_patches_real_file() {
sync: true,
vendor: false,
detached: false,
redirect: false,
all_releases: false,
vex: Default::default(),
};
Expand Down Expand Up @@ -419,6 +421,7 @@ async fn pypi_apply_dry_run_does_not_modify_file() {
sync: false,
vendor: false,
detached: false,
redirect: false,
all_releases: false,
vex: Default::default(),
};
Expand Down Expand Up @@ -530,6 +533,7 @@ async fn pypi_crawler_finds_real_installed_six() {
sync: false,
vendor: false,
detached: false,
redirect: false,
all_releases: false,
vex: Default::default(),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ fn scan_args(tmp: &Path, api_url: String, all_releases: bool) -> ScanArgs {
sync: false,
vendor: false,
detached: false,
redirect: false,
all_releases,
vex: Default::default(),
}
Expand Down
1 change: 1 addition & 0 deletions crates/socket-patch-cli/tests/in_process_python_envs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ fn default_args(cwd: &Path, api_url: String) -> ScanArgs {
sync: false,
vendor: false,
detached: false,
redirect: false,
all_releases: false,
vex: Default::default(),
}
Expand Down
Loading
Loading