Skip to content
Open
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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,26 @@ into the new version's section — see docs/releasing.md.

### Added

- **Hosted mode for Go (free tier).** `scan --mode hosted` now redirects
golang dependencies when the reference carries a `goproxy` registry
override: a fork-style
`replace <mod> <ver> => patch.socket.dev/gopatch/<uuid> <ver>-socketpatch.<n>`
in `go.mod` plus the socket module's two `h1:` lines in `go.sum` (and the
replaced original's lines pruned — the tidy-stable state). Day-2 machines
need no configuration: go consults the checksum database only for modules
absent from `go.sum`, so the committed pair is the whole redirect —
validated end-to-end in `e2e_golang_hosted_build.rs` (fresh caches, bogus
`GOSUMDB` tripwire, `go mod tidy` byte-level no-op, tampered-hash
`SECURITY ERROR`). Fails closed (per-dep `redirect_golang_*` warnings, no
partial writes) on missing hashes, an out-of-namespace module path, a
require-version mismatch, or a user-authored replace conflict; references
without the override keep the historical `redirect_golang_unsupported`
warning (paid tier stays vendored — see `docs/design/golang-hosted.md`).
Wire schema gains `integrity.goModH1` and
`registryOverride.identifiers.goModuleVersion` (additive). Requires
server-side publication of the grant-free `gopatch` artifact flavor —
production publishes no golang hosted modules yet, so behavior is unchanged
until it does.
- **Version-bump automation + release-readiness gate.**
`scripts/bump-version.sh <X.Y.Z> --pr` performs the whole bump chore —
stamps every packaging site via `version-sync.sh`, rolls `[Unreleased]`
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1228,5 +1228,6 @@ by `setup --exclude`).
surface: exact JSON shapes, exit codes, flag/env bindings, and the semver policy that
governs them.
- **[Design notes](docs/design/)** — e.g. [the configuration model](docs/design/configuration.md)
and [why hosted mode is impossible for Go](docs/design/golang-hosted-no-go.md).
and [hosted mode for Go](docs/design/golang-hosted.md) (free tier; the
[paid-tier no-go analysis](docs/design/golang-hosted-no-go.md) it supersedes).
- **[Changelog](CHANGELOG.md)**
2 changes: 1 addition & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments

`scan --vendor --detached` performs the same vendoring **without ever writing `.socket/manifest.json`**: records are fetched into memory (`download.detached: true`), the artifacts are built + wired, and the ledger entry carries `detached: true` plus an embedded copy of the patch record (`record`) as the verification source. Detached patches are invisible to apply/rollback/repair (nothing is in the manifest), exempt from `vendor`'s manifest reconcile, and exit via `remove <purl>` (which reverts them) or `vendor --revert`. Idempotent re-runs reuse the embedded record and skip the patch-view fetch entirely.

`scan --mode hosted` (== `--redirect`) swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither recorded nor attested. Re-runs over already-rewritten output record zero new edits. JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR).
`scan --mode hosted` (== `--redirect`) swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL, or — for golang — the `patch.socket.dev/gopatch/<uuid>` module path) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither recorded nor attested. Re-runs over already-rewritten output record zero new edits. JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR).

The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate and `bun.lock`), `requirements.txt` / `uv.lock`, `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` v1 — a binary `bun.lockb` with no text lock is auto-migrated to text via `bun install --save-text-lockfile --frozen-lockfile --lockfile-only` before the read, recorded as a `removed` FileEdit; `redirect_bun_lockb_would_migrate` on `--dry-run`, `redirect_bun_lockb_unsupported` when the migration is unavailable). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces/<name>/pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `<version>-socket.<hex8>` by rewriting the literal `<version>` (`redirect_maven_dep_version`) or adding a `<dependencyManagement>` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed).

