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"