From fb33fc307569b501d1e2eb317fee45f9a7ea237e Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Tue, 3 Nov 2026 09:02:03 +0100 Subject: [PATCH 1/4] fix(identity): publish a verified Dex security distribution --- .../workflows/publish-dex-security-image.yaml | 147 +++++++ .../controllers/dex/DexSecurity.Dockerfile | 11 + .../controllers/dex/IMAGE-NOTICE | 14 + .../infrastructure/controllers/dex/README.md | 33 ++ .../controllers/dex/dex-v2.46.1-network.patch | 14 + scripts/build-dex-security-image.sh | 74 ++++ scripts/check-dex-binary/main.go | 405 ++++++++++++++++++ scripts/check-dex-binary/main_test.go | 72 ++++ 8 files changed, 770 insertions(+) create mode 100644 .github/workflows/publish-dex-security-image.yaml create mode 100644 k8s/bases/infrastructure/controllers/dex/DexSecurity.Dockerfile create mode 100644 k8s/bases/infrastructure/controllers/dex/IMAGE-NOTICE create mode 100644 k8s/bases/infrastructure/controllers/dex/dex-v2.46.1-network.patch create mode 100644 scripts/build-dex-security-image.sh create mode 100644 scripts/check-dex-binary/main.go create mode 100644 scripts/check-dex-binary/main_test.go diff --git a/.github/workflows/publish-dex-security-image.yaml b/.github/workflows/publish-dex-security-image.yaml new file mode 100644 index 0000000000..d7667e6fdd --- /dev/null +++ b/.github/workflows/publish-dex-security-image.yaml @@ -0,0 +1,147 @@ +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/** + - 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/** + - 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 }} + 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: Build, scan and exercise exact distribution + id: build + run: bash scripts/build-dex-security-image.sh upstream "${IMAGE}:${IMAGE_TAG}" + - name: Retain the verified image without rebuilding + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: dex-image-${{ github.run_id }}-${{ github.run_attempt }} + 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 + packages: write + id-token: write + 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: dex-image-${{ github.run_id }}-${{ github.run_attempt }} + 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..6e5c432b44 --- /dev/null +++ b/k8s/bases/infrastructure/controllers/dex/IMAGE-NOTICE @@ -0,0 +1,14 @@ +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. 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..d608ff5c5f 100644 --- a/k8s/bases/infrastructure/controllers/dex/README.md +++ b/k8s/bases/infrastructure/controllers/dex/README.md @@ -5,6 +5,39 @@ 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 small runtime contains that executable, upstream web files, CA certificates +and license notices. 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 certificate reload; 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. + +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..b0e2e6f624 --- /dev/null +++ b/scripts/build-dex-security-image.sh @@ -0,0 +1,74 @@ +#!/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) ]] +mkdir "$source_dir/dist" +# Static libc preserves SQLite without adding a second dynamic runtime to the image. +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_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" +( + 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 ]] +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..ccdaa1ba12 --- /dev/null +++ b/scripts/check-dex-binary/main.go @@ -0,0 +1,405 @@ +// 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.") +} + +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 +} + +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 +} + +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 +} + +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") +} + +func nativeTLSFloor(binary, web, floor string) error { + fixture, err := os.MkdirTemp("", "dex-tls-") + if err != nil { + return err + } + defer 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}) + certPath, keyPath := filepath.Join(fixture, "cert.pem"), filepath.Join(fixture, "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 + } + addresses := make([]string, 2) + for i := range addresses { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return err + } + addresses[i] = l.Addr().String() + if err := l.Close(); err != nil { + return err + } + } + 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(fixture, "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 + 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 + } + } + if err := cmd.Process.Signal(syscall.SIGHUP); err != nil { + return err + } + // Repeated handshakes cover continued service and certificate-reloader operation. + for i := 0; i < 5; i++ { + time.Sleep(100 * time.Millisecond) + for _, address := range addresses { + if err := exerciseTLS(address, good); err != nil { + return err + } + } + } + return nil +} + +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..0d2960acd3 --- /dev/null +++ b/scripts/check-dex-binary/main_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +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 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) + } + }) + } +} From a4efba7c3861c47e870c6ef5c181558f6efba1d8 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Tue, 3 Nov 2026 09:15:27 +0100 Subject: [PATCH 2/4] test(identity): preserve native storage and TLS isolation --- .../workflows/publish-dex-security-image.yaml | 4 ++ .../controllers/dex/IMAGE-NOTICE | 6 +- .../infrastructure/controllers/dex/README.md | 5 +- scripts/build-dex-security-image.sh | 17 ++++- scripts/check-dex-binary/main.go | 68 +++++++++++++++---- scripts/check-dex-binary/main_test.go | 23 +++++++ 6 files changed, 105 insertions(+), 18 deletions(-) diff --git a/.github/workflows/publish-dex-security-image.yaml b/.github/workflows/publish-dex-security-image.yaml index d7667e6fdd..5d7b3ae4b4 100644 --- a/.github/workflows/publish-dex-security-image.yaml +++ b/.github/workflows/publish-dex-security-image.yaml @@ -64,6 +64,10 @@ jobs: 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}" diff --git a/k8s/bases/infrastructure/controllers/dex/IMAGE-NOTICE b/k8s/bases/infrastructure/controllers/dex/IMAGE-NOTICE index 6e5c432b44..492cb1dfa3 100644 --- a/k8s/bases/infrastructure/controllers/dex/IMAGE-NOTICE +++ b/k8s/bases/infrastructure/controllers/dex/IMAGE-NOTICE @@ -10,5 +10,9 @@ 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. The runtime base retains its own notices. +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 d608ff5c5f..398462eccf 100644 --- a/k8s/bases/infrastructure/controllers/dex/README.md +++ b/k8s/bases/infrastructure/controllers/dex/README.md @@ -12,8 +12,9 @@ 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 small runtime contains that executable, upstream web files, CA certificates -and license notices. It accepts native Dex configuration and launches directly; +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. diff --git a/scripts/build-dex-security-image.sh b/scripts/build-dex-security-image.sh index b0e2e6f624..b2ea420292 100644 --- a/scripts/build-dex-security-image.sh +++ b/scripts/build-dex-security-image.sh @@ -23,8 +23,9 @@ CGO_ENABLED=1 go -C "$source_dir" test -mod=readonly -timeout=5m ./cmd/dex ./ser git -C "$source_dir" diff --exit-code [[ -z $(git -C "$source_dir" ls-files --others --exclude-standard) ]] mkdir "$source_dir/dist" -# Static libc preserves SQLite without adding a second dynamic runtime to the image. -CGO_ENABLED=1 go -C "$source_dir" build -mod=readonly -trimpath -tags netgo,osusergo \ +# 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' @@ -43,12 +44,24 @@ 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" \ diff --git a/scripts/check-dex-binary/main.go b/scripts/check-dex-binary/main.go index ccdaa1ba12..e686a58e06 100644 --- a/scripts/check-dex-binary/main.go +++ b/scripts/check-dex-binary/main.go @@ -235,12 +235,24 @@ func nativeTLS(binary, web string) error { return nativeTLSFloor(binary, web, "1.3") } -func nativeTLSFloor(binary, web, floor string) error { +func nativeTLSFloor(binary, web, floor string) (resultErr error) { fixture, err := os.MkdirTemp("", "dex-tls-") if err != nil { return err } - defer os.RemoveAll(fixture) + 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 @@ -258,23 +270,33 @@ func nativeTLSFloor(binary, web, floor string) error { return err } certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) - certPath, keyPath := filepath.Join(fixture, "cert.pem"), filepath.Join(fixture, "key.pem") + // 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 } - addresses := make([]string, 2) - for i := range addresses { - l, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - return err - } - addresses[i] = l.Addr().String() - if err := l.Close(); 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 @@ -295,7 +317,7 @@ grpc: tlsMinVersion: %q frontend: dir: %s -`, issuer, filepath.Join(fixture, "dex.db"), addresses[0], certPath, keyPath, floor, addresses[1], certPath, keyPath, floor, web) +`, 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 @@ -309,6 +331,11 @@ frontend: 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 } @@ -370,6 +397,21 @@ frontend: return nil } +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 +} + 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) diff --git a/scripts/check-dex-binary/main_test.go b/scripts/check-dex-binary/main_test.go index 0d2960acd3..d511e8ee41 100644 --- a/scripts/check-dex-binary/main_test.go +++ b/scripts/check-dex-binary/main_test.go @@ -1,11 +1,34 @@ 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 == "" { From a4b6dec7c1cc2719e9fe2306330c05d618f80988 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Tue, 3 Nov 2026 10:30:35 +0100 Subject: [PATCH 3/4] fix(identity): retain verified attempts and observe certificate renewal --- .../workflows/publish-dex-security-image.yaml | 8 +- .../infrastructure/controllers/dex/README.md | 8 +- scripts/build-dex-security-image.sh | 15 ++- scripts/check-dex-binary/main.go | 73 +++++++++++++- scripts/check-dex-binary/main_test.go | 10 ++ scripts/check-dex-binary/workflow_test.go | 97 +++++++++++++++++++ 6 files changed, 202 insertions(+), 9 deletions(-) create mode 100644 scripts/check-dex-binary/workflow_test.go diff --git a/.github/workflows/publish-dex-security-image.yaml b/.github/workflows/publish-dex-security-image.yaml index 5d7b3ae4b4..b30c7d8d83 100644 --- a/.github/workflows/publish-dex-security-image.yaml +++ b/.github/workflows/publish-dex-security-image.yaml @@ -42,6 +42,7 @@ jobs: 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 @@ -71,10 +72,13 @@ jobs: - 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: dex-image-${{ github.run_id }}-${{ github.run_attempt }} + name: ${{ steps.artifact.outputs.name }} path: ${{ runner.temp }}/dex-image.tar if-no-files-found: error retention-days: 7 @@ -102,7 +106,7 @@ jobs: - name: Download this run's verified image uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: dex-image-${{ github.run_id }}-${{ github.run_attempt }} + name: ${{ needs.verify.outputs.artifact_name }} path: ${{ runner.temp }}/dex-verified - name: Check archive and native image identity env: diff --git a/k8s/bases/infrastructure/controllers/dex/README.md b/k8s/bases/infrastructure/controllers/dex/README.md index 398462eccf..1768be7695 100644 --- a/k8s/bases/infrastructure/controllers/dex/README.md +++ b/k8s/bases/infrastructure/controllers/dex/README.md @@ -23,11 +23,17 @@ 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 certificate reload; TLS 1.2, a wrong CA and a wrong hostname must +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 diff --git a/scripts/build-dex-security-image.sh b/scripts/build-dex-security-image.sh index b2ea420292..15e5f8d83b 100644 --- a/scripts/build-dex-security-image.sh +++ b/scripts/build-dex-security-image.sh @@ -22,6 +22,19 @@ 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. @@ -33,7 +46,7 @@ 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_NATIVE_WEB="$source_dir/web" \ +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" diff --git a/scripts/check-dex-binary/main.go b/scripts/check-dex-binary/main.go index e686a58e06..8b3571174f 100644 --- a/scripts/check-dex-binary/main.go +++ b/scripts/check-dex-binary/main.go @@ -45,6 +45,8 @@ func main() { 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") @@ -129,6 +131,7 @@ func run(binary, scanner, advisory, web string) error { 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 { @@ -144,6 +147,8 @@ func binaryDigest(path string) ([32]byte, error) { 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 @@ -199,6 +204,8 @@ func judgeReport(r io.Reader, compiler string) (bool, error) { 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"` @@ -235,6 +242,8 @@ 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 { @@ -382,21 +391,73 @@ frontend: 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 } - // Repeated handshakes cover continued service and certificate-reloader operation. - for i := 0; i < 5; i++ { - time.Sleep(100 * time.Millisecond) + 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 { - if err := exerciseTLS(address, good); err != nil { - return err + 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++ { @@ -412,6 +473,8 @@ func reserveEndpoints(count int) ([]net.Listener, error) { 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) diff --git a/scripts/check-dex-binary/main_test.go b/scripts/check-dex-binary/main_test.go index d511e8ee41..f63266363f 100644 --- a/scripts/check-dex-binary/main_test.go +++ b/scripts/check-dex-binary/main_test.go @@ -42,6 +42,16 @@ func TestNativeTLSFloorControls(t *testing.T) { } } +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"}]}}` diff --git a/scripts/check-dex-binary/workflow_test.go b/scripts/check-dex-binary/workflow_test.go new file mode 100644 index 0000000000..8f5e47ba8a --- /dev/null +++ b/scripts/check-dex-binary/workflow_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// 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 + }) +} From c302aca23a59efe187018bcfd5f7774baa50fca1 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Tue, 3 Nov 2026 12:23:16 +0100 Subject: [PATCH 4/4] fix(ci): verify Dex build-tool changes before publication --- .../workflows/publish-dex-security-image.yaml | 12 ++++++--- scripts/check-dex-binary/workflow_test.go | 27 +++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-dex-security-image.yaml b/.github/workflows/publish-dex-security-image.yaml index b30c7d8d83..7a7615f4b0 100644 --- a/.github/workflows/publish-dex-security-image.yaml +++ b/.github/workflows/publish-dex-security-image.yaml @@ -7,6 +7,9 @@ on: - .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 @@ -16,6 +19,9 @@ on: - .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 @@ -91,9 +97,9 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: - contents: read - packages: write - id-token: write + 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 diff --git a/scripts/check-dex-binary/workflow_test.go b/scripts/check-dex-binary/workflow_test.go index 8f5e47ba8a..71f9e5a0cd 100644 --- a/scripts/check-dex-binary/workflow_test.go +++ b/scripts/check-dex-binary/workflow_test.go @@ -5,12 +5,39 @@ import ( "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) {