Expand Down
125 changes: 86 additions & 39 deletions crates/socket-patch-cli/src/commands/scan/hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ const REDIRECT_CANDIDATE_FILES: &[&str] = &[
// on diverging spellings).
"gems.rb",
"gems.locked",
// The golang rewriter edits the main module's go.mod (fork-style
// `replace`) and go.sum (the socket module's two h1: lines). go.sum may
// legitimately be absent — the rewriter creates it in that case.
"go.mod",
"go.sum",
"pom.xml",
// Maven Trusted Checksums files the fail-closed maven rewriter merges into
// (read so an existing user config / checksum set is preserved, not
Expand Down Expand Up @@ -152,12 +157,22 @@ pub(super) async fn run_redirect(

let mut skipped: Vec<serde_json::Value> = Vec::new();
let mut overrides: Vec<DepOverride> = Vec::new();
// (purl, uuid, artifact_url, registry index_url, maven suffixed version)
// per granted reference — used AFTER the rewrite to decide which deps were
// actually redirected (their target URL / index / suffixed version landed
// in a file) before persisting records or attesting anything. The last
// element is Some only for fail-closed maven overrides.
type RedirectCandidate = (String, String, String, Option<String>, Option<String>);
// (purl, uuid, artifact_url, registry index_url, maven suffixed version,
// go module path) per granted reference — used AFTER the rewrite to decide
// which deps were actually redirected (their target URL / index / suffixed
// version / socket module path landed in a file) before persisting records
// or attesting anything. The fifth element is Some only for fail-closed
// maven overrides; the sixth only for golang (whose go.mod/go.sum edits
// carry the content-addressed `patch.socket.dev/gopatch/<uuid>` module
// path, never the artifact or index URL).
type RedirectCandidate = (
String,
String,
String,
Option<String>,
Option<String>,
Option<String>,
);
let mut candidates: Vec<RedirectCandidate> = Vec::new();

if !selected.is_empty() {
Expand Down Expand Up @@ -213,6 +228,26 @@ pub(super) async fn run_redirect(
if let Some(c) = berry_zip.and_then(|a| a.integrity.yarn_berry10c0.clone()) {
integrity.yarn_berry10c0 = Some(c);
}
// goproxy: the hosted-Go hash pair rides the override's
// identifiers (the tarball's dirhashH1 is the original-path
// flavor, kept for vendor-mode verification); the golang
// rewriter reads the normalized integrity, so merge — the
// gopatch-flavor zip h1 REPLACES dirhashH1 here. Only both
// together: a half-merged pair would trip the rewriter's
// fail-closed integrity check by design.
if let Some(ov) = reference
.registry_override
.as_ref()
.filter(|o| o.kind == "goproxy")
{
if let (Some(zip_h1), Some(gomod_h1)) = (
ov.identifiers.go_zip_dirhash_h1.clone(),
ov.identifiers.go_mod_h1.clone(),
) {
integrity.dirhash_h1 = Some(zip_h1);
integrity.go_mod_h1 = Some(gomod_h1);
}
}
candidates.push((
purl.to_string(),
sel.uuid.clone(),
Expand All @@ -225,6 +260,10 @@ pub(super) async fn run_redirect(
.registry_override
.as_ref()
.and_then(|o| o.identifiers.maven_suffixed_version.clone()),
reference
.registry_override
.as_ref()
.and_then(|o| o.identifiers.go_module_path.clone()),
));
overrides.push(DepOverride {
ecosystem,
Expand Down Expand Up @@ -484,39 +523,47 @@ pub(super) async fn run_redirect(
.collect();
let confirmed: Vec<(String, String)> = candidates
.iter()
.filter(|(purl, uuid, artifact_url, index_url, suffixed_version)| {
// Cargo is transactional: the rewriter reports exactly which
// patch uuids FULLY landed (manifest pin + lock + registry
// block). Substring presence must never confirm a cargo dep —
// the `[registries.…]` config block contains the index URL while
// pinning nothing, so a config-block-only rewrite would be
// attested with zero enforcement in any build.
if purl.starts_with("pkg:cargo/") {
return rewrite.confirmed_cargo_uuids.contains(uuid);
}
let encoded = socket_patch_core::utils::uri::encode_uri_component(artifact_url);
final_texts.iter().any(|text| {
// The rewriters' own predicate — raw, or the `\/`-escaped
// slashes an old composer.lock spells them with — so a
// writer's spelling can never be one this probe misses. It
// was: the composer rewriter emitted `\/`-escaped urls this
// probe never looked for, so a fully successful composer
// redirect reported `redirected: 0`, fetched no patch record
// into the ledger, and left the patch unattestable by `vex`.
socket_patch_core::patch::redirect::artifact_url_present(text, artifact_url)
// The berry rewriter writes the URL percent-encoded into the
// lock's `::__archiveUrl=` binding, so the raw form is absent.
|| text.contains(encoded.as_str())
|| index_url.as_deref().is_some_and(|iu| text.contains(iu))
// Fail-closed maven pins the globally-unique
// `-socket.<hex8>` suffixed version (never the `.pom` URL),
// so match on that string.
|| suffixed_version
.as_deref()
.is_some_and(|sv| text.contains(sv))
})
})
.map(|(purl, uuid, _, _, _)| (purl.clone(), uuid.clone()))
.filter(
|(purl, uuid, artifact_url, index_url, suffixed_version, go_module_path)| {
// Cargo is transactional: the rewriter reports exactly which
// patch uuids FULLY landed (manifest pin + lock + registry
// block). Substring presence must never confirm a cargo dep —
// the `[registries.…]` config block contains the index URL while
// pinning nothing, so a config-block-only rewrite would be
// attested with zero enforcement in any build.
if purl.starts_with("pkg:cargo/") {
return rewrite.confirmed_cargo_uuids.contains(uuid);
}
let encoded = socket_patch_core::utils::uri::encode_uri_component(artifact_url);
final_texts.iter().any(|text| {
// The rewriters' own predicate — raw, or the `\/`-escaped
// slashes an old composer.lock spells them with — so a
// writer's spelling can never be one this probe misses. It
// was: the composer rewriter emitted `\/`-escaped urls this
// probe never looked for, so a fully successful composer
// redirect reported `redirected: 0`, fetched no patch record
// into the ledger, and left the patch unattestable by `vex`.
socket_patch_core::patch::redirect::artifact_url_present(text, artifact_url)
// The berry rewriter writes the URL percent-encoded into the
// lock's `::__archiveUrl=` binding, so the raw form is absent.
|| text.contains(encoded.as_str())
|| index_url.as_deref().is_some_and(|iu| text.contains(iu))
// Fail-closed maven pins the globally-unique
// `-socket.<hex8>` suffixed version (never the `.pom` URL),
// so match on that string.
|| suffixed_version
.as_deref()
.is_some_and(|sv| text.contains(sv))
// golang pins the content-addressed
// `patch.socket.dev/gopatch/<uuid>` module path into
// go.mod + go.sum (no URL ever lands in either file).
|| go_module_path
.as_deref()
.is_some_and(|gm| text.contains(gm))
})
},
)
.map(|(purl, uuid, _, _, _, _)| (purl.clone(), uuid.clone()))
.collect();

// Fetch the full patch view (file hashes + vulnerabilities) for each
Expand Down
10 changes: 8 additions & 2 deletions crates/socket-patch-cli/src/update_notifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -445,9 +445,15 @@ mod tests {
// ceiling — the override never silently changes shipped behavior.
assert_eq!(grace_budget_from(None), Duration::from_millis(500));
assert_eq!(grace_budget_from(Some("")), Duration::from_millis(500));
assert_eq!(grace_budget_from(Some("not-a-number")), Duration::from_millis(500));
assert_eq!(
grace_budget_from(Some("not-a-number")),
Duration::from_millis(500)
);
// A valid value lifts the ceiling (the e2e suite's escape hatch).
assert_eq!(grace_budget_from(Some("30000")), Duration::from_millis(30_000));
assert_eq!(
grace_budget_from(Some("30000")),
Duration::from_millis(30_000)
);
assert_eq!(grace_budget_from(Some("0")), Duration::from_millis(0));
}

Expand Down
Loading
Loading