Skip to content
Open
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
58 changes: 58 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
97 changes: 97 additions & 0 deletions .github/workflows/installer-drift.yml
Original file line number Diff line number Diff line change
@@ -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"
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 23 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
136 changes: 136 additions & 0 deletions docs/installer-hosting.md
Original file line number Diff line number Diff line change
@@ -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, `<base>/latest/download/<file>` and
`<base>/download/v<ver>/<file>`:

```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/<asset>` | that release's asset, immutably cached |
| `install.socket.dev/patch/dl/latest/<asset>` | 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
Loading
Loading