From 0943d562010b44ab196e5eccb95b15afa127e9dc Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 5 Jul 2026 17:10:19 +0200 Subject: [PATCH] harden(update): verify the self-update commit signature before the root re-exec/build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-audit's top finding (HIGH): update.sh runs as root, pulled + re-exec'd its own new copy + ran install.sh's `docker compose build` on the pulled tree with NO signature check, so a compromised/redirected origin or a transport MITM could feed attacker commits into a root RCE. The branch-pin (bc2f2b5) stops a local `git checkout` redirect but not a remote origin compromise — only a signature does. - update.sh: after the pull and BEFORE the self-update re-exec / install.sh build, verify the pulled HEAD with `git -c gpg.ssh.allowedSignersFile= verify-commit HEAD`. Opt-in and safe by default — skipped with a warning when no anchor is configured (existing installs keep updating), but FAIL CLOSED once configured. The anchor is resolved from .env CLUSEV_ALLOWED_SIGNERS or the gitignored .clusev-allowed-signers; a SET-but-missing anchor dies (no silent downgrade), and a git-TRACKED anchor is refused (a pulled tree could otherwise swap in the attacker's own key). - clusev-release.sh: opt-in SSH signing of the release commit + tag when CLUSEV_SIGNING_KEY is set, so the verify side has provenance to check. Unsigned (identical behaviour) when unset. - .gitignore /.clusev-allowed-signers (the trust anchor must never be pull-overwritable); .env.example documents CLUSEV_ALLOWED_SIGNERS. verify-commit + allowed-signers proven end-to-end in a scratch repo (signed→pass, unsigned→fail, wrong-key→fail, tracked-anchor→refuse, set-but-missing→fail-closed). shellcheck clean. Activation (provision the maintainer keypair + sign releases) is a documented maintainer step. Co-Authored-By: Claude Fable 5 --- .env.example | 5 +++ .gitignore | 5 +++ docker/release/clusev-release.sh | 19 +++++++++-- update.sh | 58 ++++++++++++++++++++++++++++---- 4 files changed, 79 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 33beffb..3bfb5f3 100644 --- a/.env.example +++ b/.env.example @@ -111,6 +111,11 @@ SITE_ADDRESS=:80 CLUSEV_IMAGE=clusev-app:prod # HMAC key for the (v1.x) in-dashboard self-updater intent file — separate from APP_KEY. UPDATE_HMAC_KEY= +# OPTIONAL supply-chain hardening: path to an SSH allowed-signers file. When set, update.sh verifies +# the pulled HEAD is signed by a trusted maintainer key before re-exec/build and REFUSES an unsigned +# or untrusted commit (fail-closed). Unset = verification skipped (a warning is logged). The file must +# stay outside the tracked tree (it is gitignored) so a pulled/MITM'd repo cannot swap the key. +# CLUSEV_ALLOWED_SIGNERS=/home/nexxo/clusev/.clusev-allowed-signers # Shared secret between the app and the terminal sidecar (the /terminal page). The sidecar presents # it to the internal resolve endpoint that hands out decrypted SSH credentials. install.sh generates # it; leave empty to DISABLE the terminal (the resolve endpoint then 403s). diff --git a/.gitignore b/.gitignore index 12ef133..00df2c6 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,11 @@ !.env.example *.pem +# Self-update trust anchor: the SSH allowed-signers list update.sh checks the pulled HEAD against. +# MUST stay untracked so a pulled/MITM'd tree can never swap the trusted maintainer key (git pull +# leaves ignored files alone). Provisioned out-of-band; see the signature-verification spec. +/.clusev-allowed-signers + .phpunit.result.cache Homestead.json Homestead.yaml diff --git a/docker/release/clusev-release.sh b/docker/release/clusev-release.sh index b0d5553..6c661f1 100755 --- a/docker/release/clusev-release.sh +++ b/docker/release/clusev-release.sh @@ -58,8 +58,23 @@ _do_stage() { # target local ver="${target}-rc$((n + 1))" newtag="v${target}-rc$((n + 1))" sed -i -E "s/('version' => ')[^']*(')/\\1${ver}\\2/" "${PROJ}/config/clusev.php" - git -C "$PROJ" commit -q -am "chore(release): ${ver}" || { echo "commit-failed" >&2; git -C "$PROJ" checkout -- . 2>/dev/null || true; return 1; } - git -C "$PROJ" tag "$newtag" || { echo "tag-failed" >&2; _rollback "$newtag"; return 1; } + + # Opt-in SSH signing: when CLUSEV_SIGNING_KEY (path to the maintainer's SSH private key) is set in + # the release env, sign the release COMMIT (and tag) so update.sh's `verify-commit HEAD` can prove + # provenance and refuse an unsigned / attacker-substituted HEAD. Unsigned when unset — matching + # update.sh's opt-in verification (dormant until the operator provisions the keypair). The `-c` + # git-level flags go before the subcommand; the sign flags after. See the signature-verification spec. + local signkey gitc=() csign=() tsign=() + # shellcheck source=/dev/null + signkey="$( . "$GITEA_ENV" 2>/dev/null; printf '%s' "${CLUSEV_SIGNING_KEY:-}" )" + if [ -n "$signkey" ] && [ -f "$signkey" ]; then + gitc=(-c gpg.format=ssh -c "user.signingkey=${signkey}") + csign=(-S) + tsign=(-s -m "clusev ${newtag}") + fi + + git -C "$PROJ" "${gitc[@]}" commit -q "${csign[@]}" -am "chore(release): ${ver}" || { echo "commit-failed" >&2; git -C "$PROJ" checkout -- . 2>/dev/null || true; return 1; } + git -C "$PROJ" "${gitc[@]}" tag "${tsign[@]}" "$newtag" || { echo "tag-failed" >&2; _rollback "$newtag"; return 1; } local token origin host url # shellcheck source=/dev/null diff --git a/update.sh b/update.sh index 92f2e70..efd6d5c 100755 --- a/update.sh +++ b/update.sh @@ -7,11 +7,13 @@ # In order: # 1. Mark the repo a safe git dir for root. install.sh chowns the tree to the `clusev` user, # so a root-run `git pull` would otherwise refuse with "dubious ownership". -# 2. git pull --ff-only on the tracked branch. Aborts cleanly on any conflict / divergence — -# it NEVER discards local changes. -# 3. Self-update: if update.sh itself changed in the pull, re-exec the NEW copy once (guarded +# 2. git pull --ff-only, PINNED to the recorded release branch. Aborts cleanly on any conflict / +# divergence — it NEVER discards local changes. +# 3. Verify the pulled HEAD's commit signature against a trusted maintainer key (opt-in via a +# gitignored allowed-signers file) BEFORE re-exec/build — fail-closed when configured. +# 4. Self-update: if update.sh itself changed in the pull, re-exec the NEW copy once (guarded # against loops) so improvements to this very script take effect on the same run. -# 4. Hand off to ./install.sh — idempotent: rebuilds the image, restarts the stack, migrates, +# 5. Hand off to ./install.sh — idempotent: rebuilds the image, restarts the stack, migrates, # refreshes the MOTD and fixes ownership. Run NON-interactively (stdin from /dev/null) so # it never re-prompts; secrets and the configured domain / ACME e-mail are preserved. set -euo pipefail @@ -85,7 +87,51 @@ if [ "${CLUSEV_UPDATE_REEXEC:-0}" != 1 ]; then || die "git pull fehlgeschlagen — Timeout, fehlende Zugangsdaten zum privaten Repo, lokale Aenderungen oder divergierter Branch. Mit 'git status' und dem Repo-Zugriff (Token im origin-Remote) pruefen und erneut versuchen." after_hash="$(sha256sum -- "$0" | cut -d' ' -f1)" - # 3. self-update: relaunch the new update.sh if it changed + # 3. Verify the pulled HEAD is signed by a TRUSTED maintainer key BEFORE anything acts on it + # (the re-exec of this very script below, then the ROOT `docker compose build` in install.sh). + # The allowed-signers list lives in a GITIGNORED file — `.env` (CLUSEV_ALLOWED_SIGNERS, absolute + # path preferred) or the repo-root `.clusev-allowed-signers` — so a pulled or MITM'd tree can + # never swap the trusted key (git pull does not touch untracked/ignored files). This is the + # missing supply-chain control: the branch-pin above stops a local `git checkout` redirect, but + # only a signature stops a compromised ORIGIN / transport from feeding attacker commits to root. + # OPT-IN: with no signer list configured this is a loud no-op so existing installs keep updating; + # once configured, a bad OR absent signature FAILS CLOSED. Activate it by provisioning the + # maintainer public key + signing releases — see docs/…/self-update-signature-verification.md. + # Read the .env value BARE (no quote-stripping) — force_kv/set_kv write unquoted values, same as + # every other .env read here (CLUSEV_BUILD_BRANCH above, watch.sh); a quoted value simply won't + # resolve to a file and so fails closed below. + signers="$(grep -m1 '^CLUSEV_ALLOWED_SIGNERS=' .env 2>/dev/null | cut -d= -f2-)" + if [ -n "$signers" ]; then + # CLUSEV_ALLOWED_SIGNERS explicitly set => verification is REQUIRED. A missing/unreadable file + # must FAIL CLOSED, never silently downgrade to "not configured" (that fail-open would let an + # attacker who can remove the anchor skip verification entirely). + [ -f "$signers" ] && [ -r "$signers" ] \ + || die "CLUSEV_ALLOWED_SIGNERS ist gesetzt (${signers}), aber die Datei fehlt oder ist unlesbar — Update abgebrochen. Signaturpruefung darf nicht stillschweigend ausfallen." + elif [ -f "$REPO_DIR/.clusev-allowed-signers" ]; then + signers="$REPO_DIR/.clusev-allowed-signers" + fi + if [ -n "$signers" ]; then + # The trust anchor must NOT be repointable or swappable via a pull. Reject a SYMLINK first: `-f` + # follows the link and `git ls-files` checks the LINK path (not its target), so an untracked + # symlink to a tracked, pull-overwritable file would otherwise slip past the tracked-check below. + if [ -L "$signers" ]; then + die "Der allowed-signers Trust-Anchor (${signers}) ist ein Symlink — nicht erlaubt (koennte auf eine versionierte, per Pull austauschbare Datei zeigen). Bitte eine echte Datei verwenden." + fi + # And it must NOT be git-tracked (pull-overwritable): otherwise a malicious origin could ship its + # OWN allowed-signers file in the very commit being verified and pass the check against the + # attacker's key. Only untracked/gitignored or out-of-tree paths are trusted. + if git ls-files --error-unmatch -- "$signers" >/dev/null 2>&1; then + die "Der allowed-signers Trust-Anchor (${signers}) ist git-versioniert und damit ueber einen Pull austauschbar — Update abgebrochen. Die Datei muss untracked/gitignored sein (siehe .clusev-allowed-signers) oder ausserhalb des Arbeitsbaums liegen." + fi + info "Verifiziere Signatur des gepullten Commits (${signers}) ..." + git -c gpg.ssh.allowedSignersFile="$signers" verify-commit HEAD \ + || die "Signaturpruefung fehlgeschlagen — HEAD ist NICHT von einem vertrauten Maintainer-Schluessel signiert. Update abgebrochen (moegliche Kompromittierung von Origin oder Transport). Der Stack laeuft unveraendert weiter." + info "Signatur ok." + else + info "Hinweis: Commit-Signaturpruefung nicht konfiguriert (CLUSEV_ALLOWED_SIGNERS bzw. .clusev-allowed-signers) — uebersprungen." + fi + + # 4. self-update: relaunch the new (now signature-verified) update.sh if it changed if [ "$before_hash" != "$after_hash" ]; then info "update.sh wurde aktualisiert — starte die neue Version ..." exec env CLUSEV_UPDATE_REEXEC=1 "$0" "$@" @@ -95,6 +141,6 @@ fi # Refresh the update source from the (possibly changed) clone origin before handing off. [ -x ./scripts/set-repository-url.sh ] && ./scripts/set-repository-url.sh "$(pwd)" ./.env || true -# 4. apply via the idempotent installer, non-interactively (no prompts; config preserved) +# 5. apply via the idempotent installer, non-interactively (no prompts; config preserved) info "Wende Update an (install.sh) ..." exec ./install.sh