From 430fb81ddb42476ec35f1cf1a7262bb23a8f68ad Mon Sep 17 00:00:00 2001 From: Haiyan Meng Date: Wed, 29 Jul 2026 08:52:16 -0400 Subject: [PATCH] hack/verify: gate copyleft base images in Dockerfiles Add container-licenses.sh, picked up automatically by verify-all.sh, which fails a Dockerfile that bakes a denied base image into an image we build, via either FROM or COPY --from=. The distinction the check encodes is between referencing and conveying. Pulling an image at runtime -- the busybox:1.36 init container in manifests/ate-install/atenet-dns.yaml, the workload images under benchmarking, the fixture strings in actortemplate_validation_test.go -- is not distribution: the cluster fetches the image from its upstream registry and we never hand over the binary. Baking one into an image we publish does make us a distributor of whatever that base contains, and for a GPL-2.0 base that obligation cannot be discharged once a release image is public. Only the latter path is gated. A repo-wide grep for the string would fail on the runtime references, which are fine, and would teach the wrong rule. DENY_IMAGES starts with busybox (GPL-2.0-only) and alpine (bundles busybox, so it carries the same obligation). Distro bases such as debian and ubuntu are deliberately absent: they ship GPL userland too, but are redistributed verbatim with the distro's own corresponding-source availability. That line is a policy choice rather than a derived legal one, and the script says so; adjust the list to whatever the project's license policy settles on. Parsing handles what a grep gets wrong: --platform and other FROM flags, registry prefixes and digests during normalization, stage aliases and numeric indices in COPY --from=, scratch, unresolvable build args, and comments. Matching on the exact repository name keeps a reference such as mycorp/busybox-tools from tripping it. The check self-tests before each scan, because its failure mode is otherwise silent: if an edit breaks the parsing so that nothing ever matches, it does not error, it prints that the tree is clean and exits 0, forever. A gate that has quietly stopped gating is worse than no gate, because it carries exactly the false confidence the check was written to avoid. So the parser runs against a fixture with a known answer first, and refuses to report a clean tree if it no longer behaves. That fixture is a heredoc written to a temporary file rather than a checked-in testdata/Dockerfile, because a real one would be matched by this script's own git ls-files glob and would need an exclusion -- and that exclusion would be a place to hide a Dockerfile from the check. Its last two lines name a build stage `alpine` and copy from it; that case looks redundant next to the existing `build` alias but is the only one that makes losing stage tracking observable, since an alias that is not itself a denied name normalizes to a harmless string. Mutation testing confirms the fixture catches removal of the digest strip, the registry-prefix strip, the tag strip, the FROM flag skip, stage-alias tracking, the comment skip, the scratch and build-arg skips, and a widening of the exact repository-name match to a substring match. Dropping the numeric stage-index guard is deliberately not caught: an index normalizes to a number that can never equal a denied name, so the guard documents intent rather than carrying logic. No Dockerfile in tree is affected; the three present base on python-slim, golang-bookworm, distroless, node-slim and ubuntu. No test framework is introduced; the repository has none for its 67 hack scripts, and one script does not justify one. Verified clean under shellcheck at the repo's pinned 0.9.0 and under boilerplate.sh. --- hack/verify/container-licenses.sh | 294 ++++++++++++++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100755 hack/verify/container-licenses.sh diff --git a/hack/verify/container-licenses.sh b/hack/verify/container-licenses.sh new file mode 100755 index 000000000..236085ad2 --- /dev/null +++ b/hack/verify/container-licenses.sh @@ -0,0 +1,294 @@ +#!/usr/bin/env bash + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Verifies that no Dockerfile in this repository bakes a copyleft-licensed base +# image into an image we build and publish. +# +# The rule this encodes: +# +# * Referencing an image at runtime (a `image:` field in a Kubernetes +# manifest, a `docker run` in a dev script, a test fixture string) is NOT +# distribution. The cluster pulls the image straight from its upstream +# registry; we never convey the binary, so no GPL source-offer obligation +# attaches to us. Those references are intentionally NOT checked here. +# +# * Baking an image into one of ours via `FROM` or `COPY --from=` and then +# publishing the result DOES make us a distributor of whatever that base +# contains. For GPL-2.0 bases that means owing complete corresponding +# source or a written offer, and it cannot be undone once a release image +# is public. That is the path this script gates. +# +# The denylist below is a policy list, not a derived legal truth. Distro bases +# such as debian and ubuntu also ship GPL userland (bash, coreutils) and are +# deliberately absent: they are redistributed verbatim from upstream with the +# distro's own corresponding-source availability, which is the common-practice +# carve-out. Adjust the list to match whatever the project's license policy +# actually says. + +set -o errexit -o nounset -o pipefail + +ROOT="$(git rev-parse --show-toplevel)" +cd "${ROOT}" + +# Base images that may not be baked into an image we build. Keep the reason +# attached to each entry so the next person can re-evaluate it. +DENY_IMAGES=( + busybox # GPL-2.0-only. + alpine # Bundles busybox, so it carries the same GPL-2.0-only obligation. +) + +# Denied references found so far, as tab-separated file/line/ref/context +# records. Kept as data rather than printed on the spot so that the self-test +# below can assert on exactly what the parser matched. +FINDINGS=() + +# Reduces an image reference to its bare repository name so that +# `docker.io/library/busybox:1.36` and `busybox@sha256:...` both compare equal +# to `busybox`. +normalize_image() { + local ref="$1" last prefix + + ref="${ref%@*}" # Strip any digest. + + # Strip the tag from the final path component only, so a registry host with + # a port (localhost:5000/foo) survives intact. + last="${ref##*/}" + prefix="${ref%"${last}"}" + last="${last%%:*}" + ref="${prefix}${last}" + + ref="${ref#docker.io/}" + ref="${ref#index.docker.io/}" + ref="${ref#library/}" + + printf '%s' "${ref}" +} + +# Reports an image reference if it resolves to a denied base image. +report_if_denied() { + local file="$1" lineno="$2" ref="$3" context="$4" + local norm deny + + # Build args cannot be resolved statically; `scratch` is not a real image. + if [[ "${ref}" == *'$'* || "${ref}" == "scratch" ]]; then + return 0 + fi + + norm="$(normalize_image "${ref}")" + + for deny in "${DENY_IMAGES[@]}"; do + if [[ "${norm}" == "${deny}" ]]; then + FINDINGS+=("${file}"$'\t'"${lineno}"$'\t'"${ref}"$'\t'"${context}") + return 0 + fi + done +} + +# Renders the recorded findings for human consumption. +print_findings() { + local finding file lineno ref context + + for finding in "${FINDINGS[@]}"; do + IFS=$'\t' read -r file lineno ref context <<<"${finding}" + echo "${file}:${lineno}: disallowed base image '${ref}'" + echo " ${context}" + done +} + +# Scans a single Dockerfile for FROM and COPY --from= references. +check_dockerfile() { + local file="$1" + local -A stages=() + local lineno=0 line kw img src token i + local -a tok=() lines=() + + mapfile -t lines <"${file}" + if [[ ${#lines[@]} -eq 0 ]]; then + return 0 + fi + + for line in "${lines[@]}"; do + lineno=$((lineno + 1)) + + line="${line%$'\r'}" + line="${line#"${line%%[![:space:]]*}"}" # Trim leading whitespace. + + if [[ -z "${line}" || "${line}" == '#'* ]]; then + continue + fi + + # shellcheck disable=SC2206 # Word splitting on Dockerfile tokens is intended. + tok=(${line}) + kw="${tok[0],,}" + + if [[ "${kw}" == "from" ]]; then + # Skip flags such as --platform=linux/amd64. + i=1 + while [[ ${i} -lt ${#tok[@]} && "${tok[${i}]}" == --* ]]; do + i=$((i + 1)) + done + if [[ ${i} -ge ${#tok[@]} ]]; then + continue + fi + + img="${tok[${i}]}" + + # Record the stage alias from `FROM AS ` so that a later + # `COPY --from=` is understood as a stage, not an image. + if [[ $((i + 2)) -lt ${#tok[@]} && "${tok[$((i + 1))],,}" == "as" ]]; then + stages["${tok[$((i + 2))],,}"]=1 + fi + + report_if_denied "${file}" "${lineno}" "${img}" "${line}" + + elif [[ "${kw}" == "copy" && ${#tok[@]} -gt 1 ]]; then + for token in "${tok[@]:1}"; do + if [[ "${token}" != --from=* ]]; then + continue + fi + + src="${token#--from=}" + + # A named earlier stage or a numeric stage index is not an image. + if [[ -n "${stages[${src,,}]:-}" || "${src}" =~ ^[0-9]+$ ]]; then + continue + fi + + report_if_denied "${file}" "${lineno}" "${src}" "${line}" + done + fi + done +} + +# Joins its arguments one per line, tolerating an empty argument list. +join_lines() { + if [[ $# -eq 0 ]]; then + printf '' + else + printf '%s\n' "$@" + fi +} + +# Exercises the parser against a fixture with a known answer. +# +# This exists because the failure mode of this script is silent: if an edit +# breaks the parsing so that nothing ever matches, the check keeps exiting 0 +# and reporting a clean tree forever. A gate that has quietly stopped gating is +# worse than no gate at all, so every run proves the detector still detects +# before anyone trusts a clean result. +# +# The fixture is written to a temporary file rather than checked in, because a +# real Dockerfile under testdata/ would be picked up by the repository scan +# below and would need an exclusion -- and that exclusion would become a place +# to hide a Dockerfile from this check. +self_test() { + local fixture finding lineno ref got_joined want_joined + local -a got=() + + fixture="$(mktemp)" + cat >"${fixture}" <<'FIXTURE' +# FROM busybox here is a comment and must not match. +FROM busybox:1.36 +FROM --platform=linux/amd64 docker.io/library/alpine:3.19 +FROM golang:1.26-bookworm AS build +COPY --from=build /a /b +COPY --from=busybox@sha256:0123456789 /bin/sh /bin/sh +COPY --from=0 /c /d +FROM scratch +FROM ${BASE_IMAGE} +FROM localhost:5000/mycorp/busybox-tools:v1 +FROM index.docker.io/library/busybox AS final +FROM golang:1.26-bookworm AS alpine +COPY --from=alpine /e /f +FIXTURE + + FINDINGS=() + check_dockerfile "${fixture}" + rm -f "${fixture}" + + for finding in "${FINDINGS[@]+"${FINDINGS[@]}"}"; do + IFS=$'\t' read -r _ lineno ref _ <<<"${finding}" + got+=("${lineno} ${ref}") + done + + # Denied bases on lines 2, 3, 6 and 11. Everything else in the fixture must + # be skipped: the comment, the `build` stage alias, the numeric stage index, + # `scratch`, the unresolvable build arg, and mycorp/busybox-tools, whose name + # merely contains a denied one. + # + # The last two fixture lines name a stage `alpine` and then copy from it. + # That is what makes losing stage tracking detectable: an alias that is not + # itself a denied name would simply normalize to a harmless string and the + # regression would pass unnoticed. + want_joined="$(join_lines \ + "2 busybox:1.36" \ + "3 docker.io/library/alpine:3.19" \ + "6 busybox@sha256:0123456789" \ + "11 index.docker.io/library/busybox")" + got_joined="$(join_lines "${got[@]+"${got[@]}"}")" + + if [[ "${got_joined}" != "${want_joined}" ]]; then + cat >&2 <