diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66b14fd..2cdb34d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,6 +95,64 @@ 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 — run the installer against an alternate origin + # Exercises SOCKET_PATCH_BASE_URL (and SOCKET_PATCH_INSTALL_DIR) with a + # base that is not the default. Uses GitHub's own releases base, which + # is the same URL shape install.socket.dev serves, so the template the + # script builds is covered regardless of whether the Socket relay is + # deployed yet. The dedicated Socket-origin check is the next step. + run: | + SOCKET_PATCH_BASE_URL=https://github.com/SocketDev/socket-patch/releases \ + SOCKET_PATCH_INSTALL_DIR="$RUNNER_TEMP/alt-origin" \ + sh scripts/install.sh + "$RUNNER_TEMP/alt-origin/socket-patch" --version + + - name: Shell — install through install.socket.dev, once it exists + # The whole point of the relay is that a client never has to reach + # github.com. That is only assertable against the deployed host, so this + # step skips itself until the host resolves rather than being red from + # the day it merges (same posture as the installer-drift workflow). + run: | + if ! curl -sfI -m 20 https://install.socket.dev/patch/latest >/dev/null 2>&1; then + echo "::notice::install.socket.dev/patch/latest does not answer yet — skipping the Socket-origin install." + exit 0 + fi + latest=$(curl -fsSL -m 20 https://install.socket.dev/patch/latest) + echo "install.socket.dev reports latest=$latest" + SOCKET_PATCH_BASE_URL=https://install.socket.dev/SocketDev/socket-patch/releases \ + SOCKET_PATCH_INSTALL_DIR="$RUNNER_TEMP/socket-origin" \ + sh scripts/install.sh + installed=$("$RUNNER_TEMP/socket-origin/socket-patch" --version | awk '{print $NF}') + if [ "$installed" != "$latest" ]; then + echo "::error::install.socket.dev says latest is $latest but installed $installed" >&2 + exit 1 + fi + + - 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..ee841ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -337,6 +337,31 @@ into the new version's section — see docs/releasing.md. ### Changed +- **`install.sh` can install without reaching github.com.** New + `SOCKET_PATCH_BASE_URL` points the archive downloads at any releases base that + answers GitHub's two asset paths — notably + `https://install.socket.dev/SocketDev/socket-patch/releases`, which relays them + from the GitHub release, so one URL template covers either origin. A new + release needs no publish for this: the origin resolves "latest" per request. + `socket-patch --update` can use the same host today through the + `SOCKET_UPDATE_BASE_URL` override it already has. Also new: + `SOCKET_PATCH_INSTALL_DIR` to choose the install directory explicitly instead + of taking `/usr/local/bin` or `~/.local/bin`. The default download origin is + still GitHub — see `docs/installer-hosting.md`. + +- **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..74f4ad8 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,31 @@ 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`. + +On a network that blocks or distrusts `github.com`, set `SOCKET_PATCH_BASE_URL` so the +archives come from Socket too — `install.socket.dev` relays them from the GitHub release, +checksums included: + +```bash +curl -fsSL https://install.socket.dev/patch \ + | SOCKET_PATCH_BASE_URL=https://install.socket.dev/SocketDev/socket-patch/releases 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..07ad96e --- /dev/null +++ b/docs/installer-hosting.md @@ -0,0 +1,136 @@ +# 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. + +## Installing without reaching github.com + +By default the script downloads archives from the GitHub release. Point it +somewhere else with `SOCKET_PATCH_BASE_URL` — a releases base that answers +GitHub's two asset paths, `/latest/download/` and +`/download/v/`: + +```sh +curl -fsSL https://install.socket.dev/patch \ + | SOCKET_PATCH_BASE_URL=https://install.socket.dev/SocketDev/socket-patch/releases sh +``` + +`install.socket.dev` relays those exact paths from the GitHub release, which is +why one template covers both origins and the script needs no branching. It also +exposes a cleaner shape for humans and for scripts that want the version: + +| Endpoint | Serves | +|---|---| +| `install.socket.dev/patch/latest` | the latest version as plain text (`3.4.0`) | +| `install.socket.dev/patch/dl/v3.4.0/` | that release's asset, immutably cached | +| `install.socket.dev/patch/dl/latest/` | the same asset from whatever is latest | + +**A new release needs no publish for any of this.** "Latest" is resolved per +request against the upstream release, so cutting 3.4.0 makes it installable from +`install.socket.dev` immediately — nothing runs at release time. + +`socket-patch --update` can use the same host today, with no changes to the CLI, +via the endpoint override it already has: + +```sh +SOCKET_UPDATE_BASE_URL=https://install.socket.dev socket-patch --update +``` + +One caveat worth knowing before standardizing on that: a non-default +`SOCKET_UPDATE_BASE_URL` intentionally downgrades the downloaded binary's +version self-check from hard-fail to a warning, because the override is meant +for mirrors that may repackage. Making Socket's host a first-class endpoint set +that keeps the strict check is a CLI change, not a hosting one. + +## 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** — for the *bucket-backed* paths only (`patch`, + `patch.sha256`, `index.html`). `gcs-bucket-server` interpolates the object name + into the GCS JSON API URL unencoded, so only bucket-root keys resolve. This + does not affect `/patch/dl/**`, which is relayed by a separate service and + never touches the bucket. +- **The default download origin is still GitHub.** The `SOCKET_PATCH_BASE_URL` + mechanism ships first; flipping the default to `install.socket.dev` is a + one-line change, deliberately held until the relay is verified in prod. A + script that defaults to a host which does not answer yet is a broken installer + for everyone running it from a git checkout or the raw GitHub URL. + +[depscan]: https://github.com/SocketDev/depscan diff --git a/scripts/install.sh b/scripts/install.sh index 5ea1005..f2bc28e 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -3,14 +3,41 @@ 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 +# +# Override where the archives come from with SOCKET_PATCH_BASE_URL — a releases +# base that answers GitHub's two asset paths, `/latest/download/` +# and `/download/v/`. Use it to install without reaching +# github.com at all: +# +# … | SOCKET_PATCH_BASE_URL=https://install.socket.dev/SocketDev/socket-patch/releases sh +# +# install.socket.dev relays those exact paths from the GitHub release, which is +# why one template covers both origins. Whichever origin is used, the archive is +# still verified against the SHA256SUMS fetched from that same origin. +# +# Override where the binary is installed with SOCKET_PATCH_INSTALL_DIR. REPO="SocketDev/socket-patch" BINARY="socket-patch" VERSION="${SOCKET_PATCH_VERSION:-latest}" +# Releases base. Default is GitHub; see the SOCKET_PATCH_BASE_URL note above for +# installing through install.socket.dev instead. Trailing slashes are trimmed so +# a base with one does not produce `//download`. +RELEASES_BASE="${SOCKET_PATCH_BASE_URL:-https://github.com/${REPO}/releases}" +while :; do + case "$RELEASES_BASE" in + */) RELEASES_BASE="${RELEASES_BASE%/}" ;; + *) break ;; + esac +done # Detect platform OS="$(uname -s)" @@ -84,8 +111,13 @@ else exit 1 fi -# Pick install directory -if [ -w /usr/local/bin ]; then +# Pick install directory. An explicit SOCKET_PATCH_INSTALL_DIR wins over both +# defaults — needed for unprivileged installs into a toolchain-managed prefix, +# and for testing the script without writing to a system path. +if [ -n "${SOCKET_PATCH_INSTALL_DIR:-}" ]; then + INSTALL_DIR="$SOCKET_PATCH_INSTALL_DIR" + mkdir -p "$INSTALL_DIR" +elif [ -w /usr/local/bin ]; then INSTALL_DIR="/usr/local/bin" else INSTALL_DIR="${HOME}/.local/bin" @@ -96,12 +128,14 @@ fi TMPDIR="$(mktemp -d)" trap 'rm -rf "$TMPDIR"' EXIT -# Pick the release path. "latest" resolves on GitHub's side; tagged versions are -# served from /releases/download/v/. +# Pick the release path. "latest" is resolved by the origin (GitHub redirects; +# install.socket.dev resolves it against the upstream release), so the script +# never has to know the version number. Tagged versions are served from +# /download/v/. if [ "$VERSION" = "latest" ]; then - BASE_URL="https://github.com/${REPO}/releases/latest/download" + BASE_URL="${RELEASES_BASE}/latest/download" else - BASE_URL="https://github.com/${REPO}/releases/download/v${VERSION#v}" + BASE_URL="${RELEASES_BASE}/download/v${VERSION#v}" fi ARCHIVE="${BINARY}-${TARGET}.tar.gz"