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
42 changes: 38 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -98,17 +98,51 @@
SHELL ["/bin/bash", "-c"]

# 2a. External APT repos (GitHub CLI, Eza) — needs gnupg, stays combined
RUN mkdir -p -m 755 /etc/apt/keyrings \
#
# Signing keys are pinned by full primary-key fingerprint. Each downloaded
# keyring must contain exactly the expected set of primary keys (subkeys are
# ignored) before it is trusted as an APT signer; any difference fails the
# build. To rotate, verify the new fingerprint out-of-band (see SECURITY.md)
# and update the ARG. Values are space-separated, upper-case, 40-hex.
# GitHub CLI: https://github.com/cli/cli/blob/trunk/docs/install_linux.md
# Eza (deb.gierens.de): key file pinned to an immutable eza commit
ARG GH_CLI_KEY_FINGERPRINTS="2C6106201985B60E6C7AC87323F3D4EA75716059 7F38BBB59D064DBCB3D84D725612B36462313325"

Check warning on line 109 in Dockerfile

View workflow job for this annotation

GitHub Actions / build

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ARG "GH_CLI_KEY_FINGERPRINTS") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 109 in Dockerfile

View workflow job for this annotation

GitHub Actions / build-arm64

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ARG "GH_CLI_KEY_FINGERPRINTS") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
ARG EZA_KEY_FINGERPRINTS="1548BC8A4B4D2688F9B0DAF7EC29E2090CE3FD43"

Check warning on line 110 in Dockerfile

View workflow job for this annotation

GitHub Actions / build

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ARG "EZA_KEY_FINGERPRINTS") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 110 in Dockerfile

View workflow job for this annotation

GitHub Actions / build-arm64

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ARG "EZA_KEY_FINGERPRINTS") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
ARG EZA_KEY_URL=https://raw.githubusercontent.com/eza-community/eza/1cff499fb218f2a133aafa01824ddab090f4389e/deb.asc

Check warning on line 111 in Dockerfile

View workflow job for this annotation

GitHub Actions / build

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ARG "EZA_KEY_URL") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 111 in Dockerfile

View workflow job for this annotation

GitHub Actions / build-arm64

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ARG "EZA_KEY_URL") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
RUN set -euo pipefail \
&& mkdir -p -m 755 /etc/apt/keyrings \
&& ARCH=$(dpkg --print-architecture) \
&& apt-get update \
&& apt-get install -y --no-install-recommends gnupg \
&& KEYDIR=$(mktemp -d) \
&& verify_apt_key() { \
local name=$1 file=$2 expected=$3 actual want; \
want=$(printf '%s\n' $expected | tr '[:lower:]' '[:upper:]' | sort -u | paste -sd' ' -); \
[ -n "$want" ] || { echo "Error: no expected fingerprint configured for ${name} APT key" >&2; return 1; }; \
actual=$(GNUPGHOME="$KEYDIR/gnupg" gpg --batch --quiet --show-keys --with-colons "$file" 2>/dev/null \
| awk -F: '$1 == "pub" { want_fpr = 1; next } want_fpr && $1 == "fpr" { print $10; want_fpr = 0 }' \
| sort -u | paste -sd' ' -); \
if [ "$actual" != "$want" ]; then \
echo "Error: ${name} APT signing key fingerprint mismatch" >&2; \
echo " expected: ${want}" >&2; \
echo " actual: ${actual:-<none>}" >&2; \
return 1; \
fi; \
echo "Verified ${name} APT signing key: ${actual}"; \
} \
&& mkdir -m 700 "$KEYDIR/gnupg" \
# GitHub CLI
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \
&& chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& curl -fsSL -o "$KEYDIR/githubcli.gpg" https://cli.github.com/packages/githubcli-archive-keyring.gpg \
&& verify_apt_key "GitHub CLI" "$KEYDIR/githubcli.gpg" "$GH_CLI_KEY_FINGERPRINTS" \
&& install -m 644 "$KEYDIR/githubcli.gpg" /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=${ARCH} signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
# Eza
&& curl -fsSL https://raw.githubusercontent.com/eza-community/eza/main/deb.asc | gpg --dearmor -o /etc/apt/keyrings/gierens.gpg \
&& curl -fsSL -o "$KEYDIR/eza.asc" "$EZA_KEY_URL" \
&& verify_apt_key "Eza" "$KEYDIR/eza.asc" "$EZA_KEY_FINGERPRINTS" \
&& GNUPGHOME="$KEYDIR/gnupg" gpg --batch --dearmor -o /etc/apt/keyrings/gierens.gpg < "$KEYDIR/eza.asc" \
&& chmod 644 /etc/apt/keyrings/gierens.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/gierens.gpg] https://deb.gierens.de stable main" | tee /etc/apt/sources.list.d/gierens.list \
&& rm -rf "$KEYDIR" \
# Install from repos
&& apt-get update \
&& apt-get install -y --no-install-recommends gh eza \
Expand Down
20 changes: 19 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ aliases.
| Tool tier | Source | Integrity policy | Version policy |
| --- | --- | --- | --- |
| Image tier binary artifacts | Official GitHub Releases | Squarebox-pinned SHA-256, checked against the exact GitHub release-asset digest during pin refresh; installation fails closed if missing/mismatched | Pinned in the Candidate |
| Image tier APT packages | Ubuntu and configured signed repositories | APT repository signatures | Distribution/repository version |
| Image tier APT packages | Ubuntu archive; build-only GitHub CLI and Eza repositories | APT repository signatures; external signing keys pinned by full primary-key fingerprint and verified before they are trusted | Distribution/repository version |
| Box tier packages | APT inside the Box | APT repository signatures | Reconciled when selected |
| Managed-home GitHub tools | Official GitHub Releases | Exact release tag and asset name; GitHub release-asset SHA-256 digest; fail closed if missing, duplicate, malformed, or mismatched | Selected latest or explicit release |
| Managed-home Git sources | Official GitHub repositories (LazyVim, Oh My Zsh, and Zsh plugins) | Default branch resolved through GitHub metadata to a full commit SHA; exact fetch/checkout and HEAD verification before activation | Resolved only during an explicit setup/reconcile action; local changes are preserved by refusal |
Expand All @@ -169,6 +169,24 @@ Unsupported architectures and invalid Tool-tier/destination combinations fail
before network or destination mutation. Extracted archives reject escaping
links, special files, and ambiguous executable matches before promotion.

