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
24 changes: 23 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -253,8 +253,30 @@ jobs:
# that path to subsequent steps so `Command::new("vexctl")` in
# the test resolves. Pinned to a tagged release rather than
# @latest for reproducibility.
#
# Retried: the install compiles sigstore/cosign, whose module
# verification reads dozens of sum.golang.org checksum tiles, and
# transient INTERNAL_ERROR stream resets there have failed this
# step on otherwise-green runs (2026-08-20: two attempts ~18 min
# apart, different modules each time — an upstream incident, not
# one bad tile). The backoff rides out short resets; a persistent
# outage still fails loudly on the last attempt. Follow-up option
# if this recurs: install the pinned release BINARY (sha256-pinned)
# instead of compiling, which sidesteps module verification and
# drops ~100s of compile time per leg.
shell: bash
run: |
go install github.com/openvex/vexctl@v0.3.0
for attempt in 1 2 3 4 5; do
if go install github.com/openvex/vexctl@v0.3.0; then
break
fi
if [ "$attempt" = 5 ]; then
echo "::error::go install vexctl failed on all 5 attempts"
exit 1
fi
echo "::warning::go install vexctl attempt $attempt failed; retrying"
sleep $((attempt * 20))
done
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"

- name: Run tests
Expand Down
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 @@ -109,7 +109,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments

`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). Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove <purl>` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status.

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).
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` lockfileVersion 1 or 2 — bun 1.3/1.4 share one emitted grammar; 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).

**Gem stale-install guard (additive warning — the canonical narrative; other mentions point here)**: the gem hosted rewrite is pure Gemfile/lock text, so a gem ALREADY materialized under the project's bundle paths keeps its upstream bytes — the next `bundle install` prints `Using <gem>` and never refetches, on **every** bundler major (live-verified 2026-08-19 on 1.17.3 / 2.7.2 / 4.0.18: bundler 4's CHECKSUMS verify at download time only, and nothing is downloaded; `bundle install --force`/`--redownload` re-install from the stale cached `.gem` instead of re-fetching — bundler 1 silently, bundler 4 with an exit-37 checksum refusal that still leaves the upstream bytes installed; the **verified** remedy is removing the installed dir + cache `.gem` + `specifications` entry, then `bundle install`). After the rewrite, a hosted run therefore probes the installed-gem discovery paths (the same ruby-crawler discovery `apply` uses, honoring `--global`/`--global-prefix` like scan's own discovery) for each confirmed gem redirect and judges the materialization against the patch record's `afterHash` file map. Judgment rules: records are found **by uuid** — this run's fetched records first, then the redirect ledger's persisted ones, so a transiently failed `/patches/view` fetch cannot retire the warning (it re-fires on every re-scan until the stale materialization is gone); a materialization with every file at `afterHash` is already patched and never warns (an agent→hosted migration stays quiet by construction), and when several confirmed variant purls resolve to one installed dir, ANY of them judging it patched keeps it quiet; staleness needs **positive evidence** — at least one record file whose bytes were actually read and hash to neither state's expectation — so missing or unreadable files never produce a warning. Warnings emit `redirect_gem_stale_install` (JSON `redirect.warnings[]` + a code-tagged stderr line) in three flavors: a PROJECT-LOCAL dir gets the verified delete-list remedy (installed dir, cache `.gem`, `specifications` entry — plus the project's committed `vendor/cache/<leaf>.gem` when present and not proven to be the patched artifact, since bundler installs from `vendor/cache` in preference to fetching); a SHARED gem-env home gets a caveat that the home is shared machine-wide and prefers migrating the project to a local bundle path over deleting shared files; and a committed `vendor/cache` archive whose sha256 differs from the patched artifact's warns standalone even with no installed dir at all (a fresh checkout with a committed stale cache re-materializes the upstream bytes forever). A stale-flagged purl is additionally **excluded from the same run's `--vex` `assume_applied` set** — the envelope must never attest a CVE its own warning says is live; the purl falls back to normal installed-tree verification (a patched install still attests, a stale one is omitted). The probe is read-only (nothing is deleted) and skipped on `--dry-run` — deliberately explicit, since nothing was rewritten but the ledger fallback could otherwise judge an already-redirected project. Exit code and `status` are unchanged (warning-only, the hosted-refusal posture); a same-run `--vex` may still fail on "nothing to attest" per the embedded-VEX contract.

Expand Down
75 changes: 70 additions & 5 deletions crates/socket-patch-cli/tests/e2e_redirect_bun_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,24 @@ struct BunRedirectFixture {
tmp: tempfile::TempDir,
proj: PathBuf,
patched: Vec<u8>,
/// True when the installed bun itself wrote `"lockfileVersion": 2`
/// (bun >= 1.4) — i.e. this toolchain can also READ a v2 lock, so the
/// fresh-checkout install proof is valid on a v2 lock.
native_lock_v2: bool,
_server: MockServer,
}

/// Steps 1–3: real install, patched tarball + API mocks, `scan --mode hosted
/// --vex`, and the envelope/lockfile/ledger assertions. `tamper_served_tarball`
/// serves DIFFERENT bytes than the sha512 pinned into the lock. `None` = skip.
async fn bun_hosted_project(tag: &str, tamper_served_tarball: bool) -> Option<BunRedirectFixture> {
/// serves DIFFERENT bytes than the sha512 pinned into the lock.
/// `force_lock_version` re-pins the fixture lock's `"lockfileVersion"` line
/// before the scan (sound because v1 and v2 share one emitted grammar — same
/// fixture locks are byte-identical except the integer). `None` = skip.
async fn bun_hosted_project(
tag: &str,
tamper_served_tarball: bool,
force_lock_version: Option<u64>,
) -> Option<BunRedirectFixture> {
if !has_command("bun") {
println!("SKIP e2e_redirect_bun_build ({tag}): `bun` not installed");
return None;
Expand Down Expand Up @@ -170,6 +181,26 @@ async fn bun_hosted_project(tag: &str, tamper_served_tarball: bool) -> Option<Bu
);
return None;
}
let native_lock = std::fs::read_to_string(proj.join("bun.lock")).unwrap();
let native_lock_v2 = native_lock.contains("\"lockfileVersion\": 2");
if let Some(v) = force_lock_version {
// Splice ONLY the version line (v1 and v2 share one emitted grammar).
let forced: String = native_lock
.split_inclusive('\n')
.map(|line| {
if line.trim_start().starts_with("\"lockfileVersion\":") {
format!(" \"lockfileVersion\": {v},\n")
} else {
line.to_string()
}
})
.collect();
assert!(
forced.contains(&format!("\"lockfileVersion\": {v},")),
"the fixture lock must carry a lockfileVersion line to force:\n{native_lock}"
);
std::fs::write(proj.join("bun.lock"), forced).unwrap();
}

let installed_dir = proj.join("node_modules").join(DEP);
let orig = std::fs::read(installed_dir.join("index.js")).expect("installed index.js");
Expand Down Expand Up @@ -366,6 +397,12 @@ async fn bun_hosted_project(tag: &str, tamper_served_tarball: bool) -> Option<Bu
lock.contains(&sri),
"bun.lock integrity must be the patched sha512 ({sri}); got:\n{lock}"
);
if let Some(v) = force_lock_version {
assert!(
lock.contains(&format!("\"lockfileVersion\": {v},")),
"the rewrite must preserve the lockfileVersion line verbatim; got:\n{lock}"
);
}

