Files
fips/testing/check-image-scoping.sh
T
Johnathan Corgan f40cc16bdb Close the three holes in the test-image scoping guard
The guard that stops a consumer naming the shared mutable test image had
three ways through it, so a regression could walk back in past something
that looked like it was watching.

It matched only the literal fips-test:latest, but docker resolves a bare
untagged fips-test to exactly that, so image: fips-test reintroduced the
defect verbatim and passed. The match now catches the untagged form while
still allowing the run-scoped tag and the documented FIPS_TEST_IMAGE
fallback.

It did not look at the build context at all, so reverting a build.context
to the shared testing/docker directory passed. That half of the original
defect is now guarded the same way as the image name, matching a path
whose last component is docker rather than exempting bind mounts and
per-run directories by name.

Its allowlist was whole-file, and the comment claiming none of those files
was reachable from a CI run was false for two of them. Entries are now
scoped to the specific reference that is justified, so a new shared-tag
reference anywhere in a formerly exempt file is still caught, and the
comment says plainly which files CI does reach and why their particular
references are safe. The guard's own exemption is bounded by asserting it
starts no docker command.

git ls-files failure is now checked rather than discarded, so a sweep that
could not run exits 2 instead of reporting a clean tree.

Each hole was reproduced against both the old and new guard: all three are
red now and were green before. The healthy tree still passes, and the
legitimate forms do not false-positive.
2026-08-02 14:47:54 +00:00

204 lines
10 KiB
Bash
Executable File

