diff --git a/.github/workflows/publish-dex-security-image.yaml b/.github/workflows/publish-dex-security-image.yaml new file mode 100644 index 0000000000..7a7615f4b0 --- /dev/null +++ b/.github/workflows/publish-dex-security-image.yaml @@ -0,0 +1,161 @@ +name: Publish Dex Security Image + +on: + pull_request: + branches: [main] + paths: + - .github/workflows/publish-dex-security-image.yaml + - scripts/build-dex-security-image.sh + - scripts/check-dex-binary/** + - go.mod + - go.sum + - .github/scripts/setup-supply-chain-tools.sh + - k8s/bases/infrastructure/controllers/dex/DexSecurity.Dockerfile + - k8s/bases/infrastructure/controllers/dex/dex-v2.46.1-network.patch + - k8s/bases/infrastructure/controllers/dex/IMAGE-NOTICE + push: + branches: [main] + paths: + - .github/workflows/publish-dex-security-image.yaml + - scripts/build-dex-security-image.sh + - scripts/check-dex-binary/** + - go.mod + - go.sum + - .github/scripts/setup-supply-chain-tools.sh + - k8s/bases/infrastructure/controllers/dex/DexSecurity.Dockerfile + - k8s/bases/infrastructure/controllers/dex/dex-v2.46.1-network.patch + - k8s/bases/infrastructure/controllers/dex/IMAGE-NOTICE + workflow_dispatch: + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +env: + IMAGE: ghcr.io/devantler-tech/platform-dex + IMAGE_TAG: v2.46.1-security.1-${{ github.sha }} + +jobs: + verify: + name: Verify native Dex distribution + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + outputs: + archive_sha: ${{ steps.build.outputs.archive_sha }} + image_id: ${{ steps.build.outputs.image_id }} + binary_sha: ${{ steps.build.outputs.binary_sha }} + artifact_name: ${{ steps.artifact.outputs.name }} + steps: + - name: Harden runner + uses: step-security/harden-runner@dc2769abcc0fcfbadfc1df54081c1608579ef6b2 # v2.22.2 + with: + egress-policy: audit + - name: Check out reviewed platform source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Check out pinned Dex release + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: dexidp/dex + ref: 75eb19238e10fc4750e7efb05a4031b254b04029 + path: upstream + fetch-depth: 0 + persist-credentials: false + - name: Set up reviewed Go compiler + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: 1.27.3 + cache: false + - name: Install the static musl compiler + run: | + sudo apt-get update + sudo apt-get install --yes musl-tools + - name: Build, scan and exercise exact distribution + id: build + run: bash scripts/build-dex-security-image.sh upstream "${IMAGE}:${IMAGE_TAG}" + - name: Name the verified archive + id: artifact + run: printf 'name=dex-image-%s-%s\n' "${GITHUB_RUN_ID:?}" "${GITHUB_RUN_ATTEMPT:?}" >> "${GITHUB_OUTPUT:?}" + - name: Retain the verified image without rebuilding + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ steps.artifact.outputs.name }} + path: ${{ runner.temp }}/dex-image.tar + if-no-files-found: error + retention-days: 7 + compression-level: 0 + + publish: + name: Publish verified Dex bytes + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: verify + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read # Check out reviewed source and its verified tool installer. + packages: write # Push the verified distribution and attestations to GHCR. + id-token: write # Obtain the main-workflow identity for keyless Cosign signing. + steps: + - name: Harden runner + uses: step-security/harden-runner@dc2769abcc0fcfbadfc1df54081c1608579ef6b2 # v2.22.2 + with: + egress-policy: audit + - name: Check out reviewed platform source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Download this run's verified image + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: ${{ needs.verify.outputs.artifact_name }} + path: ${{ runner.temp }}/dex-verified + - name: Check archive and native image identity + env: + ARCHIVE_SHA: ${{ needs.verify.outputs.archive_sha }} + IMAGE_ID: ${{ needs.verify.outputs.image_id }} + run: | + set -euo pipefail + [[ "${ARCHIVE_SHA}" =~ ^[0-9a-f]{64}$ ]] + [[ "${IMAGE_ID}" =~ ^sha256:[0-9a-f]{64}$ ]] + printf '%s %s\n' "${ARCHIVE_SHA}" "${RUNNER_TEMP}/dex-verified/dex-image.tar" | sha256sum --check + docker load --input "${RUNNER_TEMP}/dex-verified/dex-image.tar" + test "$(docker image inspect --format '{{.Id}}' "${IMAGE}:${IMAGE_TAG}")" = "${IMAGE_ID}" + - name: Install checksum-verified supply-chain tools + run: bash .github/scripts/setup-supply-chain-tools.sh + - name: Inventory the verified distribution + run: syft "docker:${IMAGE}:${IMAGE_TAG}" --output "cyclonedx-json=${RUNNER_TEMP}/dex-sbom.json" + - name: Log in to GHCR + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Push, sign and verify this distribution + env: + ARCHIVE_SHA: ${{ needs.verify.outputs.archive_sha }} + IMAGE_ID: ${{ needs.verify.outputs.image_id }} + BINARY_SHA: ${{ needs.verify.outputs.binary_sha }} + run: | + set -euo pipefail + [[ "${BINARY_SHA}" =~ ^[0-9a-f]{64}$ ]] + docker push "${IMAGE}:${IMAGE_TAG}" + digest_ref="$(docker image inspect --format '{{json .RepoDigests}}' "${IMAGE}:${IMAGE_TAG}" | jq -er --arg prefix "${IMAGE}@sha256:" 'map(select(startswith($prefix))) | if length == 1 then .[0] else error("ambiguous digest") end')" + cosign sign --yes "${digest_ref}" + jq -n --arg revision "${GITHUB_SHA}" --arg imageId "${IMAGE_ID}" --arg binarySha "${BINARY_SHA}" \ + --arg archiveSha "${ARCHIVE_SHA}" --arg run "${GITHUB_RUN_ID}" \ + '{sourceRevision:$revision,upstreamRevision:"75eb19238e10fc4750e7efb05a4031b254b04029",patchedSourceTree:"6ba6ad659479412f2a33c391e6c64a3cfcc3f05c",imageId:$imageId,binarySha256:$binarySha,verifiedArchiveSha256:$archiveSha,workflowRunId:$run}' \ + > "${RUNNER_TEMP}/dex-build.json" + cosign attest --yes --predicate "${RUNNER_TEMP}/dex-build.json" --type https://devantler.tech/attestations/dex-build/v1 "${digest_ref}" + cosign attest --yes --predicate "${RUNNER_TEMP}/dex-sbom.json" --type cyclonedx "${digest_ref}" + identity=https://github.com/devantler-tech/platform/.github/workflows/publish-dex-security-image.yaml@refs/heads/main + cosign verify --certificate-identity "${identity}" --certificate-oidc-issuer https://token.actions.githubusercontent.com "${digest_ref}" + for type in https://devantler.tech/attestations/dex-build/v1 cyclonedx; do + cosign verify-attestation --type "${type}" --certificate-identity "${identity}" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com "${digest_ref}" > /dev/null + done + printf 'Published and verified: %s\n' "${digest_ref}" diff --git a/k8s/bases/infrastructure/controllers/dex/DexSecurity.Dockerfile b/k8s/bases/infrastructure/controllers/dex/DexSecurity.Dockerfile new file mode 100644 index 0000000000..d725a3777f --- /dev/null +++ b/k8s/bases/infrastructure/controllers/dex/DexSecurity.Dockerfile @@ -0,0 +1,11 @@ +# The native executable is compiled, scanned and exercised before this step. +# Preserve SQLite support with a static CGO build; omit the unused gomplate +# launcher. Helm's existing [dex, serve, ...] arguments execute directly. +FROM gcr.io/distroless/static-debian13:nonroot@sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3 +COPY --chmod=0755 dist/dex /usr/local/bin/dex +COPY web /srv/dex/web +COPY dist/licenses /usr/share/licenses/dex +ENV PATH=/usr/local/bin +USER 65534:65534 +ENTRYPOINT [] +CMD ["dex", "serve", "/etc/dex/config.yaml"] diff --git a/k8s/bases/infrastructure/controllers/dex/IMAGE-NOTICE b/k8s/bases/infrastructure/controllers/dex/IMAGE-NOTICE new file mode 100644 index 0000000000..492cb1dfa3 --- /dev/null +++ b/k8s/bases/infrastructure/controllers/dex/IMAGE-NOTICE @@ -0,0 +1,18 @@ +Dex v2.46.1, upstream revision 75eb19238e10fc4750e7efb05a4031b254b04029 +https://github.com/dexidp/dex + +The Platform distribution updates golang.org/x/net from v0.59.0 to v0.60.0. +The source patch and build procedure are in devantler-tech/platform. +It retains Dex's compiled connectors and storage backends, including SQLite. +The image launches Dex directly: gomplate templates and the upstream container +entrypoint are not included. Supply an already-rendered native Dex configuration. +Dex's own environment expansion remains available. + +Dex and its bundled web files retain the upstream Apache-2.0 license. Third-party +dependency license and notice files copied by Go's vendor operation are included +alongside the upstream license. source.tar.gz supplies the complete patched Dex +and vendored dependency sources, including MPL-covered files. To rebuild Dex, +use Go 1.27.3, CGO_ENABLED=1, CC=musl-gcc and the netgo,osusergo build tags with +the static external-link options in the published Platform build procedure. +Go and musl notices are included; the runtime base retains its own notices. +This modified image is maintained by Devantler Tech, not by the Dex project. diff --git a/k8s/bases/infrastructure/controllers/dex/README.md b/k8s/bases/infrastructure/controllers/dex/README.md index 7262d5eb2f..1768be7695 100644 --- a/k8s/bases/infrastructure/controllers/dex/README.md +++ b/k8s/bases/infrastructure/controllers/dex/README.md @@ -5,6 +5,46 @@ A federated OpenID Connect provider for Kubernetes. - [Documentation](https://dexidp.io/docs/) - [Helm Chart](https://github.com/dexidp/helm-charts/tree/master/charts/dex) +## Source-built distribution + +[`publish-dex-security-image.yaml`](../../../../../.github/workflows/publish-dex-security-image.yaml) +builds a reviewed Dex release with the narrow dependency correction in +[`dex-v2.46.1-network.patch`](dex-v2.46.1-network.patch). It verifies the exact +upstream commit and patched tree, runs the upstream command, server, memory-store +and OIDC connector tests, then compiles a static executable with SQLite support. +The runtime contains that musl-linked executable, upstream web files, CA +certificates, license notices and a passive source archive covering the patched +upstream and vendored dependencies. It accepts native Dex configuration and launches directly; +gomplate templates are outside this distribution's contract. + +The admission command runs the pinned scanner on the actual executable itself. +Missing module coverage or a new reachable finding refuses publication. One +historical advisory has an open-ended Go version mapping but an upstream custom +range fixed before this release. Its disposition requires unchanged primary +metadata, the fixing commit's ancestry, the exact patched executable's build +information, and real HTTPS **and** gRPC handshakes. Configured TLS 1.3 must work +before and after an observed renewal on both endpoints; TLS 1.2, a wrong CA and a wrong hostname must +fail. A real TLS-1.2-configured negative control must fail admission. This is a +specific fix-or-refute decision, not a scanner suppression or a claim that all +required modules are free of advisories. + +Renewal writes a new certificate with a distinct issuer and serial, waits until +both endpoints serve that leaf, and repeats the controls with its new trust +pool. A deliberately non-shipping reloader mutation must fail that observation. +The immutable archive name comes from its producing job's output, so a partial +publisher retry retains the successful verification attempt's artifact. + +CI also extracts and rechecks the packaged executable and web files. Publication +loads the same archive produced by verification, checks both its hash and image +identity, and pushes those bytes without rebuilding. The main-only workflow +signs the resulting digest and attaches verified build and CycloneDX statements. +Image tags identify the source revision; consumers must use the verified digest. + +Building and publishing the image does not adopt it. Runtime use requires a +separate reviewed digest pin, signer/node-pull admission and observed rollout. +The infrastructure broker and any customer broker retain independent +configuration, clients, credentials and storage. + ## Sign-in pages Dex's pages carry the Devantler Tech look: the green orb logo, the devantler.tech diff --git a/k8s/bases/infrastructure/controllers/dex/dex-v2.46.1-network.patch b/k8s/bases/infrastructure/controllers/dex/dex-v2.46.1-network.patch new file mode 100644 index 0000000000..3ec3abba19 --- /dev/null +++ b/k8s/bases/infrastructure/controllers/dex/dex-v2.46.1-network.patch @@ -0,0 +1,14 @@ +diff --git a/go.mod b/go.mod +index 6a8a2b7..55de1c4 100644 +--- a/go.mod ++++ b/go.mod +@@ -43 +43 @@ require ( +- golang.org/x/net v0.59.0 ++ golang.org/x/net v0.60.0 +diff --git a/go.sum b/go.sum +index b02875b..13f574b 100644 +--- a/go.sum ++++ b/go.sum +@@ -332,0 +333,2 @@ golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= ++golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= ++golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= diff --git a/scripts/build-dex-security-image.sh b/scripts/build-dex-security-image.sh new file mode 100644 index 0000000000..15e5f8d83b --- /dev/null +++ b/scripts/build-dex-security-image.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source_dir="${1:?usage: build-dex-security-image.sh upstream-directory image-tag}" +image="${2:?usage: build-dex-security-image.sh upstream-directory image-tag}" +source_dir="$(cd "$source_dir" && pwd)" +[[ $# == 2 && $(uname -s) == Linux && $(uname -m) == x86_64 ]] +[[ $(go env GOVERSION) == go1.27.3 ]] +[[ $(git -C "$source_dir" rev-parse HEAD) == 75eb19238e10fc4750e7efb05a4031b254b04029 ]] +[[ -z $(git -C "$source_dir" status --porcelain) ]] +git -C "$source_dir" merge-base --is-ancestor 5bbdb4420254ba73b9c4df4775fe7bdacf233b17 HEAD +git -C "$source_dir" apply --check --unidiff-zero "$root/k8s/bases/infrastructure/controllers/dex/dex-v2.46.1-network.patch" +git -C "$source_dir" apply --unidiff-zero "$root/k8s/bases/infrastructure/controllers/dex/dex-v2.46.1-network.patch" +git -C "$source_dir" diff --check +git -C "$source_dir" add -- go.mod go.sum +[[ $(git -C "$source_dir" write-tree) == 6ba6ad659479412f2a33c391e6c64a3cfcc3f05c ]] + +export GOTOOLCHAIN=local GOWORK=off +export GOPROXY=https://proxy.golang.org GOSUMDB=sum.golang.org GOVCS='*:off' +export GOPRIVATE='' GONOPROXY='' GONOSUMDB='' +CGO_ENABLED=1 go -C "$source_dir" test -mod=readonly -timeout=5m ./cmd/dex ./server ./storage/memory ./connector/oidc +git -C "$source_dir" diff --exit-code +[[ -z $(git -C "$source_dir" ls-files --others --exclude-standard) ]] +# A controlled non-shipping executable keeps serving its original certificate. +# The renewal control must reject it; sending a signal alone used to pass. +original_serve="${RUNNER_TEMP:?}/dex-serve-original.go" +cp "$source_dir/cmd/dex/serve.go" "$original_serve" +trap 'cp "$original_serve" "$source_dir/cmd/dex/serve.go"' EXIT +[[ $(grep -Fc 'ptr.Store(loaded)' "$original_serve") == 1 ]] +sed 's/ptr.Store(loaded)/_ = loaded/' "$original_serve" > "$source_dir/cmd/dex/serve.go" +CC=musl-gcc CGO_ENABLED=1 go -C "$source_dir" build -mod=readonly -trimpath -tags netgo,osusergo \ + -ldflags '-X main.version=v2.46.1-devantler.1 -linkmode external -extldflags "-static"' \ + -o "$RUNNER_TEMP/dex-no-reload" ./cmd/dex +cp "$original_serve" "$source_dir/cmd/dex/serve.go" +trap - EXIT +git -C "$source_dir" diff --exit-code +mkdir "$source_dir/dist" +# Use musl's static runtime, preserving SQLite without introducing glibc into +# the distribution. Go handles network and user lookups directly. +CC=musl-gcc CGO_ENABLED=1 go -C "$source_dir" build -mod=readonly -trimpath -tags netgo,osusergo \ + -ldflags '-X main.version=v2.46.1-devantler.1 -linkmode external -extldflags "-static"' \ + -o "$source_dir/dist/dex" ./cmd/dex +file "$source_dir/dist/dex" | grep -q 'statically linked' +mkdir -p "${RUNNER_TEMP:?}/dex-tools" +GOBIN="$RUNNER_TEMP/dex-tools" go install golang.org/x/vuln/cmd/govulncheck@v1.8.0 +curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \ + https://vuln.go.dev/ID/GO-2024-2476.json --output "$RUNNER_TEMP/dex-advisory.json" +DEX_NATIVE_BINARY="$source_dir/dist/dex" DEX_STALE_CERTIFICATE_BINARY="$RUNNER_TEMP/dex-no-reload" DEX_NATIVE_WEB="$source_dir/web" \ + go -C "$root" test -count=1 ./scripts/check-dex-binary +go -C "$root" run ./scripts/check-dex-binary --binary "$source_dir/dist/dex" \ + --scanner "$RUNNER_TEMP/dex-tools/govulncheck" --advisory "$RUNNER_TEMP/dex-advisory.json" --web "$source_dir/web" + +# Go copies dependency notices when vendoring. Keep only those documents in the +# distribution; no dependency source, tests, credentials or build caches are shipped. +go -C "$source_dir" mod vendor +mkdir -p "$source_dir/dist/licenses" +cp "$source_dir/LICENSE" "$source_dir/dist/licenses/LICENSE" +cp "$root/k8s/bases/infrastructure/controllers/dex/IMAGE-NOTICE" "$source_dir/dist/licenses/NOTICE" +cp "$(go env GOROOT)/LICENSE" "$source_dir/dist/licenses/GO-LICENSE" +cp "$(go env GOROOT)/PATENTS" "$source_dir/dist/licenses/GO-PATENTS" +cp /usr/share/doc/musl/copyright "$source_dir/dist/licenses/MUSL-COPYRIGHT" +cp -L /usr/share/doc/libgcc-s1/copyright "$source_dir/dist/licenses/GCC-RUNTIME-COPYRIGHT" +cp /usr/share/common-licenses/GPL-3 "$source_dir/dist/licenses/GPL-3" +cp /usr/share/common-licenses/LGPL-3 "$source_dir/dist/licenses/LGPL-3" +cp /usr/share/common-licenses/LGPL-2.1 "$source_dir/dist/licenses/LGPL-2.1" +( + cd "$source_dir" + find vendor -type f \( -iname 'license*' -o -iname 'copying*' -o -iname 'notice*' -o -iname 'copyright*' -o -iname 'authors*' \) -print0 | + xargs -0 -r cp --parents --target-directory=dist/licenses +) +[[ $(find "$source_dir/dist/licenses/vendor" -type f | wc -l) -gt 0 ]] +# Supply the complete patched upstream and vendored dependency sources, including +# MPL-covered dependency files, alongside their notices. This is passive data; +# no source helper or second executable is introduced into the runtime. +tar --exclude=./.git --exclude=./dist --exclude=./bin --exclude=./tmp \ + -czf "$source_dir/dist/licenses/source.tar.gz" -C "$source_dir" . +mkdir "$RUNNER_TEMP/dex-context" +cp -R "$source_dir/dist" "$source_dir/web" "$RUNNER_TEMP/dex-context/" +docker build --file "$root/k8s/bases/infrastructure/controllers/dex/DexSecurity.Dockerfile" \ + --label org.opencontainers.image.source=https://github.com/devantler-tech/platform \ + --label "org.opencontainers.image.revision=${GITHUB_SHA:?}" \ + --label io.devantler.dex.upstream-revision=75eb19238e10fc4750e7efb05a4031b254b04029 \ + --tag "$image" "$RUNNER_TEMP/dex-context" +docker image inspect "$image" | jq -e 'length == 1 and (.[0] | .Os == "linux" and .Architecture == "amd64" and .Config.User == "65534:65534" and ((.Config.Entrypoint // []) | length == 0))' > /dev/null +docker run --rm --read-only --cap-drop=ALL --security-opt=no-new-privileges "$image" dex version + +# Inspect the packaged bytes, not just the pre-image build directory. +container_id="$(docker create "$image")" +trap 'docker rm "$container_id" >/dev/null' EXIT +mkdir "$RUNNER_TEMP/dex-packaged" +docker cp "$container_id:/usr/local/bin/dex" "$RUNNER_TEMP/dex-packaged/dex" +docker cp "$container_id:/srv/dex/web" "$RUNNER_TEMP/dex-packaged/web" +cmp "$source_dir/dist/dex" "$RUNNER_TEMP/dex-packaged/dex" +go -C "$root" run ./scripts/check-dex-binary --binary "$RUNNER_TEMP/dex-packaged/dex" \ + --scanner "$RUNNER_TEMP/dex-tools/govulncheck" --advisory "$RUNNER_TEMP/dex-advisory.json" --web "$RUNNER_TEMP/dex-packaged/web" +docker save --output "$RUNNER_TEMP/dex-image.tar" "$image" +sha256sum "$RUNNER_TEMP/dex-image.tar" | awk '{print "archive_sha=" $1}' >> "${GITHUB_OUTPUT:?}" +docker image inspect --format 'image_id={{.Id}}' "$image" >> "$GITHUB_OUTPUT" +sha256sum "$source_dir/dist/dex" | awk '{print "binary_sha=" $1}' >> "$GITHUB_OUTPUT" diff --git a/scripts/check-dex-binary/main.go b/scripts/check-dex-binary/main.go new file mode 100644 index 0000000000..8b3571174f --- /dev/null +++ b/scripts/check-dex-binary/main.go @@ -0,0 +1,510 @@ +// Command check-dex-binary admits a scanned executable only after testing its +// configured HTTPS and gRPC TLS floors. All listeners and credentials are local +// disposable fixtures. Raw scanner output is never printed. +package main + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "debug/buildinfo" + "encoding/json" + "encoding/pem" + "errors" + "flag" + "fmt" + "io" + "math/big" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" +) + +const upstreamRevision = "75eb19238e10fc4750e7efb05a4031b254b04029" + +func main() { + binary := flag.String("binary", "", "exact native executable to scan and exercise") + scanner := flag.String("scanner", "", "pinned govulncheck executable") + advisory := flag.String("advisory", "", "fresh primary GO-2024-2476 OSV document") + web := flag.String("web", "", "pinned upstream web directory") + flag.Parse() + if err := run(*binary, *scanner, *advisory, *web); err != nil { + fmt.Fprintln(os.Stderr, "Dex admission refused:", err) + os.Exit(1) + } + fmt.Println("Dex executable, scanner coverage, primary advisory and native TLS controls passed.") +} + +// run binds the executable and scanner identities before exercising the real +// service. An undisposed finding or changed executable refuses admission. +func run(binary, scanner, advisory, web string) error { + if binary == "" || scanner == "" || advisory == "" || web == "" || flag.NArg() != 0 { + return errors.New("binary, scanner, advisory and web are required") + } + info, err := buildinfo.ReadFile(binary) + if err != nil { + return fmt.Errorf("read native build information: %w", err) + } + if info.GoVersion != "go1.27.3" || info.Path != "github.com/dexidp/dex/cmd/dex" { + return errors.New("unexpected executable or compiler") + } + revision, modified, netVersion := "", "", "" + for _, setting := range info.Settings { + switch setting.Key { + case "vcs.revision": + revision = setting.Value + case "vcs.modified": + modified = setting.Value + } + } + for _, dep := range info.Deps { + if dep.Path == "golang.org/x/net" { + if dep.Replace != nil { + return errors.New("unexpected networking module replacement") + } + netVersion = dep.Version + } + } + if revision != upstreamRevision || modified != "true" || netVersion != "v0.60.0" { + return errors.New("executable does not bind the reviewed patched release") + } + scanInfo, err := buildinfo.ReadFile(scanner) + if err != nil { + return err + } + if scanInfo.Path != "golang.org/x/vuln/cmd/govulncheck" || scanInfo.Main.Version != "v1.8.0" || scanInfo.GoVersion != "go1.27.3" { + return errors.New("unexpected scanner executable") + } + before, err := binaryDigest(binary) + if err != nil { + return err + } + f, err := os.CreateTemp("", "dex-scan-*.json") + if err != nil { + return err + } + defer f.Close() + defer os.Remove(f.Name()) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, scanner, "-mode", "binary", "-format", "json", binary) + cmd.Stdout = f + if err := cmd.Run(); err != nil { + return errors.New("native scanner failed") + } + if _, err := f.Seek(0, io.SeekStart); err != nil { + return err + } + needsDisposition, err := judgeReport(f, info.GoVersion) + if err != nil { + return err + } + if needsDisposition { + body, err := os.ReadFile(advisory) + if err != nil { + return err + } + if err := checkHistoricalAdvisory(body); err != nil { + return err + } + } + if err := nativeTLS(binary, web); err != nil { + return err + } + after, err := binaryDigest(binary) + if err != nil { + return err + } + if before != after { + return errors.New("executable changed during admission") + } + return nil +} + +// binaryDigest binds the file scanned to the file exercised by the fixture. +func binaryDigest(path string) ([32]byte, error) { + f, err := os.Open(path) + if err != nil { + return [32]byte{}, err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return [32]byte{}, err + } + var sum [32]byte + copy(sum[:], h.Sum(nil)) + return sum, nil +} + +// judgeReport requires actual module/compiler coverage and rejects new reachable +// findings. A true result requires the separate historical TLS disposition. +func judgeReport(r io.Reader, compiler string) (bool, error) { + decoder := json.NewDecoder(r) + coverage, disposition := false, false + for { + var item struct { + SBOM *struct { + GoVersion string `json:"go_version"` + Modules []struct { + Path string `json:"path"` + } `json:"modules"` + } `json:"SBOM"` + Finding *struct { + OSV string `json:"osv"` + Trace []struct{ Module, Function string } `json:"trace"` + } `json:"finding"` + } + if err := decoder.Decode(&item); err != nil { + if errors.Is(err, io.EOF) { + break + } + return false, errors.New("invalid or incomplete scanner stream") + } + if item.SBOM != nil { + if coverage || item.SBOM.GoVersion != compiler || compiler == "" { + return false, errors.New("ambiguous or mismatched scanner coverage") + } + for _, module := range item.SBOM.Modules { + if module.Path == "github.com/dexidp/dex" { + coverage = true + } + } + if !coverage { + return false, errors.New("scanner did not examine Dex") + } + } + if item.Finding != nil { + f := item.Finding + if f.OSV == "" || len(f.Trace) == 0 { + return false, errors.New("incomplete finding") + } + if f.Trace[0].Function == "" { + continue + } // Module-only observations remain in the original report. + if f.OSV != "GO-2024-2476" || (f.Trace[0].Module != "github.com/dexidp/dex" && f.Trace[0].Module != "./api/v2") { + return false, errors.New("executable has an undisposed reachable advisory") + } + disposition = true + } + } + if !coverage { + return false, errors.New("scanner coverage is absent") + } + return disposition, nil +} + +// checkHistoricalAdvisory refuses a changed primary range instead of silently +// carrying a past disposition forward to different advisory metadata. +func checkHistoricalAdvisory(body []byte) error { + var doc struct { + ID string `json:"id"` + Affected []struct { + Package struct{ Name, Ecosystem string } `json:"package"` + Ranges []struct { + Type string `json:"type"` + Events []struct{ Introduced, Fixed string } `json:"events"` + } `json:"ranges"` + Specific struct { + Ranges []struct { + Type string `json:"type"` + Events []struct{ Introduced, Fixed string } `json:"events"` + } `json:"custom_ranges"` + } `json:"ecosystem_specific"` + } `json:"affected"` + } + if err := json.Unmarshal(body, &doc); err != nil { + return errors.New("unreadable primary advisory") + } + if doc.ID == "GO-2024-2476" && len(doc.Affected) == 1 { + a := doc.Affected[0] + if a.Package.Name == "github.com/dexidp/dex" && a.Package.Ecosystem == "Go" && len(a.Specific.Ranges) == 1 && len(a.Ranges) == 1 && a.Ranges[0].Type == "SEMVER" && len(a.Ranges[0].Events) == 1 && a.Ranges[0].Events[0].Introduced == "0" && a.Ranges[0].Events[0].Fixed == "" { + r := a.Specific.Ranges[0] + if r.Type == "ECOSYSTEM" && len(r.Events) == 2 && r.Events[0].Introduced == "2.37.0" && r.Events[1].Fixed == "2.38.0" { + return nil + } + } + } + return errors.New("historical advisory changed; review the disposition again") +} + +func nativeTLS(binary, web string) error { + return nativeTLSFloor(binary, web, "1.3") +} + +// nativeTLSFloor runs disposable SQLite-backed HTTPS/gRPC services. Only the +// negative control supplies a weaker floor; admission always supplies TLS 1.3. +func nativeTLSFloor(binary, web, floor string) (resultErr error) { + fixture, err := os.MkdirTemp("", "dex-tls-") + if err != nil { + return err + } + defer func() { + if resultErr != nil && resultErr.Error() != "Dex discarded its configured TLS floor" { + // This process only receives the fictional configuration below. Keep + // bounded failure diagnostics useful without retaining its private key. + if body, err := os.ReadFile(filepath.Join(fixture, "dex.log")); err == nil { + if len(body) > 4096 { + body = body[len(body)-4096:] + } + fmt.Fprintf(os.Stderr, "Fictional native fixture diagnostics: %q\n", body) + } + } + _ = os.RemoveAll(fixture) + }() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return err + } + template := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "local Dex fixture"}, + NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(10 * time.Minute), + DNSNames: []string{"localhost"}, KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, IsCA: true, BasicConstraintsValid: true} + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + return err + } + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + return err + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + // Dex watches its certificate directory. Keep transient SQLite journals + // outside that watch, matching separate certificate and data projections. + tlsDir, dbDir := filepath.Join(fixture, "tls"), filepath.Join(fixture, "database") + for _, directory := range []string{tlsDir, dbDir} { + if err := os.Mkdir(directory, 0700); err != nil { + return err + } + } + certPath, keyPath := filepath.Join(tlsDir, "cert.pem"), filepath.Join(tlsDir, "key.pem") + if err := os.WriteFile(certPath, certPEM, 0600); err != nil { + return err + } + if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0600); err != nil { + return err + } + listeners, err := reserveEndpoints(2) + if err != nil { + return err + } + defer func() { + for _, listener := range listeners { + _ = listener.Close() + } + }() + addresses := make([]string, len(listeners)) + for i, listener := range listeners { + addresses[i] = listener.Addr().String() + } + issuer := "https://" + addresses[0] + "/dex" + config := fmt.Sprintf(`issuer: %s +storage: + type: sqlite3 + config: + file: %s +enablePasswordDB: true +web: + https: %s + tlsCert: %s + tlsKey: %s + tlsMinVersion: %q +grpc: + addr: %s + tlsCert: %s + tlsKey: %s + tlsMinVersion: %q +frontend: + dir: %s +`, issuer, filepath.Join(dbDir, "dex.db"), addresses[0], certPath, keyPath, floor, addresses[1], certPath, keyPath, floor, web) + configPath := filepath.Join(fixture, "config.yaml") + if err := os.WriteFile(configPath, []byte(config), 0600); err != nil { + return err + } + log, err := os.Create(filepath.Join(fixture, "dex.log")) + if err != nil { + return err + } + defer log.Close() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, binary, "serve", configPath) + cmd.Stdout, cmd.Stderr = log, log + for _, listener := range listeners { + if err := listener.Close(); err != nil { + return err + } + } + if err := cmd.Start(); err != nil { + return err + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + defer func() { + _ = cmd.Process.Signal(syscall.SIGTERM) + select { + case <-done: + case <-time.After(2 * time.Second): + cancel() + <-done + } + }() + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(certPEM) { + return errors.New("fixture trust failed") + } + good := &tls.Config{RootCAs: pool, ServerName: "localhost", MinVersion: tls.VersionTLS13} + transport := &http.Transport{TLSClientConfig: good} + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, Timeout: time.Second} + ready := false + for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) { + resp, err := client.Get(issuer + "/.well-known/openid-configuration") + if err != nil { + continue + } + var document struct { + Issuer string `json:"issuer"` + } + err = json.NewDecoder(io.LimitReader(resp.Body, 65536)).Decode(&document) + resp.Body.Close() + if err == nil && resp.StatusCode == 200 && document.Issuer == issuer { + ready = true + break + } + } + if !ready { + return errors.New("real Dex discovery did not become ready with verified TLS") + } + for _, address := range addresses { + if err := exerciseTLS(address, good); err != nil { + return err + } + } + // Renew only the certificate, retaining the private key so every old/new + // read remains a matching keypair. A different issuer name and serial make + // the old and renewed trust pools distinct despite using the same key. + template.SerialNumber = big.NewInt(2) + template.Subject.CommonName = "local Dex renewed fixture" + renewedDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + return err + } + renewedPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: renewedDER}) + renewedPath := filepath.Join(fixture, "renewed.pem") + if err := os.WriteFile(renewedPath, renewedPEM, 0600); err != nil { + return err + } + if err := os.Rename(renewedPath, certPath); err != nil { + return err + } + if err := cmd.Process.Signal(syscall.SIGHUP); err != nil { + return err + } + renewedPool := x509.NewCertPool() + if !renewedPool.AppendCertsFromPEM(renewedPEM) { + return errors.New("renewed fixture trust failed") + } + renewedConfig := good.Clone() + renewedConfig.RootCAs = renewedPool + observed := false + for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) { + count := 0 + for _, address := range addresses { + conn, err := tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", address, renewedConfig.Clone()) + if err != nil { + continue + } + peers := conn.ConnectionState().PeerCertificates + if len(peers) > 0 && peers[0].SerialNumber.Cmp(big.NewInt(2)) == 0 { + count++ + } + _ = conn.Close() + } + if count == len(addresses) { + observed = true + break + } + } + if !observed { + return errors.New("renewed certificate was not observed on both endpoints") + } + for _, address := range addresses { + if err := exerciseTLS(address, renewedConfig); err != nil { + return err + } + conn, err := tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", address, good.Clone()) + if err == nil { + _ = conn.Close() + return errors.New("retired fixture trust still accepted the renewed certificate") + } + var unknown x509.UnknownAuthorityError + if !errors.As(err, &unknown) { + return errors.New("retired fixture trust failed for an unexpected reason") + } + } + return nil +} + +// reserveEndpoints keeps every selected port bound until the caller hands all +// endpoints to the native process, preventing self-reuse during selection. +func reserveEndpoints(count int) ([]net.Listener, error) { + var listeners []net.Listener + for i := 0; i < count; i++ { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + for _, reserved := range listeners { + _ = reserved.Close() + } + return nil, err + } + listeners = append(listeners, listener) + } + return listeners, nil +} + +// exerciseTLS brackets protocol/trust refusals with successful connections, so +// an unavailable service cannot satisfy a negative control. +func exerciseTLS(address string, good *tls.Config) error { + dial := func(config *tls.Config) error { + conn, err := tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", address, config) + if err == nil { + return conn.Close() + } + return err + } + if err := dial(good.Clone()); err != nil { + return errors.New("positive TLS 1.3 handshake failed") + } + low := good.Clone() + low.MinVersion, low.MaxVersion = tls.VersionTLS12, tls.VersionTLS12 + if err := dial(low); err == nil || !strings.Contains(err.Error(), "protocol version not supported") { + return errors.New("Dex discarded its configured TLS floor") + } + wrongCA := good.Clone() + wrongCA.RootCAs = x509.NewCertPool() + var unknown x509.UnknownAuthorityError + if err := dial(wrongCA); !errors.As(err, &unknown) { + return errors.New("wrong CA did not fail certificate validation") + } + wrongSAN := good.Clone() + wrongSAN.ServerName = "wrong.invalid" + var hostname x509.HostnameError + if err := dial(wrongSAN); !errors.As(err, &hostname) { + return errors.New("wrong hostname did not fail certificate validation") + } + if err := dial(good.Clone()); err != nil { + return errors.New("positive handshake after negative controls failed") + } + return nil +} diff --git a/scripts/check-dex-binary/main_test.go b/scripts/check-dex-binary/main_test.go new file mode 100644 index 0000000000..f63266363f --- /dev/null +++ b/scripts/check-dex-binary/main_test.go @@ -0,0 +1,105 @@ +package main + +import ( + "net" + "os" + "strings" + "testing" +) + +func TestNativeEndpointsStayReservedUntilHandoff(t *testing.T) { + listeners, err := reserveEndpoints(2) + if err != nil { + t.Fatal(err) + } + defer func() { + for _, listener := range listeners { + listener.Close() + } + }() + if len(listeners) != 2 || listeners[0].Addr().String() == listeners[1].Addr().String() { + t.Fatal("native HTTPS and gRPC endpoints must be distinct") + } + for _, listener := range listeners { + other, err := net.Listen("tcp", listener.Addr().String()) + if err == nil { + other.Close() + t.Fatal("endpoint released before native process handoff") + } + } +} + +func TestNativeTLSFloorControls(t *testing.T) { + binary, web := os.Getenv("DEX_NATIVE_BINARY"), os.Getenv("DEX_NATIVE_WEB") + if binary == "" || web == "" { + t.Skip("native release fixture runs in the Dex image workflow") + } + if err := nativeTLSFloor(binary, web, "1.2"); err == nil || err.Error() != "Dex discarded its configured TLS floor" { + t.Fatalf("weak-floor control must reach the actual protocol check, got %v", err) + } + if err := nativeTLSFloor(binary, web, "1.3"); err != nil { + t.Fatal(err) + } +} + +func TestNativeAdmissionRefusesAStaleCertificate(t *testing.T) { + binary, web := os.Getenv("DEX_STALE_CERTIFICATE_BINARY"), os.Getenv("DEX_NATIVE_WEB") + if binary == "" || web == "" { + t.Skip("native reloader mutation runs in the Dex image workflow") + } + if err := nativeTLSFloor(binary, web, "1.3"); err == nil || err.Error() != "renewed certificate was not observed on both endpoints" { + t.Fatalf("stale-certificate control must fail the actual renewal observation, got %v", err) + } +} + +func TestJudgeReportRefusesUnexaminedOrNewFindings(t *testing.T) { + const sbom = `{"SBOM":{"go_version":"go1.27.3","modules":[{"path":"github.com/dexidp/dex","version":"v0.0.0-20261021142137-75eb19238e10"}]}}` + const historical = `{"finding":{"osv":"GO-2024-2476","trace":[{"module":"github.com/dexidp/dex","function":"loadTLSConfig"}]}}` + for _, tc := range []struct { + name, report string + wantError, wantDisposition bool + }{ + {"empty is not a scan", "", true, false}, + {"progress is not a scan", `{"progress":{"message":"scanning"}}`, true, false}, + {"empty module coverage", `{"SBOM":{"go_version":"go1.27.3","modules":[]}}`, true, false}, + {"different executable", `{"SBOM":{"go_version":"go1.27.3","modules":[{"path":"example.com/other"}]}}`, true, false}, + {"missing compiler coverage", strings.ReplaceAll(sbom, `"go1.27.3"`, `""`), true, false}, + {"malformed suffix", sbom + "{", true, false}, + {"new reachable finding", sbom + `{"finding":{"osv":"GO-2099-0001","trace":[{"module":"stdlib","function":"Serve"}]}}`, true, false}, + {"missing finding identity", sbom + `{"finding":{"trace":[{"module":"stdlib","function":"Serve"}]}}`, true, false}, + {"same id in another module", sbom + strings.ReplaceAll(historical, "github.com/dexidp/dex", "example.com/other"), true, false}, + {"historical requires native disposition", sbom + historical, false, true}, + {"local api replacement requires same disposition", sbom + strings.ReplaceAll(historical, "github.com/dexidp/dex", "./api/v2"), false, true}, + {"module only remains recorded", sbom + `{"finding":{"osv":"GO-2099-0002","trace":[{"module":"example.com/unused"}]}}`, false, false}, + {"examined clean executable", sbom, false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + disposition, err := judgeReport(strings.NewReader(tc.report), "go1.27.3") + if (err != nil) != tc.wantError || disposition != tc.wantDisposition { + t.Fatalf("disposition=%v err=%v; want disposition=%v error=%v", disposition, err, tc.wantDisposition, tc.wantError) + } + }) + } +} + +func TestHistoricalDispositionRefusesChangedPrimaryAdvisory(t *testing.T) { + const advisory = `{"id":"GO-2024-2476","affected":[{"package":{"name":"github.com/dexidp/dex","ecosystem":"Go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"}]}],"ecosystem_specific":{"custom_ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"2.37.0"},{"fixed":"2.38.0"}]}]}}]}` + for _, tc := range []struct { + name, body string + wantError bool + }{ + {"primary fixed range", advisory, false}, + {"missing", `{}`, true}, + {"wrong advisory", strings.ReplaceAll(advisory, "GO-2024-2476", "GO-2099-0001"), true}, + {"wrong package", strings.ReplaceAll(advisory, "github.com/dexidp/dex", "example.com/other"), true}, + {"revised affected range", strings.ReplaceAll(advisory, "2.38.0", "2.47.0"), true}, + {"changed generic mapping", strings.ReplaceAll(advisory, `"introduced":"0"`, `"introduced":"2.46.0"`), true}, + {"no fixed range", strings.ReplaceAll(advisory, `{"fixed":"2.38.0"}`, `{}`), true}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := checkHistoricalAdvisory([]byte(tc.body)); (err != nil) != tc.wantError { + t.Fatalf("err=%v; want error=%v", err, tc.wantError) + } + }) + } +} diff --git a/scripts/check-dex-binary/workflow_test.go b/scripts/check-dex-binary/workflow_test.go new file mode 100644 index 0000000000..71f9e5a0cd --- /dev/null +++ b/scripts/check-dex-binary/workflow_test.go @@ -0,0 +1,124 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// TestPublicationSelectsBuildToolChanges prevents a successful PR build from +// hiding checker-module or signing-tool changes until an unrelated release. +func TestPublicationSelectsBuildToolChanges(t *testing.T) { + var workflow struct { + On map[string]struct { + Paths []string `yaml:"paths"` + } `yaml:"on"` + } + body, err := os.ReadFile(filepath.Join("..", "..", ".github", "workflows", "publish-dex-security-image.yaml")) + if err != nil { + t.Fatal(err) + } + if err := yaml.Unmarshal(body, &workflow); err != nil { + t.Fatal(err) + } + for _, event := range []string{"pull_request", "push"} { + for _, input := range []string{"go.mod", "go.sum", ".github/scripts/setup-supply-chain-tools.sh"} { + t.Run(event+"/"+input, func(t *testing.T) { + if !slices.Contains(workflow.On[event].Paths, input) { + t.Fatalf("%s does not select a build with changed input %s", event, input) + } + }) + } + } +} + +// TestPublisherRetainsTheProducingAttempt runs the real artifact-naming step +// and resolves its declared transfer across two different attempt contexts. +func TestPublisherRetainsTheProducingAttempt(t *testing.T) { + var workflow struct { + Jobs map[string]struct { + Outputs map[string]string `yaml:"outputs"` + Steps []struct { + ID, Uses, Run string + With map[string]string `yaml:"with"` + } `yaml:"steps"` + } `yaml:"jobs"` + } + body, err := os.ReadFile(filepath.Join("..", "..", ".github", "workflows", "publish-dex-security-image.yaml")) + if err != nil { + t.Fatal(err) + } + if err := yaml.Unmarshal(body, &workflow); err != nil { + t.Fatal(err) + } + for _, tc := range []struct{ name, producer, consumer, want string }{ + {"publish-only retry", "1", "2", "dex-image-101-1"}, + {"full verification retry", "2", "2", "dex-image-101-2"}, + } { + t.Run(tc.name, func(t *testing.T) { + producer := map[string]string{"github.run_id": "101", "github.run_attempt": tc.producer} + consumer := map[string]string{"github.run_id": "101", "github.run_attempt": tc.consumer} + verify := workflow.Jobs["verify"] + for _, step := range verify.Steps { + if step.ID != "artifact" { + continue + } + output := filepath.Join(t.TempDir(), "output") + command := exec.Command("bash", "-euo", "pipefail", "-c", step.Run) + command.Env = append(os.Environ(), "GITHUB_RUN_ID=101", "GITHUB_RUN_ATTEMPT="+tc.producer, "GITHUB_OUTPUT="+output) + if b, err := command.CombinedOutput(); err != nil { + t.Fatalf("artifact naming step: %v: %s", err, b) + } + b, err := os.ReadFile(output) + if err != nil { + t.Fatal(err) + } + for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") { + key, value, ok := strings.Cut(line, "=") + if !ok { + t.Fatal("invalid output from naming step") + } + producer["steps.artifact.outputs."+key] = value + } + } + if expression := verify.Outputs["artifact_name"]; expression != "" { + consumer["needs.verify.outputs.artifact_name"] = renderArtifactExpression(t, expression, producer) + } + uploaded, downloaded := "", "" + for _, step := range verify.Steps { + if strings.HasPrefix(step.Uses, "actions/upload-artifact@") { + uploaded = renderArtifactExpression(t, step.With["name"], producer) + } + } + for _, step := range workflow.Jobs["publish"].Steps { + if strings.HasPrefix(step.Uses, "actions/download-artifact@") { + downloaded = renderArtifactExpression(t, step.With["name"], consumer) + } + } + if uploaded != tc.want || downloaded != tc.want { + t.Fatalf("uploaded=%q downloaded=%q; both must retain %q", uploaded, downloaded, tc.want) + } + }) + } +} + +// Only the context references used by this artifact handoff are modeled. A new +// expression cannot silently resolve to an empty selector. +func renderArtifactExpression(t *testing.T, expression string, values map[string]string) string { + t.Helper() + pattern := regexp.MustCompile(`\$\{\{\s*([^{}]+?)\s*\}\}`) + return pattern.ReplaceAllStringFunc(expression, func(token string) string { + key := strings.TrimSpace(pattern.FindStringSubmatch(token)[1]) + value, ok := values[key] + if !ok { + t.Fatalf("unbound artifact reference %q", key) + } + return value + }) +}