diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66b14fd..b0c5570 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,6 +95,31 @@ jobs: # same surface. shellcheck is pre-installed on ubuntu-latest. run: shellcheck --shell=sh scripts/install.sh + - name: Shell — run the installer end to end + # shellcheck proves the script parses; this proves it installs. The + # script is what install.socket.dev/patch serves and what the README + # tells people to pipe into a shell, so "it downloads the latest + # release, verifies SHA256SUMS, and produces a binary that runs" is + # worth asserting on every PR rather than discovering from a user. + # Installs the LATEST RELEASE, not this checkout — on a version-bump PR + # that is deliberately the previous version. + run: | + sh scripts/install.sh + command -v socket-patch + socket-patch --version + + - name: Shell — the installer URL is consistent across the docs + # The README, the script's own usage comment, and the hosting runbook + # all name the canonical URL. Keeping them in lockstep is the whole + # promise of install.socket.dev/patch being "a copy of this file". + run: | + for f in README.md scripts/install.sh docs/installer-hosting.md; do + if ! grep -qF 'https://install.socket.dev/patch' "$f"; then + echo "Error: $f no longer references https://install.socket.dev/patch" >&2 + exit 1 + fi + done + - name: Shell — shellcheck the release scripts run: shellcheck scripts/version-sync.sh scripts/bump-version.sh scripts/release-lint.sh diff --git a/.github/workflows/installer-drift.yml b/.github/workflows/installer-drift.yml new file mode 100644 index 0000000..762fac6 --- /dev/null +++ b/.github/workflows/installer-drift.yml @@ -0,0 +1,97 @@ +name: Installer drift + +# install.socket.dev/patch is supposed to be a byte-for-byte copy of +# scripts/install.sh — the README says so, and the whole point of hosting the +# installer on a Socket domain is that the bytes are auditable against this +# repository. Nothing enforces that at publish time from this side: the copy is +# published out of depscan's vendored `submodules/socket-patch` pin, so an +# installer change merged here is not live until that pin is bumped and depscan +# deploys (see docs/installer-hosting.md). +# +# This job is the watchdog for that gap. It is deliberately NOT part of CI: it +# checks a deployed artifact, not the diff, and a red run here means "go bump +# the pin", not "this PR is broken". +on: + schedule: + # Mondays, 07:00 UTC. + - cron: '0 7 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + drift: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Fetch the hosted installer + id: fetch + # Not `curl -f`: a non-200 body and its headers are the diagnostic. + run: | + url=https://install.socket.dev/patch + set +e + http=$(curl -sS -D headers.txt -o hosted-install.sh -w '%{http_code}' --max-time 30 "$url") + rc=$? + set -e + + # curl exit 6 is "could not resolve host": the domain has not been + # stood up yet, so there is nothing to be in drift with. Report and + # pass, rather than being red from the day this workflow merges. + if [ "$rc" -eq 6 ]; then + echo "::notice::install.socket.dev does not resolve yet — skipping the drift check." + echo 'deployed=false' >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [ "$rc" -ne 0 ]; then + echo "::error::curl exited $rc fetching $url" + exit 1 + fi + + if [ "$http" != '200' ]; then + echo "::error::$url returned HTTP $http" + # The failure mode this host is most exposed to: Cloudflare's bot + # challenge answers plain curl with a 403 and an HTML interstitial, + # which `curl | sh` would pipe straight into a shell. + if grep -qi '^cf-mitigated:' headers.txt; then + echo "::error::Cloudflare is challenging plain HTTP clients for install.socket.dev. The DNS record needs the same bot-challenge exemption patch.socket.dev has, or the documented one-liner feeds an HTML challenge page to sh." + fi + sed -n '1,40p' headers.txt + exit 1 + fi + + echo 'deployed=true' >> "$GITHUB_OUTPUT" + + - name: Compare against scripts/install.sh + if: steps.fetch.outputs.deployed == 'true' + run: | + if ! diff -u scripts/install.sh hosted-install.sh; then + echo "::error::install.socket.dev/patch has drifted from scripts/install.sh. Fix: bump submodules/socket-patch in depscan to this commit and deploy — see docs/installer-hosting.md." + exit 1 + fi + echo "install.socket.dev/patch matches scripts/install.sh" + + - name: Check the hosted copy is a usable script + if: steps.fetch.outputs.deployed == 'true' + # Belt and braces: even with matching bytes, verify what is served is + # something a shell will accept. Catches a publish that mangled line + # endings or content-encoding in a way diff -u glosses over. + run: | + shellcheck --shell=sh hosted-install.sh + sh -n hosted-install.sh + + - name: Check the published checksum + if: steps.fetch.outputs.deployed == 'true' + run: | + served=$(curl -fsSL --max-time 30 https://install.socket.dev/patch.sha256 | tr -d '[:space:]') + expected=$(sha256sum scripts/install.sh | awk '{print $1}') + if [ "$served" != "$expected" ]; then + echo "::error::install.socket.dev/patch.sha256 is $served, expected $expected" + exit 1 + fi + echo "published checksum matches: $expected" diff --git a/CHANGELOG.md b/CHANGELOG.md index ae09791..04c26c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -337,6 +337,19 @@ into the new version's section — see docs/releasing.md. ### Changed +- **The documented one-liner installs from `https://install.socket.dev/patch`.** + The previous URL was `raw.githubusercontent.com`, which asks users to trust a + third-party CDN for a script they pipe into a shell and is the first URL a + locked-down egress policy blocks. The hosted copy is byte-for-byte + `scripts/install.sh`, with its SHA-256 published at + `install.socket.dev/patch.sha256`; the GitHub raw URL keeps working and serves + the same bytes. Binaries are still downloaded from the GitHub release and + verified against its `SHA256SUMS` — the trust model is unchanged, only the + script's origin moved. New: `docs/installer-hosting.md` (how the copy is + published), a CI step that runs the installer end to end instead of only + linting it, and an `installer-drift` workflow that checks the hosted copy + against this repository weekly. + - **Release workflow consolidated into a single `release.yml`.** One dispatch now publishes every ecosystem package — crates.io, npm, PyPI, RubyGems (both gems, via OIDC trusted publishing), Packagist, Maven diff --git a/README.md b/README.md index a243fce..c06960b 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,22 @@ CVEs you've already fixed. One-line install (macOS / Linux): ```bash -curl -fsSL https://raw.githubusercontent.com/SocketDev/socket-patch/main/scripts/install.sh | sh +curl -fsSL https://install.socket.dev/patch | sh ``` -Detects your platform (macOS/Linux, x64/ARM64), downloads the latest binary, and installs -to `/usr/local/bin` or `~/.local/bin`. Use `sudo sh` instead of `sh` if `/usr/local/bin` -requires root. +Detects your platform (macOS/Linux, x64/ARM64), downloads the latest binary, verifies it +against the release's `SHA256SUMS`, and installs to `/usr/local/bin` or `~/.local/bin`. +Use `sudo sh` instead of `sh` if `/usr/local/bin` requires root. Pin a version with +`SOCKET_PATCH_VERSION=3.3.0 sh` instead of plain `sh`. + +`install.socket.dev` serves a copy of [`scripts/install.sh`](scripts/install.sh) from +this repository — read it before you run it, either there or at +[install.socket.dev/patch](https://install.socket.dev/patch). If you would rather not +depend on the Socket domain, `curl -fsSL +https://raw.githubusercontent.com/SocketDev/socket-patch/main/scripts/install.sh | sh` +does the same thing from the same bytes. See +[docs/installer-hosting.md](docs/installer-hosting.md) for how the hosted copy is +published. On Windows, install via npm or the dotnet tool (below), or grab a prebuilt `socket-patch-*-pc-windows-msvc.zip` from the diff --git a/docs/installer-hosting.md b/docs/installer-hosting.md new file mode 100644 index 0000000..e1439d4 --- /dev/null +++ b/docs/installer-hosting.md @@ -0,0 +1,91 @@ +# Hosting the installer at install.socket.dev + +The documented one-liner is + +```sh +curl -fsSL https://install.socket.dev/patch | sh +``` + +`install.socket.dev/patch` serves a **byte-for-byte copy of +[`scripts/install.sh`](../scripts/install.sh)** — not a rendered template, not a +different script. The README says so, so it has to stay true. + +## Why a Socket domain + +The one-liner used to point at `raw.githubusercontent.com`. That asks a user to +trust a third-party CDN for a script they pipe into a shell, and it is the first +URL a locked-down egress policy blocks. `install.socket.dev` is a name Socket +controls, already inside the trust boundary a customer grants `socket.dev`, and +it stays stable if the artifacts ever move. + +The GitHub URL still works and still serves the same bytes. Anyone who would +rather not add a dependency on the Socket domain can keep using it. + +## What the trust model actually is + +Unchanged by the hosting move, and worth being precise about: + +- **The script** is fetched over HTTPS from a Socket-controlled host. Its SHA-256 + is published alongside it at `install.socket.dev/patch.sha256`, and it can be + diffed against `scripts/install.sh` in this repo. +- **The binary** is fetched from the GitHub release and verified against that + release's `SHA256SUMS` before it is unpacked. Neither the script nor the + checksums are signed — this is checksum integrity rooted in HTTPS plus GitHub, + the same model `--update` and the gem/composer launchers use (see + [CLI_CONTRACT.md](../crates/socket-patch-cli/CLI_CONTRACT.md)). +- Nothing in the install path sends a Socket API token anywhere. + +Hosting the script on a Socket domain moves *who serves the script*. It does not +add a signature, and the docs should not imply that it does. + +## How a change to the installer reaches the domain + +The publish path lives in [depscan][depscan], which vendors this repository as +`submodules/socket-patch`: + +1. A change to `scripts/install.sh` merges **here**. +2. depscan's `submodules/socket-patch` pin is bumped to that commit. +3. depscan's prod deploy runs its **Publish install.socket.dev site** step, + which copies `submodules/socket-patch/scripts/install.sh` to + `gs://socket-install-prod/patch`, publishes its sha256 and the landing page, + then re-reads the object and fails the deploy if the bytes do not match. +4. `install-server` (a `gcs-bucket-server` instance, `tanka/lib/depscan/install-server.libsonnet`) + serves that bucket at `install.socket.dev`. + +So an installer change needs a depscan submodule bump plus a deploy. That +indirection is deliberate: this repository is public and needs no write +credentials into a Socket bucket, and a submodule bump is a reviewed change, so +nothing reaches a `curl | sh` endpoint without review on the depscan side too. + +**A new socket-patch release needs none of this.** The script resolves the latest +release itself at run time (`/releases/latest/download`), so cutting 3.4.0 +changes what the hosted installer *installs* without changing the hosted +installer. Only edits to the script itself require a publish. + +## The drift check + +`.github/workflows/installer-drift.yml` (weekly, plus `workflow_dispatch`) +fetches `install.socket.dev/patch` and diffs it against `scripts/install.sh` on +`main`. + +- **Different** → the job fails. The fix is a depscan submodule bump + deploy + (steps 2–3 above). Expect this to be red in the window between merging an + installer change here and bumping the pin there. +- **Host does not resolve** → the job reports "not deployed yet" and passes, so + the check is inert until the domain exists. + +The check also runs `shellcheck` and `sh -n` against the *fetched* copy, so a +mangled publish is caught even when the hash somehow matches expectations. + +## Known gaps + +- **No Windows installer.** The script is POSIX `sh`; native Windows users go + through a package manager or a release archive. A `patch.ps1` object on the + same host would be the natural addition — the hosting side already supports + it, nothing here does yet. +- **Objects must stay flat.** `gcs-bucket-server` interpolates the object name + into the GCS JSON API URL unencoded, so only bucket-root keys resolve + (`patch`, `patch.sha256`, `index.html`). A nested path like + `/patch/3.3.0/install.sh` would 404 until that is fixed on the depscan side. + +[depscan]: https://github.com/SocketDev/depscan diff --git a/scripts/install.sh b/scripts/install.sh index 5ea1005..00f5b15 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -3,10 +3,14 @@ set -eu # Socket Patch installer # Usage: -# curl -fsSL https://raw.githubusercontent.com/SocketDev/socket-patch/main/scripts/install.sh | sh +# curl -fsSL https://install.socket.dev/patch | sh +# +# install.socket.dev/patch serves a byte-for-byte copy of this file; the URL +# above and the raw.githubusercontent.com path to this script are +# interchangeable. See docs/installer-hosting.md for how the copy is published. # # Override the version that gets installed by exporting SOCKET_PATCH_VERSION: -# curl -fsSL .../install.sh | SOCKET_PATCH_VERSION=3.0.0 sh +# curl -fsSL https://install.socket.dev/patch | SOCKET_PATCH_VERSION=3.0.0 sh REPO="SocketDev/socket-patch" BINARY="socket-patch"