Skip to content

Harden vendored-mode trust in patch-service artifacts - #249

Merged
Mikola Lysenko (mikolalysenko) merged 7 commits into
mainfrom
fix/vendor-service-integrity-hardening
Sep 24, 2026
Merged

Mikola Lysenko (mikolalysenko) merged 7 commits into
mainfrom
fix/vendor-service-integrity-hardening

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

These are pre-existing gaps in how vendored mode trusts and records artifacts from the patch service (patch.socket.dev). They were found while root-causing #248's CI flake, and each was reproduced with a failing test before it was fixed. The companion PR for outage idempotence (vendored re-runs that churn locks after a 503) is separate.

  1. Stale ledger fingerprint after a rebuild (gem / maven / nuget).
    • When a wired artifact was missing and got rebuilt, the vendor ledger kept the old fingerprint: the gem file inventory, the maven/nuget artifact.sha256, and the nuget lock record. As a result:
      • vex/verify reported tamper;
      • repair could fail;
      • vendor --revert left packages.lock.json pinned to the patched contentHash;
      • the run was labelled already_vendored.
    • The rebuild branches now return a refreshed entry built from the new bytes, and the persist step carries the wiring forward. It saves the entry only over a predecessor with the same uuid.
  2. Service archives accepted without checking the patch (maven / nuget / pypi / npm). Served bytes that passed the SRI were trusted even if they didn't contain the patched files. Every patched member is now checked against the record's afterHashes before anything is written:
    • auto falls back and warns vendor_prebuilt_layout_mismatch;
    • service refuses.
  3. An integrity mismatch fell back under auto. This affected golang, composer, pypi, npm and gem (the .gem and the stub gemspec). It broke the documented rule that tampered bytes are always a hard failure. They now fail closed with vendor_prebuilt_integrity_mismatch, as cargo already did.
  4. --vendor-source service failed open. With no API client it quietly built locally. It now refuses with vendor_prebuilt_required. The cargo wired-copy rebuild also follows --vendor-source now: offline or no client refuses, and online uses the prebuilt crate.

CLI_CONTRACT.md and CHANGELOG.md ([Unreleased], Fixed) are updated.

Test plan

  • A regression test per issue and per backend. Each one fails when its fix is temporarily reverted:
    • rebuild refreshes the ledger, then --revert restores the lock (nuget), and the artifact is Healthy afterwards;
    • served archive missing the patched files → refused under service, falls back under auto;
    • IntegrityMismatch under auto hard-fails, per backend;
    • service with no client, and service with --offline, both refuse;
    • a rebuild over a ledger entry with another uuid leaves the ledger untouched.
  • Existing tests that pinned the old behaviour are updated. Service fixtures now serve real archives.
  • cargo test -p socket-patch-cli -p socket-patch-core --no-fail-fast: 7760 passed, 0 failed.
  • cargo clippy -p socket-patch-cli -p socket-patch-core --all-targets -- -D warnings: clean.

Not in this PR

  • When a maven/nuget rebuild recreates a whole uuid directory, it doesn't rewrite the uuid marker file. This predates these changes.
  • cargo/composer/golang rebuilds still return no ledger entry. That's harmless, because those entries record no fingerprint.
  • npm_common.rs also changes in the outage-idempotence PR. The hunk here is small and separate, so expect at most a trivial merge.

🤖 Generated with Claude Code


Note

High Risk
Changes fail-closed behavior for tampered or unpatched service artifacts and mutates vendor ledger persistence on rebuilds across many ecosystems, affecting verify, repair, and revert flows.

Overview
Hardens vendored-mode trust in patch-service artifacts and fixes ledger drift when missing committed copies are rebuilt.

Artifact rebuilds now refresh ledger fingerprints (gem inventory, maven/nuget sha256, nuget lock pin) while preserving wiring via carry_forward_wiring; the CLI only persists a rebuilt entry when a same-uuid predecessor exists, so revert cannot be left with an orphan artifact.

Prebuilt downloads must carry patched files: members are checked against afterHash before use (vendor_prebuilt_layout_mismatch under auto, refuse under service). Integrity mismatches no longer fall back to local builds under auto for npm, pypi, composer, gem, and golang (vendor_prebuilt_integrity_mismatch). --vendor-source=service without an API client now refuses instead of building locally; cargo wired-copy rebuilds follow the same service/offline rules as a fresh vendor.

CHANGELOG and CLI_CONTRACT.md document the new refusal codes and fallback table; repair/vendor paths and broad regression tests cover revert, uuid-mismatch, and service fixtures with real archives.