let ledger = std::fs::read_to_string(proj.join(".socket/vendor/redirect-state.json")).unwrap();
assert!(
Expand All @@ -377,6 +414,7 @@ async fn bun_hosted_project(tag: &str, tamper_served_tarball: bool) -> Option<Bu
tmp,
proj,
patched,
native_lock_v2,
_server: server,
})
}
Expand All @@ -402,11 +440,16 @@ fn fresh_checkout_bun_install(fx: &BunRedirectFixture) -> (PathBuf, Output) {
#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
async fn bun_redirect_fresh_checkout_installs_patched_bytes() {
let Some(fx) = bun_hosted_project("main", false).await else {
let Some(fx) = bun_hosted_project("main", false, None).await else {
return;
};
assert_patched_fresh_install(&fx);
}

let (fresh, ci) = fresh_checkout_bun_install(&fx);
/// Shared fresh-checkout proof: `bun install --frozen-lockfile` against an
/// empty cache must materialize the PATCHED bytes from the hosted tarball.
fn assert_patched_fresh_install(fx: &BunRedirectFixture) {
let (fresh, ci) = fresh_checkout_bun_install(fx);
assert!(
ci.status.success(),
"fresh-checkout `bun install --frozen-lockfile` must succeed from the hosted patch \
Expand All @@ -426,12 +469,34 @@ async fn bun_redirect_fresh_checkout_installs_patched_bytes() {
);
}

/// The bun 1.4 leg: `"lockfileVersion": 2` shares v1's emitted grammar (the
/// bump gates stricter parse checks — integrity hashes required for
/// off-registry npm tarballs, which our URL 3-tuple always carries), so the
/// redirect must rewrite a v2 lock exactly like a v1 lock. When the installed
/// bun is itself >= 1.4 (it WROTE v2 — older bun cannot read v2 locks), the
/// fresh-checkout frozen install must again produce the patched bytes.
#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
async fn bun_redirect_lock_v2_fresh_checkout_installs_patched_bytes() {
let Some(fx) = bun_hosted_project("lock-v2", false, Some(2)).await else {
return;
};
if !fx.native_lock_v2 {
println!(
"PARTIAL e2e_redirect_bun_build (lock-v2): installed bun writes lockfileVersion 1 \
(< 1.4) and cannot read the forced v2 lock — rewrite proven, install proof skipped"
);
return;
}
assert_patched_fresh_install(&fx);
}

/// Negative twin: the hosted route serves TAMPERED bytes while the lock pins
/// the real sha512 — the fresh frozen install must refuse.
#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
async fn bun_redirect_tampered_hosted_tarball_fails_frozen_install() {
let Some(fx) = bun_hosted_project("tampered", true).await else {
let Some(fx) = bun_hosted_project("tampered", true, None).await else {
return;
};

Expand Down
7 changes: 5 additions & 2 deletions crates/socket-patch-cli/tests/e2e_vendor_bun_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,9 +211,12 @@ fn bun_vendor_fresh_checkout_frozen_install_and_revert() {
let lock_before = std::fs::read(&lock_path).expect("bun.lock after bun install");
let pkg_before = std::fs::read(&pkg_path).expect("package.json");
let lock_before_str = String::from_utf8(lock_before.clone()).unwrap();
// bun 1.3 writes lockfileVersion 1, bun 1.4 writes 2 — one emitted
// grammar; both are wirable.
assert!(
lock_before_str.contains("\"lockfileVersion\": 1"),
"fixture must be a bun text lockfileVersion 1:\n{lock_before_str}"
lock_before_str.contains("\"lockfileVersion\": 1")
|| lock_before_str.contains("\"lockfileVersion\": 2"),
"fixture must be a bun text lockfileVersion 1 or 2:\n{lock_before_str}"
);
// Pre-vendor: the registry 4-tuple `["left-pad@1.3.0", "", {}, "sha512-…"]`.
assert!(
Expand Down
Loading
Loading