The GitHub CLI and Eza APT repositories are configured only while the image
builds (then removed). Their signing keys are not trusted on download alone:
the Dockerfile pins each key's expected full 40-hex primary-key fingerprint set
in `GH_CLI_KEY_FINGERPRINTS` and `EZA_KEY_FINGERPRINTS`, and the build fails
unless the downloaded keyring contains exactly that set of primary keys
(subkeys are not compared). The check runs before any keyring is installed
under `/etc/apt/keyrings` or any source list refers to it. The Eza key is
fetched from an immutable eza commit (`EZA_KEY_URL`), not a branch.
`tests/test-apt-key-policy.sh` asserts this policy statically.

To rotate a key, obtain the new key and confirm its fingerprint out-of-band
before editing the ARG: compare `gpg --show-keys --with-colons` output with the
publisher's documentation (GitHub CLI lists its fingerprints in
`docs/install_linux.md`) and with the issuer fingerprint on the live
repository's `Release.gpg`/`InRelease` signature. Never copy the value from a
failed build's "actual" output alone; a mismatch is exactly the signal this
control exists to raise.

Image-tier runtime updates receive one additional gate: the exact current-arch
artifact in the Candidate checksum manifest must equal GitHub's digest for the
resolved upstream release asset. Otherwise `sqrbx-update` reports that a newer
Expand Down
82 changes: 82 additions & 0 deletions tests/test-apt-key-policy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Static policy: every external APT signing key the Dockerfile downloads must be
# verified against a pinned full primary-key fingerprint set before it is
# installed as an APT signer or any source list referencing it is written.
set -euo pipefail

ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
DOCKERFILE="$ROOT/Dockerfile"

fail() {
echo "FAIL: $*" >&2
exit 1
}

line_of() {
# First line number containing the fixed string $1 (0 if absent).
grep -nF -- "$1" "$DOCKERFILE" | head -n1 | cut -d: -f1 || true
}

