Skip to content
Merged
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
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
179 changes: 179 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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"
3 changes: 3 additions & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
".": "0.0.0"
}
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions THREAT-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
90 changes: 90 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
@@ -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_<version>_linux_<arch>.tar.gz`, and writes `SHA256SUMS`.
5. The `publish` job checks the files and the tag again, makes a signed
build provenance attestation (`pullgate_<version>.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 '<Client ID>'
gh variable set RELEASE_APP_KEY_FINGERPRINT --repo code6x/pullgate --body '<fingerprint>'
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.
26 changes: 26 additions & 0 deletions release-please-config.json
Original file line number Diff line number Diff line change
@@ -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": {
".": {}
}
}
Loading