diff --git a/.changeset/dind-host-passthrough-images.md b/.changeset/dind-host-passthrough-images.md new file mode 100644 index 0000000..1ab9357 --- /dev/null +++ b/.changeset/dind-host-passthrough-images.md @@ -0,0 +1,5 @@ +--- +bump: minor +--- + +dind-box: add `DIND_HOST_PASSTHROUGH_IMAGES`, a per-repository (image-name) allowlist for host-image passthrough (issue #97). When non-empty, only host images whose reference matches at least one space-separated pattern (glob) are passed through, composed with the existing mode gate (so `public` still requires a public RepoDigest). Empty/unset preserves the current mode + registry behavior. Patterns match against several normalized forms of each reference, so `konard/hive-mind` matches `konard/hive-mind:latest` and `docker.io/konard/hive-mind:latest` alike. This is one level finer than `DIND_HOST_PASSTHROUGH_REGISTRIES` — it scopes passthrough to specific repositories / image names so a deployment can seed the inner daemon with only the images it owns rather than every public image on the host. Covered by new cases in `experiments/preload-unit-test.sh` and `tests/dind/example-preload-images.sh`, documented in `docs/dind/USAGE.md` and `README.md`. diff --git a/.github/actions/setup-buildx-resilient/action.yml b/.github/actions/setup-buildx-resilient/action.yml new file mode 100644 index 0000000..290ea7a --- /dev/null +++ b/.github/actions/setup-buildx-resilient/action.yml @@ -0,0 +1,47 @@ +name: 'Set up Docker Buildx (resilient)' +description: > + Wrapper around docker/setup-buildx-action that first pre-pulls the pinned + BuildKit image with retries and exponential backoff. Booting the + docker-container driver otherwise pulls moby/buildkit straight from Docker + Hub, and a transient registry timeout there fails the whole job (and, for + the amd64 essentials build, cascades into "box-essentials:-amd64: not + found" across every dependent dind build). Seeding the image locally first + means the boot reuses the cached image instead of hitting the registry. + See the CI investigation in issue #97. +inputs: + buildkit-image: + description: 'Pinned BuildKit image used by the docker-container driver.' + required: false + default: 'moby/buildkit:buildx-stable-1' +runs: + using: 'composite' + steps: + - name: Pre-pull BuildKit image (retry on transient registry errors) + shell: bash + env: + BUILDKIT_IMAGE: ${{ inputs.buildkit-image }} + run: | + set -u + attempts=5 + delay=5 + for attempt in $(seq 1 "$attempts"); do + echo "==> Pulling ${BUILDKIT_IMAGE} (attempt ${attempt}/${attempts})..." + if docker pull "${BUILDKIT_IMAGE}"; then + echo "==> BuildKit image cached locally; boot will not need a registry pull" + exit 0 + fi + if [ "$attempt" -lt "$attempts" ]; then + echo "==> Pull failed, waiting ${delay}s before next attempt..." + sleep "$delay" + delay=$((delay * 2)) + fi + done + # Non-fatal: fall through to setup-buildx, which will attempt its own + # pull during boot. This preserves the previous behaviour in the worst + # case while making the common transient-failure case recover. + echo "==> WARNING: could not pre-pull ${BUILDKIT_IMAGE} after ${attempts} attempts; letting setup-buildx try its own boot pull" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + with: + driver-opts: image=${{ inputs.buildkit-image }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 232d057..40a4456 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -645,7 +645,7 @@ jobs: swap-storage: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: ./.github/actions/setup-buildx-resilient - name: Build full chain (JS -> essentials -> languages -> full) run: | @@ -788,7 +788,7 @@ jobs: swap-storage: true - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: ./.github/actions/setup-buildx-resilient - name: Build base box for dind variant run: | @@ -986,7 +986,7 @@ jobs: echo "version=$VERSION" >> $GITHUB_OUTPUT - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: ./.github/actions/setup-buildx-resilient - name: Log in to GitHub Container Registry uses: docker/login-action@v4 @@ -1115,7 +1115,7 @@ jobs: echo "version=$VERSION" >> $GITHUB_OUTPUT - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: ./.github/actions/setup-buildx-resilient - name: Log in to GitHub Container Registry uses: docker/login-action@v4 @@ -1334,7 +1334,7 @@ jobs: echo "version=$VERSION" >> $GITHUB_OUTPUT - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: ./.github/actions/setup-buildx-resilient - name: Log in to GitHub Container Registry uses: docker/login-action@v4 @@ -1479,7 +1479,7 @@ jobs: echo "version=$VERSION" >> $GITHUB_OUTPUT - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: ./.github/actions/setup-buildx-resilient - name: Log in to GitHub Container Registry uses: docker/login-action@v4 @@ -1732,7 +1732,7 @@ jobs: - name: Set up Docker Buildx if: steps.check-lang.outputs.should_build == 'true' - uses: docker/setup-buildx-action@v4 + uses: ./.github/actions/setup-buildx-resilient - name: Log in to GitHub Container Registry if: steps.check-lang.outputs.should_build == 'true' @@ -1933,7 +1933,7 @@ jobs: - name: Set up Docker Buildx if: steps.check-lang.outputs.should_build == 'true' - uses: docker/setup-buildx-action@v4 + uses: ./.github/actions/setup-buildx-resilient - name: Log in to GitHub Container Registry if: steps.check-lang.outputs.should_build == 'true' @@ -2202,7 +2202,7 @@ jobs: echo "Building version: $VERSION" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: ./.github/actions/setup-buildx-resilient - name: Log in to GitHub Container Registry uses: docker/login-action@v4 @@ -2470,7 +2470,7 @@ jobs: echo "Building version: $VERSION" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: ./.github/actions/setup-buildx-resilient - name: Log in to GitHub Container Registry uses: docker/login-action@v4 @@ -2778,7 +2778,7 @@ jobs: echo "dind_suffix=${DIND_SUFFIX}" >> $GITHUB_OUTPUT - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: ./.github/actions/setup-buildx-resilient - name: Log in to GitHub Container Registry uses: docker/login-action@v4 @@ -2919,7 +2919,7 @@ jobs: echo "dind_suffix=${DIND_SUFFIX}" >> $GITHUB_OUTPUT - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: ./.github/actions/setup-buildx-resilient - name: Log in to GitHub Container Registry uses: docker/login-action@v4 diff --git a/README.md b/README.md index dd7954d..2ab111e 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,7 @@ Each row below has the same toolchain as its non-dind sibling **plus** a working > - **Recommended secure invocation:** [`docker run --runtime=sysbox-runc konard/box-dind`](https://github.com/nestybox/sysbox) — Sysbox is a drop-in OCI runtime that runs system containers without `--privileged` and without exposing host devices. > - **Do NOT bind-mount `/var/run/docker.sock`.** That gives the container root on the host ([Quarkslab](https://blog.quarkslab.com/why-is-exposing-the-docker-socket-a-really-bad-idea.html), [OWASP](https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html)) and breaks the per-box `docker ps` scoping property. > - **Storage:** the inner daemon writes to `/var/lib/docker` inside the container by default. For persistence, mount a volume: `-v box-dind-data:/var/lib/docker`. -> - **Reusing host images:** the nested daemon starts with an empty image store, so a fresh container re-downloads images the host already has. Seed it explicitly at startup with `DIND_PRELOAD_TARBALL` (mount `docker save` tarballs) or `DIND_PRELOAD_IMAGES` (pull from a registry/mirror); see [Reusing Host Images](docs/dind/USAGE.md#reusing-host-images-preload). For automatic seeding, mount the host socket at `-v /var/run/docker.sock:/var/run/host-docker.sock:ro` — host-image passthrough is on by default and copies the host's **public** images (those re-pullable from a public registry, so no local secrets or private credentials leak) into the inner daemon; `DIND_HOST_PASSTHROUGH=all` passes everything and `=off` disables it. The host socket is mounted at a non-default path and read only at startup to seed images, so the inner daemon keeps its own isolated socket. See [Host-Image Passthrough](docs/dind/USAGE.md#host-image-passthrough-dind_host_passthrough). +> - **Reusing host images:** the nested daemon starts with an empty image store, so a fresh container re-downloads images the host already has. Seed it explicitly at startup with `DIND_PRELOAD_TARBALL` (mount `docker save` tarballs) or `DIND_PRELOAD_IMAGES` (pull from a registry/mirror); see [Reusing Host Images](docs/dind/USAGE.md#reusing-host-images-preload). For automatic seeding, mount the host socket at `-v /var/run/docker.sock:/var/run/host-docker.sock:ro` — host-image passthrough is on by default and copies the host's **public** images (those re-pullable from a public registry, so no local secrets or private credentials leak) into the inner daemon; `DIND_HOST_PASSTHROUGH=all` passes everything and `=off` disables it. To copy only specific images rather than every public one, set `DIND_HOST_PASSTHROUGH_IMAGES` to a space-separated allowlist of names/globs (e.g. `"konard/hive-mind konard/hive-mind-dind"`), composed with the mode filter. The host socket is mounted at a non-default path and read only at startup to seed images, so the inner daemon keeps its own isolated socket. See [Host-Image Passthrough](docs/dind/USAGE.md#host-image-passthrough-dind_host_passthrough). > - **Usage examples:** see [`docs/dind/USAGE.md`](docs/dind/USAGE.md). Its examples are backed by executable tests under `tests/dind/`. See [docs/case-studies/issue-80/CASE-STUDY.md](docs/case-studies/issue-80/CASE-STUDY.md) for the full design and threat model. diff --git a/docs/dind/USAGE.md b/docs/dind/USAGE.md index 43f90a9..114a898 100644 --- a/docs/dind/USAGE.md +++ b/docs/dind/USAGE.md @@ -74,6 +74,7 @@ The entrypoint supports these environment variables: | `DIND_HOST_PASSTHROUGH` | `public` | Copy images already present on the host into the nested daemon at startup when a host socket is mounted (see below). `public` only passes images with a RepoDigest from an allowlisted public registry; `all` passes every tagged image; `off` disables it. A quiet no-op when no host socket is mounted. | | `DIND_HOST_DOCKER_SOCK` | `/var/run/host-docker.sock` | Path inside the container to the mounted *host* Docker socket used for passthrough. Deliberately **not** `/var/run/docker.sock`, so the inner daemon keeps its own isolated socket. | | `DIND_HOST_PASSTHROUGH_REGISTRIES` | common public registries | Space-separated allowlist of registries treated as "public" in `DIND_HOST_PASSTHROUGH=public` mode (default: `docker.io ghcr.io quay.io gcr.io registry.k8s.io public.ecr.aws mcr.microsoft.com`). | +| `DIND_HOST_PASSTHROUGH_IMAGES` | _(empty)_ | Space-separated allowlist of image references / globs. When non-empty, only host images matching at least one entry are passed through, composed with the mode filter (so `public` still requires a public RepoDigest). Empty keeps the mode + registry filter only. One level finer than `DIND_HOST_PASSTHROUGH_REGISTRIES` — scope to specific repositories / image names. | Use a named volume when the inner Docker state should survive container removal: @@ -226,6 +227,42 @@ daemon (from a volume, tarball, or earlier run) is skipped, and any single image that fails to copy logs a warning and continues. Like preload, it is skipped entirely when `DIND_SKIP_DAEMON=1`. +### Scoping to specific images (`DIND_HOST_PASSTHROUGH_IMAGES`) + +The mode gate decides *whether* an image is safe to pass; the registry allowlist +narrows it *by registry host*. `DIND_HOST_PASSTHROUGH_IMAGES` is one level finer +— it scopes passthrough to **specific repositories / image names**. When it is +non-empty, a host image must match the mode filter **and** at least one +space-separated pattern; empty (the default) keeps the mode + registry behavior. + +This is the precise fit for "seed the inner daemon with only the images I own" +rather than every public host image: + +```bash +# Pass through only hive-mind's own images, nothing else on the host: +docker run -d --privileged \ + -v /var/run/docker.sock:/var/run/host-docker.sock:ro \ + -e DIND_HOST_PASSTHROUGH=public \ + -e DIND_HOST_PASSTHROUGH_IMAGES="konard/hive-mind konard/hive-mind-dind" \ + --name box-dind \ + konard/box-dind sleep infinity + +# Globs and explicit tags / registry-qualified refs also work: +docker run -d --privileged \ + -v /var/run/docker.sock:/var/run/host-docker.sock:ro \ + -e DIND_HOST_PASSTHROUGH_IMAGES="docker.io/konard/hive-mind* konard/hive-mind-dind:latest" \ + --name box-dind \ + konard/box-dind sleep infinity +``` + +Patterns are matched against several normalized forms of each host image +reference, so a bare repository like `konard/hive-mind` matches the tagged +`konard/hive-mind:latest` and the registry-qualified +`docker.io/konard/hive-mind:latest` alike. Because it composes with the mode +gate, `public` mode still refuses a locally-built or private image even when it +matches a pattern — the allowlist only ever *narrows* the eligible set, it never +widens it past the security filter. + ## Commit Cycles `DIND_SKIP_DAEMON=1` is useful for setup containers where you want to install or diff --git a/experiments/preload-unit-test.sh b/experiments/preload-unit-test.sh index f77cbbc..8cb700d 100755 --- a/experiments/preload-unit-test.sh +++ b/experiments/preload-unit-test.sh @@ -200,6 +200,91 @@ DIND_HOST_PASSTHROUGH=off DIND_HOST_DOCKER_SOCK="$HOST_SOCK" \ check "off mode made no docker calls" bash -c '! test -s "$DOCKER_CALLS"' rm -f "$HOST_SOCK" +echo "== Case 14: DIND_HOST_PASSTHROUGH_IMAGES allowlist scopes passthrough to named repos ==" +reset_state +# Three Docker Hub images, all with a public RepoDigest so the mode gate passes; +# the allowlist must narrow to only the two hive-mind repos. +printf '%s\n%s\n%s\n' "konard/hive-mind:latest" "konard/hive-mind-dind:latest" "alpine:3.20" > "$HOST_IMAGES" +{ + echo "konard/hive-mind:latest|konard/hive-mind@sha256:aaa " + echo "konard/hive-mind-dind:latest|konard/hive-mind-dind@sha256:bbb " + echo "alpine:3.20|alpine@sha256:ccc " +} > "$HOST_DIGESTS" +make_sock "$HOST_SOCK" +DIND_HOST_PASSTHROUGH=public DIND_HOST_DOCKER_SOCK="$HOST_SOCK" \ + DIND_HOST_PASSTHROUGH_IMAGES="konard/hive-mind konard/hive-mind-dind" \ + DIND_PRELOAD_TARBALL="" DIND_PRELOAD_IMAGES="" DOCKER_INFO_OK=1 HOST_DOCKER_OK=1 \ + preload_into_daemon +check "allowlisted hive-mind image saved" grep -qx "konard/hive-mind:latest" "$DOCKER_SAVED" +check "allowlisted hive-mind-dind image saved" grep -qx "konard/hive-mind-dind:latest" "$DOCKER_SAVED" +check "non-allowlisted alpine NOT saved" bash -c '! grep -qx "alpine:3.20" "$DOCKER_SAVED"' +rm -f "$HOST_SOCK" + +echo "== Case 15: allowlist composes with mode (public still drops a local image even if allowlisted) ==" +reset_state +printf '%s\n%s\n' "konard/hive-mind:latest" "konard/hive-mind-dev:latest" > "$HOST_IMAGES" +# hive-mind has a public digest; hive-mind-dev is locally built (no digest line). +echo "konard/hive-mind:latest|konard/hive-mind@sha256:aaa " > "$HOST_DIGESTS" +make_sock "$HOST_SOCK" +DIND_HOST_PASSTHROUGH=public DIND_HOST_DOCKER_SOCK="$HOST_SOCK" \ + DIND_HOST_PASSTHROUGH_IMAGES="konard/hive-mind*" \ + DIND_PRELOAD_TARBALL="" DIND_PRELOAD_IMAGES="" DOCKER_INFO_OK=1 HOST_DOCKER_OK=1 \ + preload_into_daemon +check "allowlisted public image saved" grep -qx "konard/hive-mind:latest" "$DOCKER_SAVED" +check "allowlisted local image dropped by mode" bash -c '! grep -qx "konard/hive-mind-dev:latest" "$DOCKER_SAVED"' +rm -f "$HOST_SOCK" + +echo "== Case 16: globs and docker.io-qualified / tagged patterns all match ==" +reset_state +printf '%s\n%s\n' "konard/hive-mind:latest" "ghcr.io/owner/tool:v1" > "$HOST_IMAGES" +{ + echo "konard/hive-mind:latest|konard/hive-mind@sha256:aaa " + echo "ghcr.io/owner/tool:v1|ghcr.io/owner/tool@sha256:ddd " +} > "$HOST_DIGESTS" +make_sock "$HOST_SOCK" +# A docker.io-qualified glob for the hub image and an exact tagged ghcr ref. +DIND_HOST_PASSTHROUGH=all DIND_HOST_DOCKER_SOCK="$HOST_SOCK" \ + DIND_HOST_PASSTHROUGH_IMAGES="docker.io/konard/hive-mind* ghcr.io/owner/tool:v1" \ + DIND_PRELOAD_TARBALL="" DIND_PRELOAD_IMAGES="" DOCKER_INFO_OK=1 HOST_DOCKER_OK=1 \ + preload_into_daemon +check "docker.io-qualified glob matched the hub image" grep -qx "konard/hive-mind:latest" "$DOCKER_SAVED" +check "exact tagged ghcr ref matched" grep -qx "ghcr.io/owner/tool:v1" "$DOCKER_SAVED" +rm -f "$HOST_SOCK" + +echo "== Case 17: empty allowlist preserves prior behavior (all eligible images pass) ==" +reset_state +printf '%s\n%s\n' "konard/hive-mind:latest" "alpine:3.20" > "$HOST_IMAGES" +{ + echo "konard/hive-mind:latest|konard/hive-mind@sha256:aaa " + echo "alpine:3.20|alpine@sha256:ccc " +} > "$HOST_DIGESTS" +make_sock "$HOST_SOCK" +DIND_HOST_PASSTHROUGH=public DIND_HOST_DOCKER_SOCK="$HOST_SOCK" \ + DIND_HOST_PASSTHROUGH_IMAGES="" \ + DIND_PRELOAD_TARBALL="" DIND_PRELOAD_IMAGES="" DOCKER_INFO_OK=1 HOST_DOCKER_OK=1 \ + preload_into_daemon +check "empty allowlist still saves hive-mind" grep -qx "konard/hive-mind:latest" "$DOCKER_SAVED" +check "empty allowlist still saves alpine" grep -qx "alpine:3.20" "$DOCKER_SAVED" +rm -f "$HOST_SOCK" + +echo "== Case 18: image-matching helper normalization (direct calls) ==" +reset_state +# Like Case 13, drive the sourced helper in the current shell via `eval` so the +# function stays in scope (a `bash -c` subshell would not have it). Each check +# sets DIND_HOST_PASSTHROUGH_IMAGES inline so the case is self-contained. +check "bare repo matches tagged hub ref" \ + eval 'DIND_HOST_PASSTHROUGH_IMAGES="konard/hive-mind" host_image_matches_images_filter "konard/hive-mind:latest"' +check "docker.io-qualified pattern matches hub ref" \ + eval 'DIND_HOST_PASSTHROUGH_IMAGES="docker.io/konard/hive-mind" host_image_matches_images_filter "konard/hive-mind:latest"' +check "glob matches hub ref" \ + eval 'DIND_HOST_PASSTHROUGH_IMAGES="konard/hive-mind*" host_image_matches_images_filter "konard/hive-mind-dind:latest"' +check "unrelated pattern does not match" \ + eval '! DIND_HOST_PASSTHROUGH_IMAGES="konard/other" host_image_matches_images_filter "konard/hive-mind:latest"' +check "empty allowlist matches anything" \ + eval 'DIND_HOST_PASSTHROUGH_IMAGES="" host_image_matches_images_filter "anything:latest"' +check "ghcr exact ref matches" \ + eval 'DIND_HOST_PASSTHROUGH_IMAGES="ghcr.io/owner/tool:v1" host_image_matches_images_filter "ghcr.io/owner/tool:v1"' + echo "== Case 13: registry classification helpers ==" reset_state # These call the sourced functions in the current shell (command substitution diff --git a/tests/dind/example-preload-images.sh b/tests/dind/example-preload-images.sh index c94939c..b3c22ac 100755 --- a/tests/dind/example-preload-images.sh +++ b/tests/dind/example-preload-images.sh @@ -200,4 +200,32 @@ if ! docker logs "$public_container" 2>&1 | grep -q "passthrough loading host im fi log "public-mode passthrough copied the public image and skipped the local fixture (security filter held)" +# --- Per-repository allowlist (issue #97) ----------------------------------- +# DIND_HOST_PASSTHROUGH_IMAGES scopes passthrough to specific image names, +# composed with the mode gate. The throwaway host daemon currently holds two +# images: the local fixture (no RepoDigest) and the public alpine. Running in +# `all` mode (both would otherwise be eligible) with the allowlist pointed at +# only the fixture's repository must copy the fixture and skip alpine, proving +# the allowlist — not the mode — is what narrows the set. +images_container="${DIND_EXAMPLE_ID}-passthrough-images" +fixture_repo="${fixture_image%:*}" +log "starting consumer with DIND_HOST_PASSTHROUGH=all + DIND_HOST_PASSTHROUGH_IMAGES=${fixture_repo}" +run_dind_container "$images_container" \ + -e DIND_HOST_PASSTHROUGH=all \ + -e "DIND_HOST_PASSTHROUGH_IMAGES=$fixture_repo" \ + -e DIND_HOST_DOCKER_SOCK=/host-sock/docker.sock \ + -v "$host_sock_dir:/host-sock:ro" +wait_for_inner_docker "$images_container" +wait_for_preload_complete "$images_container" +assert_inner_has_image "$images_container" +if docker exec "$images_container" docker image inspect "$public_image" >/dev/null 2>&1; then + docker logs "$images_container" >&2 || true + fail "DIND_HOST_PASSTHROUGH_IMAGES must exclude ${public_image} (not in the allowlist)" +fi +if ! docker logs "$images_container" 2>&1 | grep -q "images=${fixture_repo}"; then + docker logs "$images_container" >&2 || true + fail "expected the consumer to log the active DIND_HOST_PASSTHROUGH_IMAGES allowlist" +fi +log "images-allowlist passthrough copied only the named repo and skipped the rest" + log "preload example passed" diff --git a/ubuntu/24.04/dind/dind-entrypoint.sh b/ubuntu/24.04/dind/dind-entrypoint.sh index a0024f3..3d4987d 100644 --- a/ubuntu/24.04/dind/dind-entrypoint.sh +++ b/ubuntu/24.04/dind/dind-entrypoint.sh @@ -63,6 +63,19 @@ # the common public registries: docker.io ghcr.io quay.io # gcr.io registry.k8s.io public.ecr.aws mcr.microsoft.com). # (issue #94) +# DIND_HOST_PASSTHROUGH_IMAGES +# Space-separated allowlist of image references / globs. +# When non-empty, only host images whose reference matches +# at least one entry are passed through, composed with the +# mode filter (so "public" still gates on a public +# RepoDigest). Empty/unset keeps the current behavior +# (mode + registry filter only). Patterns are matched +# against several normalized forms of the reference, so +# "konard/hive-mind" matches "konard/hive-mind:latest" and +# "docker.io/konard/hive-mind:latest" alike, and globs work +# (e.g. "docker.io/konard/hive-mind*"). This narrows +# passthrough one level finer than the registry allowlist +# — to specific repositories / image names. (issue #97) set -eu @@ -76,6 +89,7 @@ DIND_PRELOAD_IMAGES="${DIND_PRELOAD_IMAGES:-}" DIND_HOST_PASSTHROUGH="${DIND_HOST_PASSTHROUGH:-public}" DIND_HOST_DOCKER_SOCK="${DIND_HOST_DOCKER_SOCK:-/var/run/host-docker.sock}" DIND_HOST_PASSTHROUGH_REGISTRIES="${DIND_HOST_PASSTHROUGH_REGISTRIES:-docker.io ghcr.io quay.io gcr.io registry.k8s.io public.ecr.aws mcr.microsoft.com}" +DIND_HOST_PASSTHROUGH_IMAGES="${DIND_HOST_PASSTHROUGH_IMAGES:-}" log() { echo "[dind-entrypoint] $*"; } warn() { echo "[dind-entrypoint] WARN: $*" >&2; } @@ -317,6 +331,42 @@ registry_is_public() { return 1 } +# When DIND_HOST_PASSTHROUGH_IMAGES is non-empty, a host image is eligible only +# if its reference matches at least one space-separated pattern (shell glob). +# Patterns are matched against several normalized forms of the reference so that +# an entry like "konard/hive-mind" matches "konard/hive-mind:latest" and the +# docker.io-qualified "docker.io/konard/hive-mind[:latest]" alike, keeping the +# allowlist ergonomic. An empty list always passes (filter disabled). (issue #97) +host_image_matches_images_filter() { + ref="$1" + [ -n "$DIND_HOST_PASSTHROUGH_IMAGES" ] || return 0 + + repo="${ref%:*}" # strip the :tag -> repository (handles host:port/path too) + + # Candidate forms to test patterns against: the tagged ref, the bare repo, and + # — for Docker Hub refs — the same two with an explicit docker.io/ prefix (or, + # if already docker.io-qualified, with that prefix stripped). + set -- "$ref" "$repo" + case "$ref" in + docker.io/*) + set -- "$@" "${ref#docker.io/}" "${repo#docker.io/}" ;; + *) + if [ "$(image_registry "$ref")" = "docker.io" ]; then + set -- "$@" "docker.io/$ref" "docker.io/$repo" + fi ;; + esac + + for pattern in $DIND_HOST_PASSTHROUGH_IMAGES; do + for cand in "$@"; do + # shellcheck disable=SC2254 # intentional glob match against the pattern + case "$cand" in + $pattern) return 0 ;; + esac + done + done + return 1 +} + # Decide whether a host image should be passed through under the current mode. # "all" -> every tagged image qualifies. # "public" -> the image must carry a RepoDigest from an allowlisted public @@ -326,6 +376,11 @@ registry_is_public() { # local build secrets or images that required a credential. host_image_passes_filter() { ref="$1"; repo_digests="$2" + + # Repository/name allowlist, composed with the mode gate below: when set, the + # image must additionally match at least one pattern. (issue #97) + host_image_matches_images_filter "$ref" || return 1 + case "$DIND_HOST_PASSTHROUGH" in all) return 0 ;; public) @@ -353,7 +408,11 @@ passthrough_host_images() { fi hostdocker="docker -H unix://$DIND_HOST_DOCKER_SOCK" - log "host-image passthrough (mode=${DIND_HOST_PASSTHROUGH}) from ${DIND_HOST_DOCKER_SOCK}" + if [ -n "$DIND_HOST_PASSTHROUGH_IMAGES" ]; then + log "host-image passthrough (mode=${DIND_HOST_PASSTHROUGH}, images=${DIND_HOST_PASSTHROUGH_IMAGES}) from ${DIND_HOST_DOCKER_SOCK}" + else + log "host-image passthrough (mode=${DIND_HOST_PASSTHROUGH}) from ${DIND_HOST_DOCKER_SOCK}" + fi $hostdocker images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | sort -u \ | while IFS= read -r ref; do