# Fingerprint ARGs: non-empty, each token a 40-hex upper-case fingerprint, no duplicates.
mapfile -t fpr_args < <(sed -n 's/^ARG \([A-Z0-9_]*_KEY_FINGERPRINTS\)=.*/\1/p' "$DOCKERFILE")
[ "${#fpr_args[@]}" -ge 2 ] || fail "expected GitHub CLI and Eza fingerprint ARGs"
for required in GH_CLI_KEY_FINGERPRINTS EZA_KEY_FINGERPRINTS; do
printf '%s\n' "${fpr_args[@]}" | grep -qx "$required" || fail "missing ARG $required"
done
for arg in "${fpr_args[@]}"; do
[ "$(grep -c "^ARG ${arg}=" "$DOCKERFILE")" -eq 1 ] || fail "$arg must be declared exactly once"
value=$(sed -n "s/^ARG ${arg}=\"\{0,1\}\([^\"]*\)\"\{0,1\}\$/\1/p" "$DOCKERFILE")
[ -n "$value" ] || fail "$arg is empty"
read -r -a tokens <<<"$value"
[ "${#tokens[@]}" -ge 1 ] || fail "$arg has no fingerprints"
for token in "${tokens[@]}"; do
[[ "$token" =~ ^[0-9A-F]{40}$ ]] || fail "$arg value '$token' is not a 40-hex upper-case fingerprint"
done
[ "$(printf '%s\n' "${tokens[@]}" | sort -u | wc -l)" -eq "${#tokens[@]}" ] \
|| fail "$arg contains duplicate fingerprints"
grep -Fq "\"\$${arg}\"" "$DOCKERFILE" || fail "$arg is declared but never used for verification"
done

# The verifier compares primary-key fingerprints (pub -> following fpr) as an exact set.
grep -Fq 'verify_apt_key() {' "$DOCKERFILE" || fail "Dockerfile does not define verify_apt_key"
grep -Fq "\$1 == \"pub\" { want_fpr = 1; next } want_fpr && \$1 == \"fpr\" { print \$10; want_fpr = 0 }" "$DOCKERFILE" \
|| fail "verify_apt_key must extract only primary-key fingerprints"
grep -Fq 'if [ "$actual" != "$want" ]; then' "$DOCKERFILE" \
|| fail "verify_apt_key must require the exact expected fingerprint set"

# No key may be piped straight from the network into a keyring.
if grep -Eq 'curl[^|]*(\.gpg|\.asc|keyring)[^|]*\|' "$DOCKERFILE"; then
fail "an APT key is piped from curl without fingerprint verification"
fi
if grep -Eq 'raw\.githubusercontent\.com/eza-community/eza/(main|master)/' "$DOCKERFILE"; then
fail "Eza key URL must be pinned to an immutable commit, not a branch"
fi
grep -Eq '^ARG EZA_KEY_URL=https://raw\.githubusercontent\.com/eza-community/eza/[0-9a-f]{40}/deb\.asc$' "$DOCKERFILE" \
|| fail "Eza key URL must be pinned to a full commit SHA"

# Every downloaded key file is verified, and verification precedes keyring
# installation and source-list creation for that repository.
mapfile -t key_files < <(grep -oE 'curl -fsSL -o "\$KEYDIR/[^"]+"' "$DOCKERFILE" | sed 's/.*"\$KEYDIR\/\([^"]*\)"/\1/')
[ "${#key_files[@]}" -ge 2 ] || fail "expected at least two downloaded APT keys"
for key in "${key_files[@]}"; do
download=$(line_of "curl -fsSL -o \"\$KEYDIR/$key\"")
verify=$(grep -nE "verify_apt_key \"[^\"]+\" \"\\\$KEYDIR/${key//./\\.}\" \"\\\$[A-Z0-9_]+_KEY_FINGERPRINTS\"" "$DOCKERFILE" | head -n1 | cut -d: -f1 || true)
[ -n "$verify" ] || fail "downloaded key $key is never fingerprint-verified"
[ "$verify" -gt "$download" ] || fail "key $key is verified before it is downloaded"
use=$(grep -nF "\"\$KEYDIR/$key\"" "$DOCKERFILE" | cut -d: -f1 | awk -v v="$verify" '$1 > v' | head -n1)
[ -n "$use" ] || fail "verified key $key is never installed"
done

# Every signed-by keyring is installed only after a verification step, and the
# number of signer keyrings matches the number of verified downloads.
mapfile -t signers < <(grep -oE 'signed-by=/etc/apt/keyrings/[^] ]+' "$DOCKERFILE" | sort -u)
[ "${#signers[@]}" -eq "${#key_files[@]}" ] \
|| fail "signed-by keyrings (${#signers[@]}) do not match verified downloads (${#key_files[@]})"
first_verify=$(grep -nF 'verify_apt_key "' "$DOCKERFILE" | head -n1 | cut -d: -f1)
for signer in "${signers[@]}"; do
path=${signer#signed-by=}
sources_line=$(line_of "$signer")
[ "$sources_line" -gt "$first_verify" ] || fail "$path is trusted before key verification"
done

echo "PASS: external APT signing keys are pinned by primary-key fingerprint and verified before use"
Loading