diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f99ecda..af433a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,3 +121,39 @@ jobs: - name: govulncheck # Dependabot does not update this version. Update it by hand. run: go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./... + + vuln-release: + name: vuln (latest release) + # Weekly only. The published binaries contain the Go standard library + # they were built with, so check the binaries themselves. A finding here + # needs a new release (see docs/releasing.md). + if: github.event_name == 'schedule' + runs-on: ubuntu-latest + steps: + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: stable + cache: false + - name: govulncheck on the binaries of the latest release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + tag="$(gh release list --repo "$GITHUB_REPOSITORY" --exclude-drafts \ + --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName // empty')" + if [ -z "$tag" ]; then + echo "No release yet. Nothing to check." + exit 0 + fi + dir="${RUNNER_TEMP}/release" + gh release download "$tag" --repo "$GITHUB_REPOSITORY" --dir "$dir" \ + --pattern 'pullgate_*_linux_*.tar.gz' + status=0 + for tarball in "$dir"/*.tar.gz; do + out="${tarball%.tar.gz}" + mkdir -p "$out" + tar -xzf "$tarball" -C "$out" pullgate + echo "== ${tag}: $(basename "$tarball")" + go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 -mode=binary "$out/pullgate" || status=1 + done + exit "$status" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ebf4d40 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,179 @@ +name: Release + +# How releases work, and the one-time setup: docs/releasing.md. + +on: + push: + branches: [main] + +# Each job asks for only the permissions it needs. +permissions: {} + +concurrency: + # One release run at a time. Never cancel a run in the middle of a release. + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + release-please: + # Skip until the GitHub App is set up (see docs/releasing.md). + if: vars.RELEASE_APP_CLIENT_ID != '' + runs-on: ubuntu-latest + # The App private key is a secret of this environment. Only main may use + # the environment, so only runs on main can read the key. + environment: release + # This job uses the App token, not GITHUB_TOKEN. + permissions: {} + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + version: ${{ steps.release.outputs.version }} + steps: + - id: app-token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 + with: + client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + - id: release + uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + with: + token: ${{ steps.app-token.outputs.token }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + # Builds the files. It can only read the repository: no OIDC token, and no + # write access to the release. + build: + needs: release-please + if: needs.release-please.outputs.release_created == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + env: + TAG: ${{ needs.release-please.outputs.tag_name }} + VERSION: ${{ needs.release-please.outputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/tags/${{ needs.release-please.outputs.tag_name }} + persist-credentials: false + - name: Check that the tag is the commit of this run + # The provenance attestation names $GITHUB_SHA as the source commit. + # Build only that commit. + run: | + set -euo pipefail + head="$(git rev-parse HEAD)" + tagged="$(git rev-parse "refs/tags/${TAG}^{commit}")" + echo "HEAD: $head tag ${TAG}: $tagged run: $GITHUB_SHA" + if [ "$head" != "$GITHUB_SHA" ] || [ "$tagged" != "$GITHUB_SHA" ]; then + echo "::error::The tag ${TAG} is not the commit of this run. See docs/releasing.md." + exit 1 + fi + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + # The newest Go, so the binaries get the newest standard library fixes. + go-version: stable + # No cache for release builds: a bad cache entry could change the binaries. + cache: false + - name: Build + # -w drops the debug data. The symbol table stays (no -s): govulncheck + # needs it to check the published binaries (see ci.yml). + run: | + set -euo pipefail + mkdir -p dist + # Fixed owner, mode and time in the tarball, so a rebuild of the + # same commit with the same Go version gives the same file. + mtime="@$(git log -1 --format=%ct)" + for arch in amd64 arm64; do + name="pullgate_${VERSION}_linux_${arch}" + stage="${RUNNER_TEMP}/${name}" + mkdir -p "$stage" + CGO_ENABLED=0 GOOS=linux GOARCH="$arch" \ + go build -trimpath -ldflags "-w -X main.version=${VERSION}" \ + -o "${stage}/pullgate" ./cmd/pullgate + cp LICENSE README.md "$stage/" + tar --owner=0 --group=0 --numeric-owner --mode=go-w --mtime="$mtime" \ + -C "$stage" -cf - pullgate LICENSE README.md | gzip -9n > "dist/${name}.tar.gz" + done + cd dist + sha256sum -- *.tar.gz > SHA256SUMS + cat SHA256SUMS + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-files + path: dist/ + if-no-files-found: error + + # Signs and publishes the files from the build job. It runs no code from + # the repository. + publish: + needs: [release-please, build] + if: needs.release-please.outputs.release_created == 'true' + runs-on: ubuntu-latest + permissions: + contents: write # upload the files and publish the draft release + id-token: write # sign the attestation (Sigstore) + attestations: write # store the attestation + env: + TAG: ${{ needs.release-please.outputs.tag_name }} + VERSION: ${{ needs.release-please.outputs.version }} + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: release-files + path: dist + - name: Check the files and the tag + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + (cd dist && sha256sum --strict -c SHA256SUMS) + # The version names the bundle file. An empty or wrong version + # would go out in a release that nobody can change. + if [ "$TAG" != "v${VERSION}" ]; then + echo "::error::The tag ${TAG} does not match the version ${VERSION}. See docs/releasing.md." + exit 1 + fi + tagged="$(gh api "repos/${GITHUB_REPOSITORY}/commits/refs/tags/${TAG}" --jq .sha)" + if [ "$tagged" != "$GITHUB_SHA" ]; then + echo "::error::The tag ${TAG} points to ${tagged}, not to ${GITHUB_SHA}. See docs/releasing.md." + exit 1 + fi + - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + id: attest + with: + subject-path: dist/*.tar.gz + # The attestation is also a file of the release. With this file, + # gh attestation verify --bundle needs no GitHub login. One bundle + # covers both tarballs. + - name: Save the attestation bundle + env: + BUNDLE: ${{ steps.attest.outputs.bundle-path }} + run: cp -- "$BUNDLE" "dist/pullgate_${VERSION}.sigstore.json" + # The same check as in docs/releasing.md, with no login. A bundle that + # fails it never goes out: the release stays a draft. + - name: Check the tarballs with the bundle + run: | + set -euo pipefail + gh --version + for f in dist/*.tar.gz; do + gh attestation verify "$f" \ + --bundle "dist/pullgate_${VERSION}.sigstore.json" \ + --repo "$GITHUB_REPOSITORY" \ + --cert-identity "https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release.yml@refs/heads/main" \ + --deny-self-hosted-runners + echo "$f: OK" + done + # release-please made a draft release (see release-please-config.json). + # Add the files first and publish after that, so the release is never + # public without its files. Immutable releases need this order. + - name: Upload the files to the draft release + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "$TAG" dist/*.tar.gz dist/SHA256SUMS "dist/pullgate_${VERSION}.sigstore.json" --clobber --repo "$GITHUB_REPOSITORY" + - name: Publish the release + env: + GH_TOKEN: ${{ github.token }} + run: gh release edit "$TAG" --draft=false --repo "$GITHUB_REPOSITORY" diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..e18ee07 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/AGENTS.md b/AGENTS.md index 456f327..3171651 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,8 @@ change code. They are the rules the code must follow. 9, 10, 13). - `contrib/` - example systemd units and example configs. - `test/e2e/` - the end-to-end test. Build tag `e2e`. +- `docs/releasing.md` - how releases are made and checked. Only the owner + merges a release pull request (`chore(main): release …`). - `.claude/agents/security-reviewer.md` - the security review every change gets. diff --git a/README.md b/README.md index 9134e34..e551696 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,8 @@ path unit ─> pullgate apply NAME (root) ## Install -pullgate has no release yet. Build it with Go 1.26 or newer: +Download a release and check it as [docs/releasing.md](docs/releasing.md) +says (section "Verify a download"), or build it with Go 1.26 or newer: ```sh CGO_ENABLED=0 go build -trimpath -o pullgate ./cmd/pullgate diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md index 21a6f84..c8fbfe5 100644 --- a/THREAT-MODEL.md +++ b/THREAT-MODEL.md @@ -288,6 +288,19 @@ repo URL must pass the same check as the clone, because root runs *Defense.* At most one open question per instance; a newer tip replaces it. `questions_per_day` caps the questions. A SHA is asked once. +### T16. A bad release binary + +*Attack.* The `pullgate` binary runs as root. Whoever can publish a release +ships code to root on every host that installs it. + +*Defense.* A release is the merge of the release pull request on `main` +(docs/releasing.md). Only the release App can create a `v*` tag, nobody can +move one, releases are immutable, and the `release` environment needs the +owner's approval. The build job is read-only and checks that the tag is the +commit of the run. Each tarball has a SHA-256 in `SHA256SUMS` and a build +provenance attestation tied to `release.yml` on `main`. Check both before +you install (docs/releasing.md, "Verify a download"). + ## 6. Out of scope - An attacker who is root on the host. diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..a01e1c2 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,90 @@ +# Releasing pullgate + +pullgate uses [release-please](https://github.com/googleapis/release-please) +and the same release setup as nodkey. A release is the merge of the release +pull request. **Only the owner merges it.** + +## Pull request titles + +Every pull request is squash merged, and its title becomes the commit +message on `main`. So the title must follow +[Conventional Commits](https://www.conventionalcommits.org/). The PR title +workflow checks it. + +| type | release | changelog section | +|---|---|---| +| `security` | patch | Security | +| `feat` | minor (patch before 1.0) | Features | +| `fix` | patch | Bug Fixes | +| `perf` | patch | Performance Improvements | +| `revert` | patch | Reverts | +| `docs` | patch | Documentation | +| `chore`, `ci`, `test`, `refactor`, `build`, `style` | none | hidden | + +## What happens + +1. After every push to `main`, the **Release** workflow runs release-please. + When a commit makes a release, it opens a release pull request, titled + like `chore(main): release 0.1.0`. It changes `CHANGELOG.md` and + `.release-please-manifest.json`. +2. **Before you merge it, read the version and every line of the + changelog.** This text becomes the release notes. +3. When you merge it, release-please makes the tag (`v0.1.0`) and a + **draft** release. +4. The `build` job (read-only) checks that the tag is the commit of the run, + builds static binaries for Linux amd64 and arm64 (`CGO_ENABLED=0`, + `-trimpath`), packs each with `LICENSE` and `README.md` into + `pullgate__linux_.tar.gz`, and writes `SHA256SUMS`. +5. The `publish` job checks the files and the tag again, makes a signed + build provenance attestation (`pullgate_.sigstore.json`), + checks the tarballs with it, uploads everything to the draft release and + publishes it. The release is never public without its files. + +If `build` or `publish` fails, the release stays a draft. Fix the problem +in a new `fix:` pull request and merge the next release pull request. + +## One-time setup + +Until this is done, the `release-please` job skips itself. + +1. Install the organization's release GitHub App (the one nodkey uses) on + `code6x/pullgate`, and make a new private key for this repository. The + steps are in [nodkey's `docs/releasing.md`](https://github.com/code6x/nodkey/blob/main/docs/releasing.md), section "One-time setup: a + GitHub App". +2. Save the App's Client ID and the key's fingerprint as repository + variables, and the key as a secret of the `release` environment: + + ```sh + gh variable set RELEASE_APP_CLIENT_ID --repo code6x/pullgate --body '' + gh variable set RELEASE_APP_KEY_FINGERPRINT --repo code6x/pullgate --body '' + gh secret set RELEASE_APP_PRIVATE_KEY --repo code6x/pullgate --env release < key.pem + ``` + + The workflow does not use the fingerprint. It is only a record, so you + can check later which key belongs to this repository. + +3. Settings: a ruleset for `main` (pull requests only, required checks, no + force push), two rulesets for `v*` tags (nobody moves or deletes a tag; + only the App creates one), immutable releases on, and the `release` + environment limited to `main`. Add the owner as a required reviewer of the `release` + environment, so every release waits for the owner's approval in the + Actions page, whoever merged the release pull request. + +## Verify a download + +```sh +VERSION=0.1.0 +ARCH=amd64 +U="https://github.com/code6x/pullgate/releases/download/v$VERSION" +curl -fLO "$U/pullgate_${VERSION}_linux_${ARCH}.tar.gz" +curl -fLO "$U/SHA256SUMS" +curl -fLO "$U/pullgate_${VERSION}.sigstore.json" +sha256sum --ignore-missing -c SHA256SUMS +gh attestation verify "pullgate_${VERSION}_linux_${ARCH}.tar.gz" \ + --bundle "pullgate_${VERSION}.sigstore.json" \ + --repo code6x/pullgate \ + --cert-identity "https://github.com/code6x/pullgate/.github/workflows/release.yml@refs/heads/main" \ + --deny-self-hosted-runners +``` + +Both checks must say OK before you install the binary. diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..540880a --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "go", + "include-component-in-tag": false, + "bump-minor-pre-major": true, + "initial-version": "0.1.0", + "draft": true, + "force-tag-creation": true, + "changelog-sections": [ + { "type": "security", "section": "Security" }, + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance Improvements" }, + { "type": "revert", "section": "Reverts" }, + { "type": "docs", "section": "Documentation" }, + { "type": "chore", "section": "Miscellaneous Chores", "hidden": true }, + { "type": "ci", "section": "Continuous Integration", "hidden": true }, + { "type": "test", "section": "Tests", "hidden": true }, + { "type": "refactor", "section": "Code Refactoring", "hidden": true }, + { "type": "build", "section": "Build System", "hidden": true }, + { "type": "style", "section": "Styles", "hidden": true } + ], + "packages": { + ".": {} + } +}