Reviewed by Cursor Bugbot for commit 032ab2b. Configure here.

…get artifact is rebuilt

When the hot path found the committed artifact missing or stale, it rebuilt
it but returned no entry, so the ledger kept the previous fingerprint: the
gem file inventory, the maven/nuget sha256, and the nuget lock pin. A
rebuild from the other source (service vs local) then produced bytes the
ledger did not describe. VEX and verify reported tamper, repair of a
service-vendored maven/nuget entry could fail, --revert left
packages.lock.json pinned to the patched contentHash, and a service-sourced
rebuild was labelled already_vendored.

The rebuild branches now return a refreshed entry built from the new bytes
or tree, with no wiring of their own. For nuget the entry carries only the
re-pinned lock record, with original: None. carry_forward_wiring (same
uuid) re-attaches the first run's records and fills in the true
pre-vendor hash.

The CLI does not record such an entry when the ledger has no previous one,
because it would carry no wiring and --revert would delete the artifact
while the project still points at it. repair carries the repaired entry's
wiring forward before persisting, and emits vendor_inventory_refreshed when
the backend's refreshed inventory differs from the recorded one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…fore accepting it

service_archive_copy (the Tier-A maven .jar / nuget .nupkg path) accepted any
archive that matched its SRI. The bytes were written verbatim and every
patched file was reported AlreadyPatched, so a served archive without the
patch was committed as patched. The hot path then saw a stale artifact on
every later run and rebuilt it.

The helper now takes the PatchRecord and requires every patched member to
hash to its afterHash (zip_bytes_match_after_hashes, the check the hot path
already uses) before returning Used. A mismatch is a service miss: auto
falls back to the local build with vendor_prebuilt_layout_mismatch, and
service refuses with vendor_prebuilt_required. The vendor_prebuilt_downloaded
advisory is only pushed for accepted bytes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…to too

ServiceArtifact documents IntegrityMismatch as always a hard error, but
golang, composer, pypi, npm and gem (the .gem and the stub gemspec) mapped
it through their miss policy. Under auto they warned and built the package
locally, so a sign of tampering became a quiet fallback. Under service
they refused with the generic vendor_prebuilt_required code.

These arms now refuse in every mode, as cargo already did: golang,
composer, pypi and gem return vendor_prebuilt_integrity_mismatch, and npm
fails the package with the integrity detail. The npm test that pinned the
old fallback is replaced by one that expects the refusal. The service-mode
tests for these ecosystems now expect the specific code.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…figured

Every backend's service helper treats !service_enabled() as "build
locally", and service_enabled() is false without a client. The
service-only policy was enforced by common::service_offline_conflict, which
checked only --offline. So a VendorServiceConfig with source: Service and
client: None built the artifact locally in every backend, contradicting
service's fail-closed promise. The CLI always passes a client, so only
library callers could reach this.

The gate every backend already calls at its entry point now also refuses
that combination (vendor_prebuilt_required), before any service
consultation or write. Regression tests cover all eight backends.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The in-sync hot path rebuilt a missing/stale crate copy with
copy_and_patch directly, so `--vendor-source=service` with --offline or
no API client rebuilt locally and reported success, and online service
mode never consulted the patch service. Refuse via
service_offline_conflict first and prefer cargo_service_copy, falling
back to the local build only where the policy allows, as composer and
gem already do.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The service SRI proves only that the download is intact. A wheel or
tarball whose patched members still held the original bytes was written
as-is, reported as already patched, and pinned in the lockfile. Check
each patched member against its afterHash before using the artifact:
auto builds locally with vendor_prebuilt_layout_mismatch, service
refuses (pypi: vendor_prebuilt_required; npm fails the package). Adds
read_archive_bytes_to_map for the in-memory tarball check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A wiring-less refreshed entry relies on carry_forward_wiring, which
re-attaches wiring only from a same-uuid ledger entry. Over an entry
from another patch uuid (the run that wired this uuid never saved), the
guard let it through, saving an entry --revert could not unwire and
sweeping the other uuid's dir. Leave the ledger as it is instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mikolalysenko
Mikola Lysenko (mikolalysenko) merged commit fce6fe6 into main Sep 24, 2026
200 checks passed
@mikolalysenko
Mikola Lysenko (mikolalysenko) deleted the fix/vendor-service-integrity-hardening branch September 24, 2026 13:14
Mikola Lysenko (mikolalysenko) added a commit that referenced this pull request Sep 24, 2026
…outage-idempotence

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants