diff --git a/.github/scripts/base-packages.sh b/.github/scripts/base-packages.sh new file mode 100755 index 0000000..f2afffd --- /dev/null +++ b/.github/scripts/base-packages.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# base-packages.sh: compare the Debian packages the mail path runs on with +# what debian:trixie-slim would install today (security audit 2026-10-17, +# I5). The image installs without pinning, and published tags are never +# rebuilt, so a security update of postfix, Cyrus SASL, OpenSSL or Berkeley +# DB reaches operators only with the next release. This fails when one of +# them has moved past the version the last release was built with - the +# table between the base-package-versions markers in docs/development.md - +# as the nudge to cut a patch release (and then to update the table). +# +# Run by .github/workflows/base-packages.yml every week; needs docker. +set -eu +doc=docs/development.md +table=$(sed -n '//,//p' "$doc" | + sed -n 's/^| `\([^`]*\)` | `\([^`]*\)` |$/\1 \2/p') +[ -n "$table" ] || { echo "no base-package-versions table in $doc" >&2; exit 2; } +names=$(echo "$table" | cut -d' ' -f1 | tr '\n' ' ') +# apt-cache policy's "Candidate:" is what an install would take now. +now=$(docker run --rm debian:trixie-slim sh -c "apt-get update -qq >/dev/null && for p in $names; do printf '%s %s\n' \"\$p\" \"\$(apt-cache policy \"\$p\" | sed -n 's/^ *Candidate: //p')\"; done") +moved=0 +echo "$table" | while read -r name built; do + cand=$(echo "$now" | awk -v n="$name" '$1 == n { print $2 }') + if [ "$cand" != "$built" ]; then + echo "::warning::$name: the last release was built with $built, trixie now installs ${cand:-nothing}" >&2 + echo moved + fi +done | grep -q moved && moved=1 +if [ "$moved" = 1 ]; then + echo "A package the mail path runs on has moved: cut a patch release, then update the table in $doc." >&2 + exit 1 +fi +echo "Every watched package is as the last release was built." diff --git a/.github/workflows/base-packages.yml b/.github/workflows/base-packages.yml new file mode 100644 index 0000000..697d673 --- /dev/null +++ b/.github/workflows/base-packages.yml @@ -0,0 +1,20 @@ +name: base packages + +# Every Monday: has a Debian package the mail path runs on moved since the +# last release was built? A red run is the nudge to cut a patch release +# (security audit 2026-10-17, I5; .github/scripts/base-packages.sh). +on: + schedule: + - cron: "17 5 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + watch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - name: Compare with trixie + run: sh .github/scripts/base-packages.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c33f4bd..f7da86a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,14 +66,14 @@ jobs: arch: arm64 runs-on: ${{ matrix.runner }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: ref: v${{ needs.prepare.outputs.version }} - - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Build image (native, loaded locally for the e2e gate) - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . file: build/Dockerfile @@ -83,7 +83,7 @@ jobs: VERSION=${{ needs.prepare.outputs.version }} tags: selfpost:e2e - - uses: actions/setup-go@v5 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version: "1.26" cache-dependency-path: test/e2e/go.sum @@ -110,8 +110,22 @@ jobs: fi done + # The binaries about to be pushed, as built in the image - with the Go + # of golang:1.26-trixie, which may lag the runner's: a standard-library + # advisory fixed in a later patch fails the release until the base tag + # moves, which is the point (security audit 2026-10-17, I4). + - name: Vulnerabilities in the image's binaries + run: | + set -euo pipefail + id=$(docker create selfpost:e2e) + trap 'docker rm -f "$id" >/dev/null' EXIT + for bin in panel selfpost-backup; do + docker cp "$id:/usr/local/bin/$bin" "/tmp/$bin" + go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 -mode=binary "/tmp/$bin" + done + - name: Log in to ghcr.io - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -152,14 +166,14 @@ jobs: needs: [prepare, build] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: ref: v${{ needs.prepare.outputs.version }} - - uses: docker/setup-buildx-action@v3 + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Log in to ghcr.io - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 804214a..312d15f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,7 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: # design-first.sh reads each commit's parent and the template history. fetch-depth: 0 @@ -20,7 +20,7 @@ jobs: HEAD: ${{ github.event.pull_request.head.sha || github.sha }} run: bash .github/scripts/design-first.sh "$BASE" "$HEAD" - - uses: actions/setup-go@v5 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version: "1.26" cache: true @@ -51,6 +51,13 @@ jobs: - name: Test run: go test ./... + # Known vulnerabilities in the module's dependencies and in the + # standard library of this Go, reached from SelfPost's code (security + # audit 2026-10-17, I4: twelve stdlib advisories went unnoticed until + # the base image's tag happened to move). Pinned like staticcheck. + - name: Vulnerabilities + run: go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./... + # The e2e module runs only in release.yml, after a release is published; # a type error there would first show up then. Compile it here without # running it: `go test -run` would still run TestMain, which brings up diff --git a/CHANGELOG.md b/CHANGELOG.md index ae733fd..8aeff37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version - **A domain import is bounded** (audit W4, W5, W8): a DKIM key over 4096 bits, more than 500 applications, and a domain named as the server itself - whose key signs the panel's own mail - are refused. +- **The build is pinned and checked** (audit I4, I5): GitHub Actions by + commit, certbot and Caddy by release; govulncheck on the source in CI and + on the image's binaries before a release is pushed; a weekly check says + when a Debian package the mail path runs on has moved, so that a patch + release follows. - **The TLS private key no longer has to be readable by Postfix** (audit I1): the guide gave it to group 10998 for the panel's backup, and `postfix` is in that group, so every Postfix daemon - an smtpd talking to diff --git a/SECURITY.md b/SECURITY.md index 42b7582..62f651a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,6 +13,12 @@ reporting if you are behind — the image tag is `ghcr.io/mixeme/selfpost:X.Y.Z` | 1.x | no — upgrade to 2.x (guide § Upgrading) | | 0.x | no (pre-release) | +A security update of a Debian package the image runs - Postfix, Cyrus SASL, +OpenSSL, Berkeley DB - arrives with the next patch release built after it: +published image tags are never rebuilt. A weekly check +(`.github/workflows/base-packages.yml`) says when one has moved, and a patch +release follows. + ## Reporting a vulnerability **Do not open a public issue.** Use GitHub's private vulnerability reporting: diff --git a/deploy/caddy/docker-compose.caddy.yml b/deploy/caddy/docker-compose.caddy.yml index 0984ee0..8fc89d0 100644 --- a/deploy/caddy/docker-compose.caddy.yml +++ b/deploy/caddy/docker-compose.caddy.yml @@ -16,7 +16,8 @@ # the CA URL's host and path, "acme-v02.api.letsencrypt.org-directory" for Let's # Encrypt). caddy/modules/caddytls/automation.go, DefaultIssuers(): ZeroSSL is a # second issuer only when an email is configured, and the Caddyfile below sets -# none. Read at the projects' master of 2026-10-14: `caddy:2` is a moving tag. +# none. Read at the projects' master of 2026-10-14, when the image was the +# moving `caddy:2`; it is pinned below now, and a bump re-reads these. # # Merge with the base file — run this from the deploy/ directory (Compose # resolves every relative path in both files against the directory of the @@ -46,7 +47,9 @@ services: - ./caddy/caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/:/etc/postfix/tls:ro caddy: - image: caddy:2 + # Pinned to a release: "2" moves under a running server (security audit + # 2026-10-17, I4). Bump it deliberately. + image: caddy:2.11.7 restart: unless-stopped # A ceiling on processes and threads (security audit 2026-10-17, I3). pids_limit: 256 diff --git a/deploy/nginx/docker-compose.nginx.yml b/deploy/nginx/docker-compose.nginx.yml index 297b11d..ea8803e 100644 --- a/deploy/nginx/docker-compose.nginx.yml +++ b/deploy/nginx/docker-compose.nginx.yml @@ -60,7 +60,9 @@ services: - ./nginx/certbot-www:/var/www/certbot:ro certbot: - image: certbot/certbot:latest + # Pinned like every image here: "latest" moves under a running server + # (security audit 2026-10-17, I4). Bump it deliberately. + image: certbot/certbot:v5.8.0 pids_limit: 64 volumes: - ./nginx/certbot-etc:/etc/letsencrypt diff --git a/docs/development.md b/docs/development.md index 53e1319..764a65c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -280,6 +280,26 @@ re-read what changed, update the citations, then this table: | `logrotate` | `3.22.0-1` | +A security update of one of these, or of OpenSSL or Berkeley DB, reaches +operators only with the next release: published tags are never rebuilt. +`.github/workflows/base-packages.yml` compares every Monday what +`debian:trixie-slim` would install with the versions the last release was +built with, below, and fails when one has moved - the sign to cut a patch +release. Cutting a release updates this table from the image +(`dpkg-query -W` of these names; security audit 2026-10-17, I5): + + +| Package | Built with | +|---|---| +| `postfix` | `3.10.13-0+deb13u1` | +| `libsasl2-2` | `2.1.28+dfsg1-9` | +| `libsasl2-modules-db` | `2.1.28+dfsg1-9` | +| `sasl2-bin` | `2.1.28+dfsg1-9` | +| `openssl` | `3.5.7-1~deb13u3` | +| `libssl3t64` | `3.5.7-1~deb13u3` | +| `libdb5.3t64` | `5.3.28+dfsg2-9` | + + A document instead of source: its URL and section. Where neither answers the question, the comment says so and what was measured instead - "measured on the e2e stand, 2026-10-14: ..." - and a measurement is never written as if diff --git a/docs/guide.md b/docs/guide.md index 070b172..23694c7 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -768,6 +768,13 @@ published, then `docker compose up -d`. The backup version check requires the ru to match the version that created a full backup — see [Fixed image tag](#fixed-image-tag). +**Base-package fixes come with releases.** The image is Debian trixie, and a +security update of Postfix, Cyrus SASL, OpenSSL or Berkeley DB reaches you with +the next SelfPost release built after it: a published image tag is never +rebuilt. The project checks every week whether one of those has moved and cuts +a patch release when it has; a release whose notes say "base packages" is one +of those. + **To 2.8.0** there is nothing to do. From 2.8.0 the panel signs outgoing mail itself, in a milter of its own, and OpenDKIM is no longer in the image; the keys, the selectors and the DNS records stay as they are. The keys move diff --git a/docs/security-audit-2026-10-17.md b/docs/security-audit-2026-10-17.md index 25dbeef..84a6a2d 100644 --- a/docs/security-audit-2026-10-17.md +++ b/docs/security-audit-2026-10-17.md @@ -824,17 +824,25 @@ the argument. ### Stage 5 — supply chain and currency (~2.5 h) -- [ ] **I4.** Actions pinned by SHA with the version in a comment; +- [x] **I4.** Actions pinned by SHA with the version in a comment; `govulncheck ./...` in `test.yml` and in `release.yml` before the push (a stdlib advisory fixed in the current patch of the pinned minor fails the build until the base tag moves — that is the point); `certbot` and `caddy` pinned in the fragments. Leave `provenance`/`sbom` off until the GHCR cleanup loop is taught to keep attestation manifests (a follow-up, - 1 h). — **Sonnet**, 1 h. -- [ ] **I5.** guide § Upgrading and SECURITY.md: base-package fixes arrive + 1 h). — **Sonnet**, 1 h. *Done 2026-10-17:* actions pinned to the + latest release of the major each used (checkout v4.4.0, setup-go v5.6.0, + setup-buildx v3.12.0, build-push v6.19.2, login v3.7.0); govulncheck + v1.9.0 on the source in test.yml and on the image's two binaries + (`-mode=binary`, the base image's Go) in release.yml before the push; + certbot v5.8.0, caddy 2.11.7. provenance/SBOM stay off, as planned. +- [x] **I5.** guide § Upgrading and SECURITY.md: base-package fixes arrive with the next patch release; the owner's rule on published tags. A weekly workflow that rebuilds the package list in `debian:trixie-slim` and fails - when postfix/libsasl2/openssl/libdb moved, as the nudge. — **Sonnet**, + when postfix/libsasl2/openssl/libdb moved, as the nudge. — **Sonnet**, *Done 2026-10-17:* the guide, SECURITY.md; + `base-packages.yml` + `.github/scripts/base-packages.sh` against a table + in development.md (seven packages, from the 2.14.3 image), tried on the + test server both ways. 1.5 h. ### Stage 6 — the rest, and what to accept (~3 h)