harden(update): verify the self-update commit signature before the root re-exec/build
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=<anchor> 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 <noreply@anthropic.com>
feat/v1-foundation
parent
46924135e6
commit
0943d56201
|
|
@ -111,6 +111,11 @@ SITE_ADDRESS=:80
|
||||||
CLUSEV_IMAGE=clusev-app:prod
|
CLUSEV_IMAGE=clusev-app:prod
|
||||||
# HMAC key for the (v1.x) in-dashboard self-updater intent file — separate from APP_KEY.
|
# HMAC key for the (v1.x) in-dashboard self-updater intent file — separate from APP_KEY.
|
||||||
UPDATE_HMAC_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
|
# 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 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).
|
# it; leave empty to DISABLE the terminal (the resolve endpoint then 403s).
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@
|
||||||
!.env.example
|
!.env.example
|
||||||
*.pem
|
*.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
|
.phpunit.result.cache
|
||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,23 @@ _do_stage() { # target
|
||||||
local ver="${target}-rc$((n + 1))" newtag="v${target}-rc$((n + 1))"
|
local ver="${target}-rc$((n + 1))" newtag="v${target}-rc$((n + 1))"
|
||||||
|
|
||||||
sed -i -E "s/('version' => ')[^']*(')/\\1${ver}\\2/" "${PROJ}/config/clusev.php"
|
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
|
local token origin host url
|
||||||
# shellcheck source=/dev/null
|
# shellcheck source=/dev/null
|
||||||
|
|
|
||||||
58
update.sh
58
update.sh
|
|
@ -7,11 +7,13 @@
|
||||||
# In order:
|
# In order:
|
||||||
# 1. Mark the repo a safe git dir for root. install.sh chowns the tree to the `clusev` user,
|
# 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".
|
# 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 —
|
# 2. git pull --ff-only, PINNED to the recorded release branch. Aborts cleanly on any conflict /
|
||||||
# it NEVER discards local changes.
|
# divergence — it NEVER discards local changes.
|
||||||
# 3. Self-update: if update.sh itself changed in the pull, re-exec the NEW copy once (guarded
|
# 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.
|
# 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
|
# 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.
|
# it never re-prompts; secrets and the configured domain / ACME e-mail are preserved.
|
||||||
set -euo pipefail
|
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."
|
|| 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)"
|
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
|
if [ "$before_hash" != "$after_hash" ]; then
|
||||||
info "update.sh wurde aktualisiert — starte die neue Version ..."
|
info "update.sh wurde aktualisiert — starte die neue Version ..."
|
||||||
exec env CLUSEV_UPDATE_REEXEC=1 "$0" "$@"
|
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.
|
# 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
|
[ -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) ..."
|
info "Wende Update an (install.sh) ..."
|
||||||
exec ./install.sh </dev/null
|
exec ./install.sh </dev/null
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue