Second half of security-review-2026-08-05 H-6. The infra half (unom/infra, runners/ci-core/) split the LAN registry in two: :5010 serves GET/HEAD only and refuses everything else with 405, :5011 demands basic auth on every request including the /v2/ ping. Both fronts sit on one store, and a registry keys by repository name rather than by the host:port the client used, so an image pushed to :5011 is the identical image every consumer pulls from :5010. So: builds tag the write port, a docker login precedes the push, and the release-tag manifest PUTs authenticate. Consumers are untouched — every `container:` in every other workflow still pulls anonymously from :5010, and ci/rust-ci-arm64cross.Dockerfile's `FROM 192.168.1.58:5010/...` still resolves. Not doing the digest pinning the review asked for, deliberately, and the header says why at length. Once pushes are authenticated, the people who can overwrite a tag are exactly the people who can push to main and edit a pinned digest in this file — a pin defends against nobody it did not already trust, and costs a two-commit dance on every ci/ change (~3x a month) during which consumers run a builder image predating the change they are testing. What does close the residual gap is making :latest a checked function of the tree. reconcile-latest.sh asserts on every run that :latest and :ck-$KEY are the same digest, re-points it when they are not, and warns loudly. An out-of-band overwrite is caught on the next push to main with no churn, and it fixes a pre-existing bug on the side: reverting ci/ used to leave :latest on the newer build forever, because the older key is a cache hit and nothing re-pointed it. Repair rather than fail, because a legitimate revert must not red-line main. Verified against the live registry from a runner host with the real docker client: unauthenticated push denied, push to :5010 refused 405, authenticated push to :5011 accepted, that same image pulled back anonymously from :5010. reconcile-latest.sh exercised over all three cases (diverged -> repaired, already equal -> no-op, missing key -> exit 1). All seven builder images are consistent with their content keys today, so the new step is a silent no-op on its first real run.
69 lines
3.2 KiB
Bash
Executable File
69 lines
3.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Assert that a builder image's :latest is the SAME manifest as its content key, and
|
|
# re-point it when it isn't.
|
|
#
|
|
# This is what we do instead of pinning consumers by @sha256: digest
|
|
# (security-review-2026-08-05, H-6 — see the reasoning at the top of docker.yml). The
|
|
# content key is a hash of the ci/ tree, so "which image should :latest be?" has an
|
|
# answer derivable from the commit alone. Checking it on every run turns :latest from a
|
|
# tag someone remembered to move into a function of the tree.
|
|
#
|
|
# Two different things make them diverge and neither is distinguishable from here:
|
|
#
|
|
# - Someone overwrote :latest out of band. Post-fix that needs the push credential,
|
|
# but it is exactly the H-6 attack and it must not pass silently.
|
|
# - ci/ was reverted. The older key is already a cache hit, so nothing rebuilds and
|
|
# nothing re-points :latest — it stays on the newer build forever while every
|
|
# consumer pulls a builder that does not match the tree it is building. That bug
|
|
# predates this script.
|
|
#
|
|
# Both are repaired identically, so: repair, and shout. Failing the build instead would
|
|
# turn a legitimate revert into a red main with no way forward.
|
|
#
|
|
# Reads go to the anonymous port, the single write to the authenticated one.
|
|
set -euo pipefail
|
|
|
|
IMAGE="${1:?usage: reconcile-latest.sh <image> <content-key>}"
|
|
KEY="${2:?usage: reconcile-latest.sh <image> <content-key>}"
|
|
: "${CI_REGISTRY:?CI_REGISTRY not set}"
|
|
: "${CI_REGISTRY_PUSH:?CI_REGISTRY_PUSH not set}"
|
|
: "${CI_REGISTRY_PASSWORD:?CI_REGISTRY_PASSWORD not set}"
|
|
|
|
ACCEPT='Accept: application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
|
|
|
|
# Digest of a tag, or empty if the tag does not exist. Never fails the script itself —
|
|
# "missing" is a state this has to reason about, not an error to abort on.
|
|
digest_of() {
|
|
curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$1" 2>/dev/null \
|
|
| tr -d '\r' | sed -n 's/^[Dd]ocker-[Cc]ontent-[Dd]igest: //p' || true
|
|
}
|
|
|
|
key_digest=$(digest_of "$KEY")
|
|
latest_digest=$(digest_of latest)
|
|
|
|
if [ -z "$key_digest" ]; then
|
|
echo "::error::$IMAGE:$KEY has no manifest — the build or push above did not land"
|
|
exit 1
|
|
fi
|
|
|
|
if [ "$key_digest" = "$latest_digest" ]; then
|
|
echo "$IMAGE:latest == :$KEY ($key_digest)"
|
|
exit 0
|
|
fi
|
|
|
|
echo "::warning::$IMAGE:latest did not match its content key :$KEY — re-pointing it. If ci/ was not just reverted, someone overwrote this tag out of band: check the registry access log on home-ci-core."
|
|
echo " was: ${latest_digest:-<no :latest tag>}"
|
|
echo " wanted: $key_digest (:$KEY)"
|
|
|
|
tmp=$(mktemp)
|
|
trap 'rm -f "$tmp"' EXIT
|
|
media_type=$(curl -sfI -H "$ACCEPT" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY" \
|
|
| tr -d '\r' | sed -n 's/^[Cc]ontent-[Tt]ype: //p')
|
|
curl -sf -H "$ACCEPT" -o "$tmp" "http://$CI_REGISTRY/v2/$IMAGE/manifests/$KEY"
|
|
curl -sf -u "ci:$CI_REGISTRY_PASSWORD" -X PUT -H "Content-Type: $media_type" \
|
|
--data-binary @"$tmp" "http://$CI_REGISTRY_PUSH/v2/$IMAGE/manifests/latest"
|
|
|
|
now=$(digest_of latest)
|
|
[ "$now" = "$key_digest" ] || { echo "::error::re-point failed: :latest is $now"; exit 1; }
|
|
echo "$IMAGE:latest re-pointed to $key_digest"
|