#!/bin/bash
# ── Test-image and build-context scoping guard ──────────────────────────────
# A local CI run builds fips-test:<run-id> from a build context of its own and
# hands both to every suite, through FIPS_TEST_IMAGE / FIPS_TEST_APP_IMAGE and
# FIPS_BUILD_CONTEXT. It does NOT write fips-test:latest and does not build from
# the shared testing/docker directory, deliberately.
#
# Both halves protect the same thing. While a bridge back to the shared mutable
# tag existed, a consumer that named it directly kept working while resolving
# whichever concurrent run wrote the tag last. The shared build context is that
# same failure one level down: testing/docker/ is a single directory in the
# working tree, so two runs racing on its CONTENTS produce a correctly
# per-run-tagged image built from the other run's binaries — scoping the tag
# alone does not close it. Either way the verdict is recorded against a commit
# whose binaries had not run, and nothing in the harness compares a running
# container's binary against the commit under test, so that failure is silent
# and leaves no artifact.
#
# The bridge is gone, so a consumer that names the shared tag now fails loudly
# at run time. This guard is the static half: it stops one being reintroduced,
# because the reintroduction is invisible on any host where a hand build has
# left an fips-test:latest and a populated testing/docker/ lying around.
#
# What counts as a violation:
# * a reference to the shared image name, whether tagged :latest or left
# untagged — docker resolves a bare `fips-test` to fips-test:latest, so
# `image: fips-test` reintroduces the identical defect;
# * a build context that names the shared testing/docker directory;
# unless it is a comment, the documented ${FIPS_TEST_IMAGE:-...} or
# ${FIPS_BUILD_CONTEXT:-...} fallback, or one of the individually justified
# references listed below.
#
# Exit 0 = clean. Exit 1 = an unexpected reference. Exit 2 = the guard could
# not run; never treated as a pass.
# ─────────────────────────────────────────────────────────────────────────────
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# The shared image name: tagged :latest, or left untagged, which docker
# resolves to :latest. The run-scoped forms fips-test:${CI_RUN_ID} and the
# comparison harness's fips-test:compare-<ref> must NOT match, so the untagged
# half requires the name to end at a character that can continue neither a
# repository name nor a tag.
SHARED_IMAGE_RE="fips-test(-app)?:latest|fips-test(-app)?([^-:._[:alnum:]]|\$)"
# The shared build context: a path whose LAST component is the tracked
# testing/docker directory. A path that continues past it — ../docker/resolv.conf,
# "\$SCRIPT_DIR"/docker/* — names a file inside the directory rather than using
# the directory as a context, and is not what races. The per-run context is
# docker-<run-id>, so the terminator class also keeps that from matching.
SHARED_CONTEXT_RE="/docker([[:space:]\"'}]|\$)|^[[:space:]]*(build|context):[[:space:]]*[\"']?docker/?[\"']?[[:space:]]*\$"
# Individually justified references, as "<path under testing/>::<text the line
# must contain>". The exemption is scoped to the reference, not to the file: a
# NEW shared-tag or shared-context reference added anywhere in one of these
# files is still a violation.
#
# Two of these files ARE reached from ci-local.sh, which is why a
# whole-file exemption would not be honest here:
# * sidecar/scripts/test-sidecar.sh is invoked with --skip-build, so the
# build block holding its two references never executes under CI;
# * ci-cleanup.sh is invoked to reap, and its references only pick the
# throwaway image ip(8) runs in — it addresses no test container.
ALLOWED_REFS=(
# The developer build. The shared directory is its context and the shared
# tags are its product; that is the whole point of the script, and CI does
# not run it.
'scripts/build.sh::DOCKER_DIR="$TESTING_DIR/docker"'
'scripts/build.sh::docker build -t fips-test:latest "$DOCKER_DIR"'
'scripts/build.sh::docker build -t fips-test-app:latest -f "$DOCKER_DIR/Dockerfile.app"'
'scripts/build.sh::echo "Done. Images: fips-test:latest, fips-test-app:latest"'
# The hand-run sidecar build, behind its --skip-build guard. ci-local.sh
# always passes that flag, so under CI these two lines do not run and the
# image comes from FIPS_TEST_IMAGE like everywhere else.
'sidecar/scripts/test-sidecar.sh::docker build -t fips-test:latest "$DOCKER_DIR"'
'sidecar/scripts/test-sidecar.sh::docker build -t fips-test-app:latest -f "$DOCKER_DIR/Dockerfile.app"'
# Hand-run A/B between two refs: it retags whatever the developer build
# last produced, under a name of its own.
'static/scripts/iperf-compare-refs.sh::docker tag fips-test:latest "$tag"'
# The image ip(8) runs in during a reap, wanted only for its iproute2. The
# repository-wide listing and the literal tag are the last two fallbacks,
# after --veth-image and the run's own FIPS_TEST_IMAGE have both come back
# empty; neither addresses a container under test.
'ci-cleanup.sh::docker image ls --format'
'ci-cleanup.sh::VETH_IMAGE="fips-test:latest"'
# Per-ref interop images. This copies three files OUT of the shared
# directory into a context of its own and builds from that, so it reads the
# shared directory but never uses it as a context.
'interop/build-images.sh::DOCKER_CTX_SRC="$REPO_ROOT/testing/docker"'
)
# This guard is the one file that must contain the patterns it looks for, so it
# is swept out of its own file list. The exemption is bounded rather than
# trusted: the guard must not be able to hide a real invocation behind it, so
# assert it runs no container command. That catches one written as its own
# statement, which is the shape a reintroduction here would take; it would not
# catch one buried inside a command substitution.
GUARD_REL="check-image-scoping.sh"
if ! command -v git >/dev/null 2>&1; then
echo "check-image-scoping: git not available, cannot sweep" >&2
exit 2
fi
if [[ ! -d "$SCRIPT_DIR" ]]; then
echo "check-image-scoping: $SCRIPT_DIR missing" >&2
exit 2
fi
if [[ ! -f "$SCRIPT_DIR/$GUARD_REL" ]]; then
echo "check-image-scoping: cannot find itself at $SCRIPT_DIR/$GUARD_REL" >&2
exit 2
fi
# Tracked files only, and no documentation: prose naming the tag or the
# directory is describing it, not resolving it.
if ! tracked="$(git -C "$SCRIPT_DIR/.." ls-files -- testing/)"; then
echo "check-image-scoping: git ls-files failed, refusing to pass" >&2
exit 2
fi
if [[ -z "$tracked" ]]; then
echo "check-image-scoping: no tracked files under testing/, refusing to pass" >&2
exit 2
fi
mapfile -t files < <(printf '%s\n' "$tracked" | grep -vE '\.md$')
if [[ ${#files[@]} -eq 0 ]]; then
echo "check-image-scoping: no tracked non-doc files under testing/, refusing to pass" >&2
exit 2
fi
violations=0
if grep -qE '^[[:space:]]*docker[[:space:]]' "$SCRIPT_DIR/$GUARD_REL"; then
echo "FAIL testing/$GUARD_REL runs a docker command."
echo " It is exempt from its own sweep only because it resolves no image."
violations=$((violations + 1))
fi
# True when this file:line is one of the justified references above.
allowed_ref() {
local rel="$1" text="$2" entry path frag
for entry in "${ALLOWED_REFS[@]}"; do
path="${entry%%::*}"
frag="${entry#*::}"
[[ "$rel" == "$path" && "$text" == *"$frag"* ]] && return 0
done
return 1
}
for f in "${files[@]}"; do
rel="${f#testing/}"
[[ "$rel" == "$GUARD_REL" ]] && continue
[[ -f "$SCRIPT_DIR/../$f" ]] || continue
while IFS= read -r hit; do
n="${hit%%:*}"
text="${hit#*:}"
# A comment line is describing the tag, not resolving it. Shell, python
# and yaml all use #; nothing under testing/ uses // for comments.
[[ "$text" =~ ^[[:space:]]*# ]] && continue
# The documented indirection: the shared tag as a FALLBACK, which is
# what a bare hand run is supposed to get.
[[ "$text" == *'${FIPS_TEST_IMAGE:-fips-test:latest}'* ]] && continue
[[ "$text" == *'${FIPS_TEST_APP_IMAGE:-fips-test-app:latest}'* ]] && continue
[[ "$text" == *'os.environ.get("FIPS_TEST_IMAGE", "fips-test:latest")'* ]] && continue
allowed_ref "$rel" "$text" && continue
echo "FAIL $f:$n names the shared test image directly:"
echo " $text"
violations=$((violations + 1))
done < <(grep -nE "$SHARED_IMAGE_RE" "$SCRIPT_DIR/../$f" 2>/dev/null)
while IFS= read -r hit; do
n="${hit%%:*}"
text="${hit#*:}"
[[ "$text" =~ ^[[:space:]]*# ]] && continue
# The same indirection for the context: the shared directory is allowed
# only as the fallback a bare hand run gets. Compose resolves a relative
# build.context against the compose FILE's directory, which is why the
# harness exports an absolute path here.
[[ "$text" == *'${FIPS_BUILD_CONTEXT:-'* ]] && continue
allowed_ref "$rel" "$text" && continue
echo "FAIL $f:$n builds from the shared testing/docker context:"
echo " $text"
violations=$((violations + 1))
done < <(grep -nE "$SHARED_CONTEXT_RE" "$SCRIPT_DIR/../$f" 2>/dev/null)
done
if [[ $violations -gt 0 ]]; then
echo ""
echo "check-image-scoping: $violations unscoped reference(s) to the shared test image"
echo "or the shared build context."
echo "Read FIPS_TEST_IMAGE (default \${FIPS_TEST_IMAGE:-fips-test:latest}) for the image"
echo "and FIPS_BUILD_CONTEXT (default \${FIPS_BUILD_CONTEXT:-../docker}) for the context."
echo "A run that resolves either shared name can execute a concurrent run's binaries"
echo "and record the verdict against this commit."
exit 1
fi
echo "check-image-scoping: no unscoped references to the shared test image or build context"
exit 0