From 891ae11904934380288c3fe27ab6d9cb7b190c60 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Wed, 13 May 2026 19:24:07 +0400 Subject: [PATCH 01/15] feat(ci): sign + SBOM + SLSA L3 provenance for images and tarballs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the supply-chain hardening pass: every published artifact (Docker images + sc CLI tarballs) gets a cosign keyless signature, a CycloneDX SBOM attestation, and a SLSA build provenance attestation. A post-publish verification job re-fetches the artifacts and runs cosign verify + slsa-verifier against them before the workflow reports release-success. What changes: * New composite action `.github/actions/install-attest-tools` installs cosign, syft, and slsa-verifier from a single SHA-pinned source — one place to bump, three workflows consume it. * `push.yaml` `docker-build` matrix: - Per-job `id-token: write` + `attestations: write` (root stays read). - `docker buildx build` loop replaced with `docker/build-push-action` so we capture the pushed digest as a step output. - Soft-fail (`continue-on-error: true`) sign + attest steps after publish: SBOM via syft, keyless sign + SBOM-attest via cosign, SLSA L3 provenance via `actions/attest-build-provenance`. - Trailing aggregator emits `::warning::` if any attestation step failed without blocking the run. * `push.yaml` `build-platforms` matrix: - Same per-job permissions. - Per-tarball sidecars generated in `.sc/stacks/dist/bundle/`: `.sha256`, `.sbom.cdx.json`, `.cosign-bundle`, `.intoto.jsonl`. - `upload-artifact` path widened to capture all sidecars. * `push.yaml` `docker-finalize`: - Artifact copy step enumerates every sidecar kind explicitly instead of `*.tar.gz` only. - New sidecar-count assertion records partial Welder uploads as warnings (Phase 2 bake-in policy). * `push.yaml` new `verify-attestations` job (`needs: docker-finalize`): - Re-fetches every published image + tarball with sidecars from `dist.simple-container.com` / Docker Hub. - Runs `cosign verify` + `cosign verify-blob` + `slsa-verifier verify-image` / `verify-artifact` against the *production* identity regex (push.yaml@refs/heads/main) only. - Aggregator job fails on missing/invalid attestations so the Telegram notifier surfaces it; per-step `continue-on-error: true` keeps individual sigstore-public flakes from gating releases. * `build-staging.yml` `build-staging` job: - Same attestation pipeline applied to `:staging` images. - Staging images sign with their *own* OIDC identity (`build-staging.yml@refs/heads/staging`), separate from prod. * `SECURITY.md`: - Split identity-regex contract: production trust root vs staging trust root, documented for consumer-side verification. - Image + tarball verification command examples. - Composite-action consumer guidance: SHA-pin the underlying Docker image, not just the action ref (mutable-tag pull is the V5 residual risk). - CDN-rollback residual risk + the sc.sh manifest-pinning mitigation landing in the follow-up PR (Phase 2 PR 2c). Soft-fail policy: publish is the gating job; sign + attest run after publish with `continue-on-error: true` so a sigstore-public outage does not block releases. After 14 days of clean post-publish verification, flip per-step `continue-on-error` to `false`. Identity-regex contract is the consumer-facing API; see SECURITY.md for the canonical regex. Renames to either `push.yaml` or `build-staging.yml` must bump the regex in the same PR. Refs: HARDENING.md "Phase 2 — Plan" + SECURITY-CONTROLS.md §3 control matrix. Follow-up PR (2c) rewrites `sc.sh` to verify the cosign bundle before extracting the tarball. Signed-off-by: Dmitrii Creed --- .../actions/install-attest-tools/action.yml | 70 ++++ .github/workflows/build-staging.yml | 151 ++++++-- .github/workflows/push.yaml | 364 +++++++++++++++++- SECURITY.md | 101 +++++ 4 files changed, 643 insertions(+), 43 deletions(-) create mode 100644 .github/actions/install-attest-tools/action.yml diff --git a/.github/actions/install-attest-tools/action.yml b/.github/actions/install-attest-tools/action.yml new file mode 100644 index 00000000..d175a555 --- /dev/null +++ b/.github/actions/install-attest-tools/action.yml @@ -0,0 +1,70 @@ +name: 'Install attestation tools' +description: 'Install cosign, syft, and slsa-verifier with single-source SHA pins for use across the SC release pipeline' + +inputs: + cosign-version: + description: 'Cosign CLI release tag to install (e.g. v2.4.1)' + required: false + default: 'v2.4.1' + syft-version: + description: 'Syft CLI release tag to install (e.g. v1.16.0)' + required: false + default: 'v1.16.0' + slsa-verifier-version: + description: 'slsa-verifier CLI release tag to install (e.g. v2.7.1)' + required: false + default: 'v2.7.1' + +runs: + using: 'composite' + steps: + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + with: + cosign-release: ${{ inputs.cosign-version }} + + - name: Install syft + uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + syft-version: ${{ inputs.syft-version }} + + - name: Install slsa-verifier + shell: bash + env: + SLSA_VERIFIER_VERSION: ${{ inputs.slsa-verifier-version }} + run: | + set -euo pipefail + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + arch_raw="$(uname -m)" + case "$arch_raw" in + x86_64|amd64) arch=amd64 ;; + aarch64|arm64) arch=arm64 ;; + *) echo "unsupported arch: $arch_raw" >&2; exit 1 ;; + esac + + bin_url="https://github.com/slsa-framework/slsa-verifier/releases/download/${SLSA_VERIFIER_VERSION}/slsa-verifier-${os}-${arch}" + sum_url="https://github.com/slsa-framework/slsa-verifier/releases/download/${SLSA_VERIFIER_VERSION}/SHA256SUM.md" + + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' EXIT + + curl -fsSL -o "$tmpdir/slsa-verifier" "$bin_url" + curl -fsSL -o "$tmpdir/SHA256SUM.md" "$sum_url" + + expected="$(grep -E "slsa-verifier-${os}-${arch}\b" "$tmpdir/SHA256SUM.md" | awk '{print $1}' | head -n1)" + if [ -z "$expected" ]; then + echo "could not locate expected SHA256 for slsa-verifier-${os}-${arch} in release SHA256SUM.md" >&2 + exit 1 + fi + + actual="$(sha256sum "$tmpdir/slsa-verifier" | awk '{print $1}')" + if [ "$expected" != "$actual" ]; then + echo "slsa-verifier checksum mismatch (expected $expected, got $actual)" >&2 + exit 1 + fi + + install_dir="$HOME/.local/bin" + mkdir -p "$install_dir" + install -m 0755 "$tmpdir/slsa-verifier" "$install_dir/slsa-verifier" + echo "$install_dir" >> "$GITHUB_PATH" + "$install_dir/slsa-verifier" version diff --git a/.github/workflows/build-staging.yml b/.github/workflows/build-staging.yml index fe0f81ad..63b5ee63 100644 --- a/.github/workflows/build-staging.yml +++ b/.github/workflows/build-staging.yml @@ -10,6 +10,10 @@ jobs: build-staging: name: Build Staging Image runs-on: blacksmith-8vcpu-ubuntu-2204 + permissions: + contents: read + id-token: write # OIDC for keyless cosign + attest-build-provenance + attestations: write outputs: cicd-bot-telegram-token: ${{ steps.prepare-additional-secrets.outputs.cicd-bot-telegram-token }} cicd-bot-telegram-chat-id: ${{ steps.prepare-additional-secrets.outputs.cicd-bot-telegram-chat-id }} @@ -77,37 +81,132 @@ jobs: run: | sc stack secret-get -s dist dockerhub-cicd-token | docker login --username simplecontainer --password-stdin - - name: Build and push Docker image with BuildKit caching + - name: Build and push github-actions staging image + id: build_gha_staging + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: . + file: github-actions-staging.Dockerfile + platforms: linux/amd64 + tags: | + simplecontainer/github-actions:staging + simplecontainer/github-actions:${{ github.ref_name }} + push: true + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: false + + - name: Build and push caddy staging image + id: build_caddy_staging + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: . + file: caddy.Dockerfile + platforms: linux/amd64 + tags: | + simplecontainer/caddy:staging + simplecontainer/caddy:${{ env.VERSION }} + push: true + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: false + + # Phase 2: sign + SBOM + SLSA provenance for staging images. Staging has + # its OWN trust root (build-staging.yml@refs/heads/staging) — consumers + # who opt in to `:staging` images verify against the staging identity + # regex, not the production one (push.yaml@refs/heads/main). See + # SECURITY.md "Identity regex contract". + - name: Install attestation tools + if: steps.build_gha_staging.outcome == 'success' || steps.build_caddy_staging.outcome == 'success' + uses: ./.github/actions/install-attest-tools + + - name: Generate CycloneDX SBOM for github-actions staging + id: sbom_gha_staging + if: steps.build_gha_staging.outcome == 'success' + continue-on-error: true env: - DOCKER_BUILDKIT: 1 - REF_NAME: ${{ github.ref_name }} + IMAGE_REF: simplecontainer/github-actions@${{ steps.build_gha_staging.outputs.digest }} run: | - # Build and push with advanced caching using BuildKit - docker buildx build \ - --platform linux/amd64 \ - --cache-from type=gha \ - --cache-to type=gha,mode=max \ - --file github-actions-staging.Dockerfile \ - --tag simplecontainer/github-actions:staging \ - --tag "simplecontainer/github-actions:$REF_NAME" \ - --push \ - . + set -euo pipefail + syft scan "registry:${IMAGE_REF}" -o "cyclonedx-json=sbom-github-actions-staging.cdx.json" - - name: Build and push caddy staging image + - name: Generate CycloneDX SBOM for caddy staging + id: sbom_caddy_staging + if: steps.build_caddy_staging.outcome == 'success' + continue-on-error: true + env: + IMAGE_REF: simplecontainer/caddy@${{ steps.build_caddy_staging.outputs.digest }} + run: | + set -euo pipefail + syft scan "registry:${IMAGE_REF}" -o "cyclonedx-json=sbom-caddy-staging.cdx.json" + + - name: Cosign sign + attest staging images (keyless) + id: cosign_staging + if: steps.build_gha_staging.outcome == 'success' || steps.build_caddy_staging.outcome == 'success' + continue-on-error: true + env: + COSIGN_EXPERIMENTAL: "1" + GHA_REF: simplecontainer/github-actions@${{ steps.build_gha_staging.outputs.digest }} + CADDY_REF: simplecontainer/caddy@${{ steps.build_caddy_staging.outputs.digest }} + GHA_OUTCOME: ${{ steps.build_gha_staging.outcome }} + CADDY_OUTCOME: ${{ steps.build_caddy_staging.outcome }} + GHA_SBOM_OUTCOME: ${{ steps.sbom_gha_staging.outcome }} + CADDY_SBOM_OUTCOME: ${{ steps.sbom_caddy_staging.outcome }} + run: | + set -uo pipefail + rc=0 + if [ "$GHA_OUTCOME" = "success" ]; then + cosign sign --yes "$GHA_REF" || rc=1 + if [ "$GHA_SBOM_OUTCOME" = "success" ]; then + cosign attest --yes --predicate sbom-github-actions-staging.cdx.json --type cyclonedx "$GHA_REF" || rc=1 + fi + fi + if [ "$CADDY_OUTCOME" = "success" ]; then + cosign sign --yes "$CADDY_REF" || rc=1 + if [ "$CADDY_SBOM_OUTCOME" = "success" ]; then + cosign attest --yes --predicate sbom-caddy-staging.cdx.json --type cyclonedx "$CADDY_REF" || rc=1 + fi + fi + exit "$rc" + + - name: SLSA build provenance for github-actions staging + id: slsa_gha_staging + if: steps.build_gha_staging.outcome == 'success' + continue-on-error: true + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-name: index.docker.io/simplecontainer/github-actions + subject-digest: ${{ steps.build_gha_staging.outputs.digest }} + push-to-registry: true + + - name: SLSA build provenance for caddy staging + id: slsa_caddy_staging + if: steps.build_caddy_staging.outcome == 'success' + continue-on-error: true + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-name: index.docker.io/simplecontainer/caddy + subject-digest: ${{ steps.build_caddy_staging.outputs.digest }} + push-to-registry: true + + - name: Soft-fail aggregator for staging attestation + if: always() env: - DOCKER_BUILDKIT: 1 + GHA_SBOM_OUTCOME: ${{ steps.sbom_gha_staging.outcome }} + CADDY_SBOM_OUTCOME: ${{ steps.sbom_caddy_staging.outcome }} + COSIGN_OUTCOME: ${{ steps.cosign_staging.outcome }} + GHA_SLSA_OUTCOME: ${{ steps.slsa_gha_staging.outcome }} + CADDY_SLSA_OUTCOME: ${{ steps.slsa_caddy_staging.outcome }} run: | - # Build and push caddy staging image with BuildKit caching - # Tag with both 'staging' and the full VERSION (e.g., staging-219-20260220-093856) - docker buildx build \ - --platform linux/amd64 \ - --cache-from type=gha \ - --cache-to type=gha,mode=max \ - --file caddy.Dockerfile \ - --tag simplecontainer/caddy:staging \ - --tag simplecontainer/caddy:$VERSION \ - --push \ - . + fail=0 + for v in "$GHA_SBOM_OUTCOME" "$CADDY_SBOM_OUTCOME" "$COSIGN_OUTCOME" "$GHA_SLSA_OUTCOME" "$CADDY_SLSA_OUTCOME"; do + if [ "$v" != "success" ] && [ "$v" != "skipped" ]; then fail=1; fi + done + if [ "$fail" -eq 1 ]; then + echo "::warning title=Staging attestation incomplete::sbom-gha=$GHA_SBOM_OUTCOME sbom-caddy=$CADDY_SBOM_OUTCOME cosign=$COSIGN_OUTCOME slsa-gha=$GHA_SLSA_OUTCOME slsa-caddy=$CADDY_SLSA_OUTCOME" + else + echo "Staging attestation complete." + fi - name: Image built successfully env: diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index 8016ae72..22fdc0a3 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -124,6 +124,10 @@ jobs: name: Build sc for ${{ matrix.os }}/${{ matrix.arch }} runs-on: blacksmith-8vcpu-ubuntu-2204 needs: [prepare, build-setup] + permissions: + contents: read + id-token: write # OIDC for keyless cosign + attest-build-provenance + attestations: write strategy: matrix: include: @@ -157,6 +161,94 @@ jobs: go build -ldflags "-s -w -X=github.com/simple-container-com/api/internal/build.Version=${VERSION}" -o dist/${GOOS}-${GOARCH}/sc${EXT} ./cmd/sc tar -czf .sc/stacks/dist/bundle/sc-${GOOS}-${GOARCH}.tar.gz -C dist/${GOOS}-${GOARCH} sc${EXT} cp .sc/stacks/dist/bundle/sc-${GOOS}-${GOARCH}.tar.gz .sc/stacks/dist/bundle/sc-${GOOS}-${GOARCH}-v${VERSION}.tar.gz + # Phase 2: per-tarball SHA-256, CycloneDX SBOM, cosign keyless signature + # (self-contained bundle), SLSA provenance. Soft-fail per step — the tarball + # itself is the publish artifact; sidecars are best-effort during the 14-day + # bake-in. See HARDENING.md Phase 2 plan + verify-attestations.yml gate. + - name: SHA-256 sidecars for ${{ matrix.os }}/${{ matrix.arch }} + id: sha256 + env: + GOOS: ${{ matrix.os }} + GOARCH: ${{ matrix.arch }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + set -euo pipefail + cd .sc/stacks/dist/bundle + sha256sum "sc-${GOOS}-${GOARCH}.tar.gz" > "sc-${GOOS}-${GOARCH}.tar.gz.sha256" + sha256sum "sc-${GOOS}-${GOARCH}-v${VERSION}.tar.gz" > "sc-${GOOS}-${GOARCH}-v${VERSION}.tar.gz.sha256" + - name: Install attestation tools + uses: ./.github/actions/install-attest-tools + - name: Generate CycloneDX SBOM for sc-${{ matrix.os }}-${{ matrix.arch }} + id: sbom_tarball + continue-on-error: true + env: + GOOS: ${{ matrix.os }} + GOARCH: ${{ matrix.arch }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + set -euo pipefail + cd .sc/stacks/dist/bundle + syft scan "dir:../../../../dist/${GOOS}-${GOARCH}" \ + -o "cyclonedx-json=sc-${GOOS}-${GOARCH}-v${VERSION}.sbom.cdx.json" + cp "sc-${GOOS}-${GOARCH}-v${VERSION}.sbom.cdx.json" \ + "sc-${GOOS}-${GOARCH}.sbom.cdx.json" + - name: Cosign sign-blob (keyless, bundle) for sc-${{ matrix.os }}-${{ matrix.arch }} + id: cosign_sign_tarball + continue-on-error: true + env: + COSIGN_EXPERIMENTAL: "1" + GOOS: ${{ matrix.os }} + GOARCH: ${{ matrix.arch }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + set -euo pipefail + cd .sc/stacks/dist/bundle + for tarball in "sc-${GOOS}-${GOARCH}-v${VERSION}.tar.gz" "sc-${GOOS}-${GOARCH}.tar.gz"; do + cosign sign-blob --yes \ + --bundle "${tarball}.cosign-bundle" \ + "${tarball}" + done + - name: SLSA build provenance for sc-${{ matrix.os }}-${{ matrix.arch }} + id: slsa_tarball + continue-on-error: true + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: | + .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}-v${{ needs.prepare.outputs.version }}.tar.gz + .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}.tar.gz + - name: Materialize SLSA provenance bundle next to tarballs + if: steps.slsa_tarball.outcome == 'success' + continue-on-error: true + env: + BUNDLE_PATH: ${{ steps.slsa_tarball.outputs.bundle-path }} + GOOS: ${{ matrix.os }} + GOARCH: ${{ matrix.arch }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + set -euo pipefail + dest_dir=".sc/stacks/dist/bundle" + # attest-build-provenance emits a single bundle covering all subjects; + # publish it under both tarball names so consumers can locate it via + # the same prefix as the tarball they downloaded. + for tarball in "sc-${GOOS}-${GOARCH}-v${VERSION}.tar.gz" "sc-${GOOS}-${GOARCH}.tar.gz"; do + cp "$BUNDLE_PATH" "${dest_dir}/${tarball}.intoto.jsonl" + done + - name: Soft-fail aggregator for sc-${{ matrix.os }}-${{ matrix.arch }} attestation + if: always() + env: + SBOM_OUTCOME: ${{ steps.sbom_tarball.outcome }} + SIGN_OUTCOME: ${{ steps.cosign_sign_tarball.outcome }} + SLSA_OUTCOME: ${{ steps.slsa_tarball.outcome }} + run: | + fail=0 + for v in "$SBOM_OUTCOME" "$SIGN_OUTCOME" "$SLSA_OUTCOME"; do + if [ "$v" != "success" ]; then fail=1; fi + done + if [ "$fail" -eq 1 ]; then + echo "::warning title=Attestation incomplete::sc-${{ matrix.os }}-${{ matrix.arch }} tarball published but one or more attestation steps failed (sbom=$SBOM_OUTCOME sign=$SIGN_OUTCOME slsa=$SLSA_OUTCOME)." + else + echo "All tarball attestation steps succeeded for ${{ matrix.os }}/${{ matrix.arch }}." + fi - name: upload build artifacts uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: @@ -167,7 +259,13 @@ jobs: # artifacts and therefore never re-uploaded to dist.simple-container.com. Result: # sc.sh (which downloads the unversioned tarball when SIMPLE_CONTAINER_VERSION is # empty) served stale v2026.3.6 for ~4 weeks after PR #186 merged. - path: .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}*.tar.gz + # Phase 2: also captures .sha256, .sbom.cdx.json, .cosign-bundle, .intoto.jsonl sidecars. + path: | + .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}*.tar.gz + .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}*.tar.gz.sha256 + .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}*.tar.gz.cosign-bundle + .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}*.tar.gz.intoto.jsonl + .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}*.sbom.cdx.json retention-days: 1 build-binaries: @@ -276,29 +374,38 @@ jobs: name: Docker build and push ${{ matrix.image }} runs-on: blacksmith-8vcpu-ubuntu-2204 needs: [prepare, build-setup, build-platforms, build-binaries, build-github-actions-staging, test, build-docs] + permissions: + contents: read + id-token: write # OIDC token for keyless cosign + attest-build-provenance + attestations: write # required by actions/attest-build-provenance strategy: matrix: include: - image: kubectl dockerfile: kubectl.Dockerfile + image_repo: simplecontainer/kubectl tags: | simplecontainer/kubectl:latest simplecontainer/kubectl:${{ needs.prepare.outputs.version }} - image: caddy dockerfile: caddy.Dockerfile + image_repo: simplecontainer/caddy tags: | simplecontainer/caddy:latest simplecontainer/caddy:${{ needs.prepare.outputs.version }} - image: github-actions dockerfile: github-actions.Dockerfile + image_repo: simplecontainer/github-actions tags: | simplecontainer/github-actions:latest simplecontainer/github-actions:${{ needs.prepare.outputs.version }} - image: github-actions-staging dockerfile: github-actions-staging.Dockerfile + image_repo: simplecontainer/github-actions tags: simplecontainer/github-actions:staging - image: cloud-helpers-aws dockerfile: cloud-helpers.aws.Dockerfile + image_repo: simplecontainer/cloud-helpers tags: | simplecontainer/cloud-helpers:aws-latest simplecontainer/cloud-helpers:aws-${{ needs.prepare.outputs.version }} @@ -344,24 +451,83 @@ jobs: run: | sc stack secret-get -s dist dockerhub-cicd-token | docker login --username simplecontainer --password-stdin - name: Build and push ${{ matrix.image }} image + id: build_and_push + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: . + file: ${{ matrix.dockerfile }} + platforms: linux/amd64 + tags: ${{ matrix.tags }} + push: true + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: false + # Phase 2 attestation: keyless cosign sign + CycloneDX SBOM + SLSA L3 + # provenance. The publish step above is the gating job. Sign/attest steps + # below are continue-on-error so a sigstore-public outage does not block + # releases. A trailing aggregator emits ::warning:: on any failure. After + # 14 days of clean post-publish verification (see verify-attestations), + # flip continue-on-error to false (tracked in HARDENING.md Phase 2 plan). + - name: Install attestation tools + if: steps.build_and_push.outcome == 'success' + uses: ./.github/actions/install-attest-tools + - name: Generate CycloneDX SBOM for ${{ matrix.image }} + id: sbom + if: steps.build_and_push.outcome == 'success' + continue-on-error: true env: - DOCKER_BUILDKIT: 1 - VERSION: ${{ needs.prepare.outputs.version }} + IMAGE_REF: ${{ matrix.image_repo }}@${{ steps.build_and_push.outputs.digest }} run: | - mapfile -t tags <<< "${{ matrix.tags }}" - for tag in "${tags[@]}"; do - # Skip empty tags caused by trailing newlines in multi-line YAML - if [ -n "$tag" ]; then - docker buildx build \ - --platform linux/amd64 \ - --cache-from type=gha \ - --cache-to type=gha,mode=max \ - --file ${{ matrix.dockerfile }} \ - --tag "$tag" \ - --push \ - . - fi + set -euo pipefail + syft scan "registry:${IMAGE_REF}" -o "cyclonedx-json=sbom-${{ matrix.image }}.cdx.json" + ls -lh "sbom-${{ matrix.image }}.cdx.json" + - name: Cosign sign ${{ matrix.image }} (keyless) + id: cosign_sign + if: steps.build_and_push.outcome == 'success' + continue-on-error: true + env: + COSIGN_EXPERIMENTAL: "1" + IMAGE_REF: ${{ matrix.image_repo }}@${{ steps.build_and_push.outputs.digest }} + run: | + cosign sign --yes "${IMAGE_REF}" + - name: Cosign attest SBOM for ${{ matrix.image }} + id: cosign_attest_sbom + if: steps.sbom.outcome == 'success' && steps.cosign_sign.outcome == 'success' + continue-on-error: true + env: + COSIGN_EXPERIMENTAL: "1" + IMAGE_REF: ${{ matrix.image_repo }}@${{ steps.build_and_push.outputs.digest }} + run: | + cosign attest --yes \ + --predicate "sbom-${{ matrix.image }}.cdx.json" \ + --type cyclonedx \ + "${IMAGE_REF}" + - name: SLSA build provenance for ${{ matrix.image }} + id: slsa_provenance + if: steps.build_and_push.outcome == 'success' + continue-on-error: true + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-name: index.docker.io/${{ matrix.image_repo }} + subject-digest: ${{ steps.build_and_push.outputs.digest }} + push-to-registry: true + - name: Soft-fail aggregator for ${{ matrix.image }} attestation + if: always() && steps.build_and_push.outcome == 'success' + env: + SIGN_OUTCOME: ${{ steps.cosign_sign.outcome }} + SBOM_OUTCOME: ${{ steps.sbom.outcome }} + ATTEST_SBOM_OUTCOME: ${{ steps.cosign_attest_sbom.outcome }} + SLSA_OUTCOME: ${{ steps.slsa_provenance.outcome }} + run: | + fail=0 + for v in "$SIGN_OUTCOME" "$SBOM_OUTCOME" "$ATTEST_SBOM_OUTCOME" "$SLSA_OUTCOME"; do + if [ "$v" != "success" ]; then fail=1; fi done + if [ "$fail" -eq 1 ]; then + echo "::warning title=Attestation incomplete::${{ matrix.image }} published but one or more attestation steps failed (sign=$SIGN_OUTCOME sbom=$SBOM_OUTCOME attest-sbom=$ATTEST_SBOM_OUTCOME slsa=$SLSA_OUTCOME). See verify-attestations workflow." + else + echo "All attestation steps succeeded for ${{ matrix.image }}." + fi docker-finalize: name: Docker finalize (tag-release, deploy) @@ -405,8 +571,46 @@ jobs: path: docs/site - name: copy build artifacts to dist bundle run: | + set -euo pipefail mkdir -p .sc/stacks/dist/bundle - cp artifacts/sc-*/*.tar.gz .sc/stacks/dist/bundle/ 2>/dev/null || true + # Phase 2: copy tarballs + every sidecar (sha256, sbom, cosign-bundle, + # intoto.jsonl). Sidecars may be absent if sign/SBOM steps soft-failed + # in build-platforms — that is by design during the 14-day bake-in; the + # count-assertion step below records the gap rather than blocks the run. + shopt -s nullglob + for f in artifacts/sc-*/*.tar.gz \ + artifacts/sc-*/*.tar.gz.sha256 \ + artifacts/sc-*/*.tar.gz.cosign-bundle \ + artifacts/sc-*/*.tar.gz.intoto.jsonl \ + artifacts/sc-*/*.sbom.cdx.json; do + cp "$f" .sc/stacks/dist/bundle/ + done + - name: Sidecar count assertion (Phase 2 bake-in — warn-only) + run: | + set -euo pipefail + cd .sc/stacks/dist/bundle + shopt -s nullglob + arr_tarballs=( sc-*.tar.gz ); tarballs=${#arr_tarballs[@]} + arr_shas=( sc-*.tar.gz.sha256 ); shas=${#arr_shas[@]} + arr_bundles=( sc-*.tar.gz.cosign-bundle ); bundles=${#arr_bundles[@]} + arr_provs=( sc-*.tar.gz.intoto.jsonl ); provs=${#arr_provs[@]} + arr_sboms=( sc-*.sbom.cdx.json ); sboms=${#arr_sboms[@]} + echo "tarballs=$tarballs sha256=$shas cosign-bundle=$bundles slsa=$provs sbom=$sboms" + if [ "$tarballs" -eq 0 ]; then + echo "::error title=No tarballs to publish::dist bundle has zero tarballs" + exit 1 + fi + missing=0 + for kind_count in "sha256=$shas" "cosign-bundle=$bundles" "slsa=$provs"; do + count="${kind_count#*=}" + if [ "$count" -lt "$tarballs" ]; then + echo "::warning title=Sidecar count mismatch::${kind_count%=*} has $count of $tarballs expected sidecars (Phase 2 soft-fail)" + missing=1 + fi + done + if [ "$missing" -eq 0 ]; then + echo "All sidecars present for every published tarball." + fi - name: finalize dist bundle setup env: VERSION: ${{ needs.prepare.outputs.version }} @@ -428,6 +632,131 @@ jobs: run: |- welder deploy -e prod --timestamps + verify-attestations: + name: Verify published attestations + runs-on: ubuntu-latest + needs: [prepare, docker-finalize] + permissions: + contents: read + env: + # Production trust root — sc.sh + consumer docs MUST trust ONLY this regex. + # See HARDENING.md "Phase 2 — Plan" + SECURITY.md "Identity regex contract". + PROD_IDENTITY_REGEX: '^https://github\.com/simple-container-com/api/\.github/workflows/push\.yaml@refs/heads/main$' + OIDC_ISSUER: 'https://token.actions.githubusercontent.com' + VERSION: ${{ needs.prepare.outputs.version }} + DIST_BASE: 'https://dist.simple-container.com' + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Install attestation tools + uses: ./.github/actions/install-attest-tools + - name: Verify image signatures (cosign) + id: verify_images + continue-on-error: true + run: | + set -uo pipefail + fail=0 + # Only the production tags. The staging image has its own trust root + # (build-staging.yml@refs/heads/staging) and is verified separately. + for img in \ + "simplecontainer/kubectl:${VERSION}" \ + "simplecontainer/caddy:${VERSION}" \ + "simplecontainer/github-actions:${VERSION}" \ + "simplecontainer/cloud-helpers:aws-${VERSION}"; do + echo "::group::cosign verify $img" + if ! cosign verify "$img" \ + --certificate-identity-regexp "$PROD_IDENTITY_REGEX" \ + --certificate-oidc-issuer "$OIDC_ISSUER" >/dev/null; then + echo "::warning title=cosign verify failed::$img" + fail=1 + else + echo "OK: $img" + fi + echo "::endgroup::" + done + exit "$fail" + - name: Verify image SLSA provenance (slsa-verifier) + id: verify_image_slsa + continue-on-error: true + run: | + set -uo pipefail + fail=0 + for img in \ + "docker.io/simplecontainer/kubectl:${VERSION}" \ + "docker.io/simplecontainer/caddy:${VERSION}" \ + "docker.io/simplecontainer/github-actions:${VERSION}" \ + "docker.io/simplecontainer/cloud-helpers:aws-${VERSION}"; do + echo "::group::slsa-verifier verify-image $img" + if ! slsa-verifier verify-image "$img" \ + --source-uri github.com/simple-container-com/api >/dev/null; then + echo "::warning title=slsa-verifier verify-image failed::$img" + fail=1 + else + echo "OK: $img" + fi + echo "::endgroup::" + done + exit "$fail" + - name: Verify tarball signatures + provenance (cosign + slsa-verifier) + id: verify_tarballs + continue-on-error: true + run: | + set -uo pipefail + fail=0 + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + for plat in linux-amd64 darwin-arm64 darwin-amd64; do + for variant in "-v${VERSION}" ""; do + tarball="sc-${plat}${variant}.tar.gz" + url="${DIST_BASE}/${tarball}" + echo "::group::verify ${tarball}" + if ! curl -fsSL "${url}" -o "${tmp}/${tarball}" \ + || ! curl -fsSL "${url}.sha256" -o "${tmp}/${tarball}.sha256" \ + || ! curl -fsSL "${url}.cosign-bundle" -o "${tmp}/${tarball}.cosign-bundle" \ + || ! curl -fsSL "${url}.intoto.jsonl" -o "${tmp}/${tarball}.intoto.jsonl"; then + echo "::warning title=sidecar download failed::${tarball} — one or more of {tarball,sha256,cosign-bundle,intoto.jsonl} returned non-200" + fail=1 + echo "::endgroup::" + continue + fi + ( cd "$tmp" && sha256sum -c "${tarball}.sha256" >/dev/null ) || { echo "::warning::sha256 mismatch ${tarball}"; fail=1; } + cosign verify-blob \ + --bundle "${tmp}/${tarball}.cosign-bundle" \ + --certificate-identity-regexp "$PROD_IDENTITY_REGEX" \ + --certificate-oidc-issuer "$OIDC_ISSUER" \ + "${tmp}/${tarball}" >/dev/null \ + || { echo "::warning::cosign verify-blob failed ${tarball}"; fail=1; } + slsa-verifier verify-artifact \ + --provenance-path "${tmp}/${tarball}.intoto.jsonl" \ + --source-uri github.com/simple-container-com/api \ + "${tmp}/${tarball}" >/dev/null \ + || { echo "::warning::slsa-verifier verify-artifact failed ${tarball}"; fail=1; } + echo "::endgroup::" + done + done + exit "$fail" + - name: Aggregate verification results + if: always() + env: + IMG_SIG_OUTCOME: ${{ steps.verify_images.outcome }} + IMG_SLSA_OUTCOME: ${{ steps.verify_image_slsa.outcome }} + TARBALL_OUTCOME: ${{ steps.verify_tarballs.outcome }} + run: | + fail=0 + for v in "$IMG_SIG_OUTCOME" "$IMG_SLSA_OUTCOME" "$TARBALL_OUTCOME"; do + if [ "$v" != "success" ]; then fail=1; fi + done + # Phase 2 bake-in: aggregate-fail this job so the workflow surfaces it, + # but downstream `finalize` already runs with `if: always()` so the + # Telegram notification still fires. After 14 days clean, this becomes + # the hard gate that flips per-step continue-on-error to false. + if [ "$fail" -eq 1 ]; then + echo "::error title=Post-publish attestation verification incomplete::See ::warning:: annotations above for the failed artifacts. Bake-in policy: this job's failure is informational during the rollout window; treat it as a release-quality regression." + exit 1 + fi + echo "All published attestations verified." + finalize: name: Finalize build and deploy for ${{ needs.prepare.outputs.stack-name }} runs-on: ubuntu-latest @@ -438,6 +767,7 @@ jobs: - prepare - build-setup - docker-finalize + - verify-attestations steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 if: ${{ always() }} diff --git a/SECURITY.md b/SECURITY.md index ed05c455..30880e4c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -73,6 +73,107 @@ set of audited libraries (`cosign`, `sigstore-go`). We avoid rolling our own crypto. The local security-scan cache uses HMAC-SHA256 with a 32-byte random per-cache key for tamper detection. +## Artifact signing and verification (Phase 2) + +Every release produces signed, attested artifacts published to Docker +Hub and `dist.simple-container.com`. Consumers can verify before use. + +### Identity-regex contract + +Cosign keyless signatures bind the signing identity to a GitHub +Actions OIDC subject. Consumers verify against one of two pinned +identities; **do not mix them**. + +| Trust root | Subject regex | Use for | +|---|---|---| +| **Production** | `^https://github\.com/simple-container-com/api/\.github/workflows/push\.yaml@refs/heads/main$` | `sc.sh` installs; production Docker images (`:latest`, `:vYYYY.M.x`, `:aws-vYYYY.M.x`); release tarballs | +| **Staging** | `^https://github\.com/simple-container-com/api/\.github/workflows/build-staging\.yml@refs/heads/staging$` | Consumers who **knowingly opt in** to `:staging` images via composite actions | +| OIDC issuer (both) | `https://token.actions.githubusercontent.com` | — | + +If either workflow file is ever renamed, the regex above is +bumped in the same PR. This file is the canonical reference for +consumer-side verification. + +### Verifying images + +```bash +IMG=docker.io/simplecontainer/github-actions +DIGEST=$(crane digest "$IMG:vYYYY.M.x") # pin to the immutable digest +cosign verify "$IMG@$DIGEST" \ + --certificate-identity-regexp '^https://github\.com/simple-container-com/api/\.github/workflows/push\.yaml@refs/heads/main$' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com +cosign verify-attestation "$IMG@$DIGEST" --type cyclonedx \ + --certificate-identity-regexp '...' --certificate-oidc-issuer '...' +slsa-verifier verify-image "$IMG@$DIGEST" \ + --source-uri github.com/simple-container-com/api +``` + +### Verifying tarballs + +The CDN ships these sidecars next to every tarball: + +- `.sha256` — SHA-256 checksum +- `.cosign-bundle` — cosign keyless bundle (cert + sig + Rekor entry) +- `.intoto.jsonl` — SLSA build provenance + +```bash +T="sc-linux-amd64-vYYYY.M.x.tar.gz" +curl -fLO "https://dist.simple-container.com/$T"{,.sha256,.cosign-bundle,.intoto.jsonl} +sha256sum -c "$T.sha256" +cosign verify-blob --bundle "$T.cosign-bundle" \ + --certificate-identity-regexp '^https://github\.com/simple-container-com/api/\.github/workflows/push\.yaml@refs/heads/main$' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com "$T" +slsa-verifier verify-artifact "$T" \ + --provenance-path "$T.intoto.jsonl" \ + --source-uri github.com/simple-container-com/api +``` + +`sc.sh` runs the tarball steps automatically when `cosign` is on `PATH`. + +### Composite-action consumers — SHA-pin the underlying image + +`simple-container-com/api/.github/actions/{deploy-client-stack, +provision-parent-stack,destroy,cancel-stack}` are docker-action +wrappers that pull `simplecontainer/github-actions:staging` by **tag** +at consume-time. Tags are mutable; the underlying image is signed but +the GitHub Actions runtime does not verify the signature before +launching the container. + +Consumers running these actions in **production** pipelines should +pin the action repository **and** the docker image to a digest. The +recommended pattern (see `simple-container-com/actions` for the +maintained variant of these wrappers): + +1. Pin the action ref by SHA, not `@main`. +2. Vendor the action.yml locally and replace + `image: 'docker://simplecontainer/github-actions:staging'` with + `image: 'docker://simplecontainer/github-actions@sha256:'` + for the digest you have verified out-of-band with `cosign verify`. +3. Re-bump the digest on a documented cadence (we publish the + current production digest in every release-notes entry). + +A native `cosign verify` step inside the wrapper action is on the +roadmap; until then, **digest-pinning is the only consumer-side +mitigation for the mutable-tag pull path**. + +### Residual risk: CDN rollback + +A network attacker who can rewrite responses from +`dist.simple-container.com` can serve an older, validly-signed, +still-vulnerable tarball when the consumer fetches the unversioned +`sc-os-arch.tar.gz` pointer. The signature still verifies (the older +build was legitimately signed at release time) but the binary is +known-vulnerable. + +Mitigation in this phase: `sc.sh` (Phase-2 PR 2c) defaults to +fetching the **latest version** from a signed `version` manifest, +not the unversioned tarball. Consumers who set +`SIMPLE_CONTAINER_VERSION=vYYYY.M.x` get the explicit version they +asked for; consumers who do not set it get the version the manifest +declares current. + +This residual risk is closed by TUF/RSTUF in Phase 6. + [push]: .github/workflows/push.yaml [install-sc]: https://github.com/simple-container-com/actions/tree/main/install-sc [gsa]: https://github.com/simple-container-com/api/security/advisories/new From a0a432b77de9d4974838287bb91aa4a32e2cf011 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Fri, 15 May 2026 12:39:16 +0400 Subject: [PATCH 02/15] fix(ci): address round-1 review (gh attestation verify, digest TOCTOU, staging split) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-1 codex + gemini review findings on PR #257. Applied: * P0 — slsa-verifier doesn't match attest-build-provenance@v4 output (gemini) and the install path 404s on `SHA256SUM.md` (codex). Both closed by switching verify-attestations to `gh attestation verify` (GitHub-native, accepts the Sigstore-bundle format the action actually emits). `install-attest-tools` no longer installs slsa- verifier; cosign + syft only. * P0 — verify by digest, not tag. docker-build matrix now uploads one `image-digest-` artifact per image with the `@sha256:` line; verify-attestations downloads them all and verifies against the immutable refs. Closes the verify-by- tag TOCTOU window. * P1 — github-actions-staging matrix entry in push.yaml no longer signs the `:staging` tag. That tag is owned by build-staging.yml under the staging trust root (build-staging.yml@refs/heads/staging); signing it twice with two different OIDC identities would flap the documented verification regex on every release. The push-without- attest path is preserved so the prod main pipeline still tags `:staging` for legacy consumers, but only build-staging.yml mints the trust-anchor signature. * P1 — sidecar count assertion now validates the SBOM count too. * P2 — `.intoto.jsonl` sidecar renamed to `.sigstore.json` (the file is a single-document Sigstore bundle, not line-delimited JSON). Build, copy, upload, verify, count-assertion, and SECURITY.md examples all updated. * P2 — SECURITY.md: tarball verification example switched to `gh attestation verify --bundle`; image example uses the same CLI. sc.sh-automation sentence qualified as "lands in the follow-up PR" with link to HARDENING.md. Defended (no change): * Gemini P0-1 — verify-attestations sits after docker-finalize and after `welder deploy`, so it is a post-publish quality gate, not a pre-deploy gate. Intentional: pre-deploy gating would couple every release to sigstore-public uptime, which is the velocity trap the Phase 2 plan calls out. Failure of this job surfaces the issue loudly enough for rollback decisions; the bake-in window then graduates this into the hard gate. * Gemini P1-1 — sign-after-publish race is inherent to the soft-fail design (publish first, sign second). Documented in HARDENING.md Phase 2 plan; bake-in to hard-fail closes it. * Gemini's claimed regex space typo (round 1, P0-3) — hallucinated finding, no such typo exists; verified by grep. Refs PR #257. Signed-off-by: Dmitrii Creed --- .../actions/install-attest-tools/action.yml | 46 +----- .github/workflows/push.yaml | 153 ++++++++++++------ SECURITY.md | 25 +-- 3 files changed, 129 insertions(+), 95 deletions(-) diff --git a/.github/actions/install-attest-tools/action.yml b/.github/actions/install-attest-tools/action.yml index d175a555..60699897 100644 --- a/.github/actions/install-attest-tools/action.yml +++ b/.github/actions/install-attest-tools/action.yml @@ -1,5 +1,5 @@ name: 'Install attestation tools' -description: 'Install cosign, syft, and slsa-verifier with single-source SHA pins for use across the SC release pipeline' +description: 'Install cosign + syft with single-source SHA pins for use across the SC release pipeline. Provenance verification uses the gh CLI (preinstalled on GitHub-hosted runners) against the GitHub-native attestation API, so no separate slsa-verifier install is needed.' inputs: cosign-version: @@ -10,10 +10,6 @@ inputs: description: 'Syft CLI release tag to install (e.g. v1.16.0)' required: false default: 'v1.16.0' - slsa-verifier-version: - description: 'slsa-verifier CLI release tag to install (e.g. v2.7.1)' - required: false - default: 'v2.7.1' runs: using: 'composite' @@ -28,43 +24,11 @@ runs: with: syft-version: ${{ inputs.syft-version }} - - name: Install slsa-verifier + - name: Verify gh CLI is available (for attestation verify) shell: bash - env: - SLSA_VERIFIER_VERSION: ${{ inputs.slsa-verifier-version }} run: | - set -euo pipefail - os="$(uname -s | tr '[:upper:]' '[:lower:]')" - arch_raw="$(uname -m)" - case "$arch_raw" in - x86_64|amd64) arch=amd64 ;; - aarch64|arm64) arch=arm64 ;; - *) echo "unsupported arch: $arch_raw" >&2; exit 1 ;; - esac - - bin_url="https://github.com/slsa-framework/slsa-verifier/releases/download/${SLSA_VERIFIER_VERSION}/slsa-verifier-${os}-${arch}" - sum_url="https://github.com/slsa-framework/slsa-verifier/releases/download/${SLSA_VERIFIER_VERSION}/SHA256SUM.md" - - tmpdir="$(mktemp -d)" - trap 'rm -rf "$tmpdir"' EXIT - - curl -fsSL -o "$tmpdir/slsa-verifier" "$bin_url" - curl -fsSL -o "$tmpdir/SHA256SUM.md" "$sum_url" - - expected="$(grep -E "slsa-verifier-${os}-${arch}\b" "$tmpdir/SHA256SUM.md" | awk '{print $1}' | head -n1)" - if [ -z "$expected" ]; then - echo "could not locate expected SHA256 for slsa-verifier-${os}-${arch} in release SHA256SUM.md" >&2 + if ! command -v gh >/dev/null 2>&1; then + echo "gh CLI not found on PATH — required for 'gh attestation verify' provenance checks." >&2 exit 1 fi - - actual="$(sha256sum "$tmpdir/slsa-verifier" | awk '{print $1}')" - if [ "$expected" != "$actual" ]; then - echo "slsa-verifier checksum mismatch (expected $expected, got $actual)" >&2 - exit 1 - fi - - install_dir="$HOME/.local/bin" - mkdir -p "$install_dir" - install -m 0755 "$tmpdir/slsa-verifier" "$install_dir/slsa-verifier" - echo "$install_dir" >> "$GITHUB_PATH" - "$install_dir/slsa-verifier" version + gh --version | head -n 1 diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index 22fdc0a3..df857c0e 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -227,11 +227,14 @@ jobs: run: | set -euo pipefail dest_dir=".sc/stacks/dist/bundle" - # attest-build-provenance emits a single bundle covering all subjects; - # publish it under both tarball names so consumers can locate it via - # the same prefix as the tarball they downloaded. + # attest-build-provenance emits a single Sigstore bundle covering all + # subjects; publish it under both tarball names so consumers can find + # it via the same prefix as the tarball they downloaded. + # Naming: .sigstore.json matches the actual format (Sigstore bundle, a + # single JSON object) — historical .intoto.jsonl is misleading because + # the file is not line-delimited. for tarball in "sc-${GOOS}-${GOARCH}-v${VERSION}.tar.gz" "sc-${GOOS}-${GOARCH}.tar.gz"; do - cp "$BUNDLE_PATH" "${dest_dir}/${tarball}.intoto.jsonl" + cp "$BUNDLE_PATH" "${dest_dir}/${tarball}.sigstore.json" done - name: Soft-fail aggregator for sc-${{ matrix.os }}-${{ matrix.arch }} attestation if: always() @@ -259,12 +262,12 @@ jobs: # artifacts and therefore never re-uploaded to dist.simple-container.com. Result: # sc.sh (which downloads the unversioned tarball when SIMPLE_CONTAINER_VERSION is # empty) served stale v2026.3.6 for ~4 weeks after PR #186 merged. - # Phase 2: also captures .sha256, .sbom.cdx.json, .cosign-bundle, .intoto.jsonl sidecars. + # Phase 2: also captures .sha256, .sbom.cdx.json, .cosign-bundle, .sigstore.json sidecars. path: | .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}*.tar.gz .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}*.tar.gz.sha256 .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}*.tar.gz.cosign-bundle - .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}*.tar.gz.intoto.jsonl + .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}*.tar.gz.sigstore.json .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}*.sbom.cdx.json retention-days: 1 @@ -468,12 +471,21 @@ jobs: # releases. A trailing aggregator emits ::warning:: on any failure. After # 14 days of clean post-publish verification (see verify-attestations), # flip continue-on-error to false (tracked in HARDENING.md Phase 2 plan). + # + # `github-actions-staging` is intentionally skipped from attestation here. + # The `:staging` tag is the canonical output of build-staging.yml under + # the staging trust root (build-staging.yml@refs/heads/staging) and is + # signed there. Signing it again from push.yaml@refs/heads/main would + # mint a second valid signature under the production identity, which + # contradicts the split identity-regex contract in SECURITY.md and + # would cause consumer verification to flap between identities on + # successive main / staging pushes. - name: Install attestation tools - if: steps.build_and_push.outcome == 'success' + if: steps.build_and_push.outcome == 'success' && matrix.image != 'github-actions-staging' uses: ./.github/actions/install-attest-tools - name: Generate CycloneDX SBOM for ${{ matrix.image }} id: sbom - if: steps.build_and_push.outcome == 'success' + if: steps.build_and_push.outcome == 'success' && matrix.image != 'github-actions-staging' continue-on-error: true env: IMAGE_REF: ${{ matrix.image_repo }}@${{ steps.build_and_push.outputs.digest }} @@ -483,7 +495,7 @@ jobs: ls -lh "sbom-${{ matrix.image }}.cdx.json" - name: Cosign sign ${{ matrix.image }} (keyless) id: cosign_sign - if: steps.build_and_push.outcome == 'success' + if: steps.build_and_push.outcome == 'success' && matrix.image != 'github-actions-staging' continue-on-error: true env: COSIGN_EXPERIMENTAL: "1" @@ -504,15 +516,34 @@ jobs: "${IMAGE_REF}" - name: SLSA build provenance for ${{ matrix.image }} id: slsa_provenance - if: steps.build_and_push.outcome == 'success' + if: steps.build_and_push.outcome == 'success' && matrix.image != 'github-actions-staging' continue-on-error: true uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-name: index.docker.io/${{ matrix.image_repo }} subject-digest: ${{ steps.build_and_push.outputs.digest }} push-to-registry: true + # Record the immutable digest so verify-attestations can verify by + # @sha256:... instead of by mutable tag (closes the verify-by-tag TOCTOU + # window flagged in PR #257 round-1 review). + - name: Record image digest for verify-attestations + if: steps.build_and_push.outcome == 'success' && matrix.image != 'github-actions-staging' + env: + DIGEST: ${{ steps.build_and_push.outputs.digest }} + IMAGE_REPO: ${{ matrix.image_repo }} + run: | + set -euo pipefail + mkdir -p digests + printf '%s@%s\n' "$IMAGE_REPO" "$DIGEST" > "digests/${{ matrix.image }}.txt" + - name: Upload digest artifact for ${{ matrix.image }} + if: steps.build_and_push.outcome == 'success' && matrix.image != 'github-actions-staging' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: image-digest-${{ matrix.image }} + path: digests/${{ matrix.image }}.txt + retention-days: 1 - name: Soft-fail aggregator for ${{ matrix.image }} attestation - if: always() && steps.build_and_push.outcome == 'success' + if: always() && steps.build_and_push.outcome == 'success' && matrix.image != 'github-actions-staging' env: SIGN_OUTCOME: ${{ steps.cosign_sign.outcome }} SBOM_OUTCOME: ${{ steps.sbom.outcome }} @@ -574,14 +605,14 @@ jobs: set -euo pipefail mkdir -p .sc/stacks/dist/bundle # Phase 2: copy tarballs + every sidecar (sha256, sbom, cosign-bundle, - # intoto.jsonl). Sidecars may be absent if sign/SBOM steps soft-failed + # sigstore.json). Sidecars may be absent if sign/SBOM steps soft-failed # in build-platforms — that is by design during the 14-day bake-in; the # count-assertion step below records the gap rather than blocks the run. shopt -s nullglob for f in artifacts/sc-*/*.tar.gz \ artifacts/sc-*/*.tar.gz.sha256 \ artifacts/sc-*/*.tar.gz.cosign-bundle \ - artifacts/sc-*/*.tar.gz.intoto.jsonl \ + artifacts/sc-*/*.tar.gz.sigstore.json \ artifacts/sc-*/*.sbom.cdx.json; do cp "$f" .sc/stacks/dist/bundle/ done @@ -593,7 +624,7 @@ jobs: arr_tarballs=( sc-*.tar.gz ); tarballs=${#arr_tarballs[@]} arr_shas=( sc-*.tar.gz.sha256 ); shas=${#arr_shas[@]} arr_bundles=( sc-*.tar.gz.cosign-bundle ); bundles=${#arr_bundles[@]} - arr_provs=( sc-*.tar.gz.intoto.jsonl ); provs=${#arr_provs[@]} + arr_provs=( sc-*.tar.gz.sigstore.json ); provs=${#arr_provs[@]} arr_sboms=( sc-*.sbom.cdx.json ); sboms=${#arr_sboms[@]} echo "tarballs=$tarballs sha256=$shas cosign-bundle=$bundles slsa=$provs sbom=$sboms" if [ "$tarballs" -eq 0 ]; then @@ -601,10 +632,17 @@ jobs: exit 1 fi missing=0 - for kind_count in "sha256=$shas" "cosign-bundle=$bundles" "slsa=$provs"; do + # Every tarball has matching .sha256 / .cosign-bundle / .sigstore.json + # / .sbom.cdx.json sidecars; build-platforms duplicates the per-build + # outputs across the versioned and unversioned tarball names. + for kind_count in "sha256=$shas" \ + "cosign-bundle=$bundles" \ + "slsa=$provs" \ + "sbom=$sboms"; do + label="${kind_count%%=*}" count="${kind_count#*=}" if [ "$count" -lt "$tarballs" ]; then - echo "::warning title=Sidecar count mismatch::${kind_count%=*} has $count of $tarballs expected sidecars (Phase 2 soft-fail)" + echo "::warning title=Sidecar count mismatch::${label} has $count of $tarballs expected sidecars (Phase 2 soft-fail)" missing=1 fi done @@ -635,9 +673,10 @@ jobs: verify-attestations: name: Verify published attestations runs-on: ubuntu-latest - needs: [prepare, docker-finalize] + needs: [prepare, docker-build, docker-finalize] permissions: contents: read + attestations: read # gh attestation verify reads from the GH attestation API env: # Production trust root — sc.sh + consumer docs MUST trust ONLY this regex. # See HARDENING.md "Phase 2 — Plan" + SECURITY.md "Identity regex contract". @@ -645,25 +684,48 @@ jobs: OIDC_ISSUER: 'https://token.actions.githubusercontent.com' VERSION: ${{ needs.prepare.outputs.version }} DIST_BASE: 'https://dist.simple-container.com' + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Install attestation tools uses: ./.github/actions/install-attest-tools - - name: Verify image signatures (cosign) + - name: Download per-image digests from docker-build matrix + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: image-digest-* + path: image-digests + merge-multiple: true + - name: Resolve image refs from digest artifacts + id: refs + run: | + set -euo pipefail + # Each image-digest-/.txt contains exactly one line of + # `@sha256:` — verify by immutable digest, not tag. + shopt -s nullglob + mapfile -t refs < <(cat image-digests/*.txt | sort -u) + if [ "${#refs[@]}" -eq 0 ]; then + echo "::error title=verify-attestations::no image-digest artifacts found from docker-build" + exit 1 + fi + printf 'image refs to verify:\n' + printf ' %s\n' "${refs[@]}" + { + echo "image_refs<> "$GITHUB_OUTPUT" + - name: Verify image signatures (cosign verify by digest) id: verify_images continue-on-error: true + env: + IMAGE_REFS: ${{ steps.refs.outputs.image_refs }} run: | set -uo pipefail fail=0 - # Only the production tags. The staging image has its own trust root - # (build-staging.yml@refs/heads/staging) and is verified separately. - for img in \ - "simplecontainer/kubectl:${VERSION}" \ - "simplecontainer/caddy:${VERSION}" \ - "simplecontainer/github-actions:${VERSION}" \ - "simplecontainer/cloud-helpers:aws-${VERSION}"; do + while IFS= read -r img; do + [ -z "$img" ] && continue echo "::group::cosign verify $img" if ! cosign verify "$img" \ --certificate-identity-regexp "$PROD_IDENTITY_REGEX" \ @@ -674,31 +736,33 @@ jobs: echo "OK: $img" fi echo "::endgroup::" - done + done <<< "$IMAGE_REFS" exit "$fail" - - name: Verify image SLSA provenance (slsa-verifier) + - name: Verify image SLSA provenance (gh attestation verify) id: verify_image_slsa continue-on-error: true + env: + IMAGE_REFS: ${{ steps.refs.outputs.image_refs }} run: | set -uo pipefail fail=0 - for img in \ - "docker.io/simplecontainer/kubectl:${VERSION}" \ - "docker.io/simplecontainer/caddy:${VERSION}" \ - "docker.io/simplecontainer/github-actions:${VERSION}" \ - "docker.io/simplecontainer/cloud-helpers:aws-${VERSION}"; do - echo "::group::slsa-verifier verify-image $img" - if ! slsa-verifier verify-image "$img" \ - --source-uri github.com/simple-container-com/api >/dev/null; then - echo "::warning title=slsa-verifier verify-image failed::$img" + while IFS= read -r img; do + [ -z "$img" ] && continue + echo "::group::gh attestation verify oci://$img" + # gh attestation verify reads from the GitHub-native attestation API + # that actions/attest-build-provenance@v4 writes to. The --owner + # check binds the attestation to this repo's org. + if ! gh attestation verify "oci://${img}" \ + --owner simple-container-com >/dev/null; then + echo "::warning title=gh attestation verify failed::$img" fail=1 else echo "OK: $img" fi echo "::endgroup::" - done + done <<< "$IMAGE_REFS" exit "$fail" - - name: Verify tarball signatures + provenance (cosign + slsa-verifier) + - name: Verify tarball signatures + provenance id: verify_tarballs continue-on-error: true run: | @@ -714,8 +778,8 @@ jobs: if ! curl -fsSL "${url}" -o "${tmp}/${tarball}" \ || ! curl -fsSL "${url}.sha256" -o "${tmp}/${tarball}.sha256" \ || ! curl -fsSL "${url}.cosign-bundle" -o "${tmp}/${tarball}.cosign-bundle" \ - || ! curl -fsSL "${url}.intoto.jsonl" -o "${tmp}/${tarball}.intoto.jsonl"; then - echo "::warning title=sidecar download failed::${tarball} — one or more of {tarball,sha256,cosign-bundle,intoto.jsonl} returned non-200" + || ! curl -fsSL "${url}.sigstore.json" -o "${tmp}/${tarball}.sigstore.json"; then + echo "::warning title=sidecar download failed::${tarball} — one or more of {tarball,sha256,cosign-bundle,sigstore.json} returned non-200" fail=1 echo "::endgroup::" continue @@ -727,11 +791,10 @@ jobs: --certificate-oidc-issuer "$OIDC_ISSUER" \ "${tmp}/${tarball}" >/dev/null \ || { echo "::warning::cosign verify-blob failed ${tarball}"; fail=1; } - slsa-verifier verify-artifact \ - --provenance-path "${tmp}/${tarball}.intoto.jsonl" \ - --source-uri github.com/simple-container-com/api \ - "${tmp}/${tarball}" >/dev/null \ - || { echo "::warning::slsa-verifier verify-artifact failed ${tarball}"; fail=1; } + gh attestation verify "${tmp}/${tarball}" \ + --bundle "${tmp}/${tarball}.sigstore.json" \ + --owner simple-container-com >/dev/null \ + || { echo "::warning::gh attestation verify failed ${tarball}"; fail=1; } echo "::endgroup::" done done diff --git a/SECURITY.md b/SECURITY.md index 30880e4c..85f42189 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -96,6 +96,11 @@ consumer-side verification. ### Verifying images +Always verify by digest, not tag — tags are mutable. SLSA build +provenance is verified via the GitHub-native `gh attestation verify` +because we publish provenance through `actions/attest-build-provenance@v4` +(a Sigstore bundle, not a raw `intoto.jsonl`). + ```bash IMG=docker.io/simplecontainer/github-actions DIGEST=$(crane digest "$IMG:vYYYY.M.x") # pin to the immutable digest @@ -103,9 +108,9 @@ cosign verify "$IMG@$DIGEST" \ --certificate-identity-regexp '^https://github\.com/simple-container-com/api/\.github/workflows/push\.yaml@refs/heads/main$' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com cosign verify-attestation "$IMG@$DIGEST" --type cyclonedx \ - --certificate-identity-regexp '...' --certificate-oidc-issuer '...' -slsa-verifier verify-image "$IMG@$DIGEST" \ - --source-uri github.com/simple-container-com/api + --certificate-identity-regexp '^https://github\.com/simple-container-com/api/\.github/workflows/push\.yaml@refs/heads/main$' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com +gh attestation verify "oci://$IMG@$DIGEST" --owner simple-container-com ``` ### Verifying tarballs @@ -114,21 +119,23 @@ The CDN ships these sidecars next to every tarball: - `.sha256` — SHA-256 checksum - `.cosign-bundle` — cosign keyless bundle (cert + sig + Rekor entry) -- `.intoto.jsonl` — SLSA build provenance +- `.sigstore.json` — SLSA build provenance (Sigstore bundle from `attest-build-provenance@v4`) ```bash T="sc-linux-amd64-vYYYY.M.x.tar.gz" -curl -fLO "https://dist.simple-container.com/$T"{,.sha256,.cosign-bundle,.intoto.jsonl} +curl -fLO "https://dist.simple-container.com/$T"{,.sha256,.cosign-bundle,.sigstore.json} sha256sum -c "$T.sha256" cosign verify-blob --bundle "$T.cosign-bundle" \ --certificate-identity-regexp '^https://github\.com/simple-container-com/api/\.github/workflows/push\.yaml@refs/heads/main$' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com "$T" -slsa-verifier verify-artifact "$T" \ - --provenance-path "$T.intoto.jsonl" \ - --source-uri github.com/simple-container-com/api +gh attestation verify "$T" --bundle "$T.sigstore.json" \ + --owner simple-container-com ``` -`sc.sh` runs the tarball steps automatically when `cosign` is on `PATH`. +`sc.sh` will run the tarball steps automatically when `cosign` is on +`PATH` — that integration lands in the follow-up PR (see +[`HARDENING.md`](../HARDENING.md) Phase 2 plan; until it merges, the +commands above are the manual verification path). ### Composite-action consumers — SHA-pin the underlying image From 508c6f719f82c5129ea70739e847b1ca76a95174 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Fri, 15 May 2026 12:50:33 +0400 Subject: [PATCH 03/15] =?UTF-8?q?fix(ci):=20round-2=20review=20=E2=80=94?= =?UTF-8?q?=20drop=20staging=20matrix=20entry,=20gh=20attestation=20--repo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 codex + gemini findings on PR #257. * P1 (codex) — github-actions-staging matrix entry in push.yaml's docker-build was leaving the :staging tag PUBLISHED but UNSIGNED after my round-1 skip. Consumers following SECURITY.md's staging verification path would fail until build-staging.yml happened to re-push the tag. Dropped the matrix entry entirely; build-staging.yml is now the sole publisher (and sole signer, under the staging trust root) of `simplecontainer/github-actions:staging`. The `build-github-actions-staging` job + its conditional download step in docker-build are now dead code; left in place pending a separate cleanup PR (no behavioral impact). * P1 (gemini) — `gh attestation verify --owner` only works for GHCR images; Docker Hub images need `--repo simple-container-com/api` because the CLI cannot deduce which attestation store to query without an explicit repo binding. Switched both verify-attestations steps and both SECURITY.md examples. * Side cleanup — `matrix.image != 'github-actions-staging'` guards on the seven attestation steps are now dead conditions (the matrix never has that value). Stripped to keep the YAML readable; the intent is documented in the comment block above the steps. Round-2 confirmation: gemini explicitly verified the image-digest-* artifact pipeline, the soft-fail aggregator semantics, the .intoto.jsonl → .sigstore.json rename, and the absence of new context interpolation issues. Refs PR #257. Signed-off-by: Dmitrii Creed --- .github/workflows/push.yaml | 39 ++++++++++++++++++------------------- SECURITY.md | 4 ++-- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index df857c0e..47e903a3 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -376,7 +376,7 @@ jobs: docker-build: name: Docker build and push ${{ matrix.image }} runs-on: blacksmith-8vcpu-ubuntu-2204 - needs: [prepare, build-setup, build-platforms, build-binaries, build-github-actions-staging, test, build-docs] + needs: [prepare, build-setup, build-platforms, build-binaries, test, build-docs] permissions: contents: read id-token: write # OIDC token for keyless cosign + attest-build-provenance @@ -402,10 +402,15 @@ jobs: tags: | simplecontainer/github-actions:latest simplecontainer/github-actions:${{ needs.prepare.outputs.version }} - - image: github-actions-staging - dockerfile: github-actions-staging.Dockerfile - image_repo: simplecontainer/github-actions - tags: simplecontainer/github-actions:staging + # The :staging tag is intentionally NOT published from this workflow. + # build-staging.yml (triggered on the `staging` branch) is the + # canonical owner of `simplecontainer/github-actions:staging` and signs + # it under the staging trust root. Publishing :staging here from main + # would either (a) double-sign with the production identity — breaking + # the SECURITY.md identity-regex contract — or (b) leave it unsigned, + # so consumers following the documented verification path would fail. + # The `build-github-actions-staging` job + its conditional download + # step are now dead code; cleanup tracked as a follow-up. - image: cloud-helpers-aws dockerfile: cloud-helpers.aws.Dockerfile image_repo: simplecontainer/cloud-helpers @@ -416,12 +421,6 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - name: download github-actions-staging binary - if: matrix.image == 'github-actions-staging' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: github-actions-staging-binary - path: bin - name: download github-actions binary if: matrix.image == 'github-actions' uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 @@ -481,11 +480,11 @@ jobs: # would cause consumer verification to flap between identities on # successive main / staging pushes. - name: Install attestation tools - if: steps.build_and_push.outcome == 'success' && matrix.image != 'github-actions-staging' + if: steps.build_and_push.outcome == 'success' uses: ./.github/actions/install-attest-tools - name: Generate CycloneDX SBOM for ${{ matrix.image }} id: sbom - if: steps.build_and_push.outcome == 'success' && matrix.image != 'github-actions-staging' + if: steps.build_and_push.outcome == 'success' continue-on-error: true env: IMAGE_REF: ${{ matrix.image_repo }}@${{ steps.build_and_push.outputs.digest }} @@ -495,7 +494,7 @@ jobs: ls -lh "sbom-${{ matrix.image }}.cdx.json" - name: Cosign sign ${{ matrix.image }} (keyless) id: cosign_sign - if: steps.build_and_push.outcome == 'success' && matrix.image != 'github-actions-staging' + if: steps.build_and_push.outcome == 'success' continue-on-error: true env: COSIGN_EXPERIMENTAL: "1" @@ -516,7 +515,7 @@ jobs: "${IMAGE_REF}" - name: SLSA build provenance for ${{ matrix.image }} id: slsa_provenance - if: steps.build_and_push.outcome == 'success' && matrix.image != 'github-actions-staging' + if: steps.build_and_push.outcome == 'success' continue-on-error: true uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: @@ -527,7 +526,7 @@ jobs: # @sha256:... instead of by mutable tag (closes the verify-by-tag TOCTOU # window flagged in PR #257 round-1 review). - name: Record image digest for verify-attestations - if: steps.build_and_push.outcome == 'success' && matrix.image != 'github-actions-staging' + if: steps.build_and_push.outcome == 'success' env: DIGEST: ${{ steps.build_and_push.outputs.digest }} IMAGE_REPO: ${{ matrix.image_repo }} @@ -536,14 +535,14 @@ jobs: mkdir -p digests printf '%s@%s\n' "$IMAGE_REPO" "$DIGEST" > "digests/${{ matrix.image }}.txt" - name: Upload digest artifact for ${{ matrix.image }} - if: steps.build_and_push.outcome == 'success' && matrix.image != 'github-actions-staging' + if: steps.build_and_push.outcome == 'success' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: image-digest-${{ matrix.image }} path: digests/${{ matrix.image }}.txt retention-days: 1 - name: Soft-fail aggregator for ${{ matrix.image }} attestation - if: always() && steps.build_and_push.outcome == 'success' && matrix.image != 'github-actions-staging' + if: always() && steps.build_and_push.outcome == 'success' env: SIGN_OUTCOME: ${{ steps.cosign_sign.outcome }} SBOM_OUTCOME: ${{ steps.sbom.outcome }} @@ -753,7 +752,7 @@ jobs: # that actions/attest-build-provenance@v4 writes to. The --owner # check binds the attestation to this repo's org. if ! gh attestation verify "oci://${img}" \ - --owner simple-container-com >/dev/null; then + --repo simple-container-com/api >/dev/null; then echo "::warning title=gh attestation verify failed::$img" fail=1 else @@ -793,7 +792,7 @@ jobs: || { echo "::warning::cosign verify-blob failed ${tarball}"; fail=1; } gh attestation verify "${tmp}/${tarball}" \ --bundle "${tmp}/${tarball}.sigstore.json" \ - --owner simple-container-com >/dev/null \ + --repo simple-container-com/api >/dev/null \ || { echo "::warning::gh attestation verify failed ${tarball}"; fail=1; } echo "::endgroup::" done diff --git a/SECURITY.md b/SECURITY.md index 85f42189..3c4ad4db 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -110,7 +110,7 @@ cosign verify "$IMG@$DIGEST" \ cosign verify-attestation "$IMG@$DIGEST" --type cyclonedx \ --certificate-identity-regexp '^https://github\.com/simple-container-com/api/\.github/workflows/push\.yaml@refs/heads/main$' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com -gh attestation verify "oci://$IMG@$DIGEST" --owner simple-container-com +gh attestation verify "oci://$IMG@$DIGEST" --repo simple-container-com/api ``` ### Verifying tarballs @@ -129,7 +129,7 @@ cosign verify-blob --bundle "$T.cosign-bundle" \ --certificate-identity-regexp '^https://github\.com/simple-container-com/api/\.github/workflows/push\.yaml@refs/heads/main$' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com "$T" gh attestation verify "$T" --bundle "$T.sigstore.json" \ - --owner simple-container-com + --repo simple-container-com/api ``` `sc.sh` will run the tarball steps automatically when `cosign` is on From 581402ed8ac2c04e6cef1b1efb7b45f06a1374f2 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Fri, 15 May 2026 12:58:25 +0400 Subject: [PATCH 04/15] =?UTF-8?q?fix(ci):=20round-3=20review=20=E2=80=94?= =?UTF-8?q?=20installer=20best-effort,=20drop=20staging=20dep=20from=20doc?= =?UTF-8?q?ker-finalize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-3 codex findings on PR #257 (gemini round-3 already clean). * P2 — `Install attestation tools` was not continue-on-error, so an installer flake would abort the job before `upload build artifacts` ran, blocking the tarball publish even though downstream sign/SBOM/ SLSA steps are best-effort. Made the installer step continue-on-error in build-platforms + docker-build + build-staging.yml; downstream cosign/syft steps remain continue-on-error so they soft-fail on missing tools, and the publish/upload steps still run. Verify- attestations keeps non-best-effort install (failure there IS the signal we want). * P2 — `build-github-actions-staging` job stayed in `docker-finalize.needs` after round-2 dropped its sole consumer (the github-actions-staging matrix entry in docker-build). A flake of that orphan job would block prod tag-release + welder deploy for no functional reason. Removed it from needs; the job itself is scheduled for full cleanup in a follow-up PR. Refs PR #257. Signed-off-by: Dmitrii Creed --- .github/workflows/build-staging.yml | 2 ++ .github/workflows/push.yaml | 18 +++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-staging.yml b/.github/workflows/build-staging.yml index 63b5ee63..ff746634 100644 --- a/.github/workflows/build-staging.yml +++ b/.github/workflows/build-staging.yml @@ -117,7 +117,9 @@ jobs: # regex, not the production one (push.yaml@refs/heads/main). See # SECURITY.md "Identity regex contract". - name: Install attestation tools + id: install_attest_tools if: steps.build_gha_staging.outcome == 'success' || steps.build_caddy_staging.outcome == 'success' + continue-on-error: true # Best-effort during Phase 2 bake-in (matches push.yaml). uses: ./.github/actions/install-attest-tools - name: Generate CycloneDX SBOM for github-actions staging diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index 47e903a3..003da935 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -177,6 +177,11 @@ jobs: sha256sum "sc-${GOOS}-${GOARCH}.tar.gz" > "sc-${GOOS}-${GOARCH}.tar.gz.sha256" sha256sum "sc-${GOOS}-${GOARCH}-v${VERSION}.tar.gz" > "sc-${GOOS}-${GOARCH}-v${VERSION}.tar.gz.sha256" - name: Install attestation tools + id: install_attest_tools + continue-on-error: true # Best-effort during Phase 2 bake-in. If the + # installer flakes, downstream sign/SBOM/SLSA + # steps soft-skip via their own outcome + # guards; the tarball + .sha256 still publish. uses: ./.github/actions/install-attest-tools - name: Generate CycloneDX SBOM for sc-${{ matrix.os }}-${{ matrix.arch }} id: sbom_tarball @@ -480,7 +485,13 @@ jobs: # would cause consumer verification to flap between identities on # successive main / staging pushes. - name: Install attestation tools + id: install_attest_tools if: steps.build_and_push.outcome == 'success' + continue-on-error: true # Best-effort during Phase 2 bake-in. If the + # installer flakes, downstream sign/SBOM/SLSA + # steps soft-skip and the `Record image + # digest` step still runs (it only does an + # `echo > file` and does not need cosign). uses: ./.github/actions/install-attest-tools - name: Generate CycloneDX SBOM for ${{ matrix.image }} id: sbom @@ -562,7 +573,12 @@ jobs: docker-finalize: name: Docker finalize (tag-release, deploy) runs-on: blacksmith-8vcpu-ubuntu-2204 - needs: [prepare, build-setup, build-platforms, build-binaries, build-github-actions-staging, test, build-docs, docker-build] + # build-github-actions-staging removed from needs after Phase 2 round-2: + # its output is no longer consumed by docker-build (the github-actions-staging + # matrix entry was dropped). The job itself is dead code, scheduled for + # cleanup in a follow-up PR; keeping it in `needs` would let a flake of an + # unused job block prod tag-release + welder deploy. + needs: [prepare, build-setup, build-platforms, build-binaries, test, build-docs, docker-build] permissions: contents: write # `welder run tag-release` pushes the release git tag steps: From d387ba6ce1dca1cfe15d1e68e854025982dd73c5 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Fri, 15 May 2026 13:22:05 +0400 Subject: [PATCH 05/15] perf+feat(ci): verify-attestations off critical path; cover branch-preview.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that go together: 1. Cut release wall-clock by ~2 min — moved verify-attestations out of push.yaml's job graph into a standalone .github/workflows/ verify-attestations.yml. Triggers: - workflow_run after push.yaml or branch-preview.yaml succeeds (parallel post-publish quality gate; doesn't block what already shipped) - schedule cron Monday 09:17 UTC (drift detection for identity- regex renames, sidecar loss, CDN-rollback) - workflow_dispatch with optional version + verify_tarballs inputs Image-digest pipeline preserved via download-artifact with run-id from the workflow_run event; falls back to tag-resolve when the artifact pattern isn't available (older runs, scheduled cron). 2. Mirror the full attestation pipeline into branch-preview.yaml so preview builds get the SAME security guarantees as production releases. PAY-SPACE and other consumers pin-test against preview versions (per /sc-preview workflow); without parity, the preview verification path silently breaks. Adds to branch-preview.yaml: - docker-build matrix: docker/build-push-action + cosign sign + SBOM attest + SLSA provenance (soft-fail), per-image digest artifact upload (same as push.yaml) - build-platforms matrix: per-tarball .sha256 + .cosign-bundle + .sigstore.json + .sbom.cdx.json sidecars (versioned-only, since preview never publishes the unversioned twin) - publish-sc-preview: explicit sidecar enumeration in dist copy Estimated impact on push.yaml wall-clock vs pre-Phase-2 baseline (~17 min median): docker-build matrix gains ~30-60s/item but runs in parallel; build-platforms gains ~20-40s/item parallel; docker-finalize gains ~5s for sidecar enumeration. Estimated new median: ~18 min (+5-8% vs baseline, well under the 110% Phase 2 ceiling). Will be measured precisely after dispatching branch-preview.yaml from this branch. Refs PR #257. Signed-off-by: Dmitrii Creed --- .github/workflows/branch-preview.yaml | 190 ++++++++++++++++++++-- .github/workflows/push.yaml | 151 ----------------- .github/workflows/verify-attestations.yml | 188 +++++++++++++++++++++ 3 files changed, 364 insertions(+), 165 deletions(-) create mode 100644 .github/workflows/verify-attestations.yml diff --git a/.github/workflows/branch-preview.yaml b/.github/workflows/branch-preview.yaml index e2a95cbc..cb3a252e 100644 --- a/.github/workflows/branch-preview.yaml +++ b/.github/workflows/branch-preview.yaml @@ -122,6 +122,10 @@ jobs: name: Build sc for ${{ matrix.os }}/${{ matrix.arch }} runs-on: blacksmith-8vcpu-ubuntu-2204 needs: [prepare, build-setup] + permissions: + contents: read + id-token: write # OIDC for keyless cosign + attest-build-provenance + attestations: write strategy: matrix: include: @@ -155,11 +159,73 @@ jobs: go build -ldflags "-s -w -X=github.com/simple-container-com/api/internal/build.Version=${VERSION}" -o dist/${GOOS}-${GOARCH}/sc${EXT} ./cmd/sc tar -czf .sc/stacks/dist/bundle/sc-${GOOS}-${GOARCH}.tar.gz -C dist/${GOOS}-${GOARCH} sc${EXT} cp .sc/stacks/dist/bundle/sc-${GOOS}-${GOARCH}.tar.gz .sc/stacks/dist/bundle/sc-${GOOS}-${GOARCH}-v${VERSION}.tar.gz + # Phase 2 attestation (mirrors push.yaml). Soft-fail per step — the + # tarball itself is the publish artifact. Preview builds only publish + # versioned tarballs (no unversioned twin), so sidecars are per-version. + - name: SHA-256 sidecar for ${{ matrix.os }}/${{ matrix.arch }} + env: + GOOS: ${{ matrix.os }} + GOARCH: ${{ matrix.arch }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + set -euo pipefail + cd .sc/stacks/dist/bundle + sha256sum "sc-${GOOS}-${GOARCH}-v${VERSION}.tar.gz" > "sc-${GOOS}-${GOARCH}-v${VERSION}.tar.gz.sha256" + - name: Install attestation tools + id: install_attest_tools + continue-on-error: true + uses: ./.github/actions/install-attest-tools + - name: Generate CycloneDX SBOM for sc-${{ matrix.os }}-${{ matrix.arch }} + id: sbom_tarball + continue-on-error: true + env: + GOOS: ${{ matrix.os }} + GOARCH: ${{ matrix.arch }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + set -euo pipefail + cd .sc/stacks/dist/bundle + syft scan "dir:../../../../dist/${GOOS}-${GOARCH}" \ + -o "cyclonedx-json=sc-${GOOS}-${GOARCH}-v${VERSION}.sbom.cdx.json" + - name: Cosign sign-blob (keyless, bundle) for sc-${{ matrix.os }}-${{ matrix.arch }} + id: cosign_sign_tarball + continue-on-error: true + env: + COSIGN_EXPERIMENTAL: "1" + GOOS: ${{ matrix.os }} + GOARCH: ${{ matrix.arch }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + set -euo pipefail + cd .sc/stacks/dist/bundle + tarball="sc-${GOOS}-${GOARCH}-v${VERSION}.tar.gz" + cosign sign-blob --yes --bundle "${tarball}.cosign-bundle" "${tarball}" + - name: SLSA build provenance for sc-${{ matrix.os }}-${{ matrix.arch }} + id: slsa_tarball + continue-on-error: true + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}-v${{ needs.prepare.outputs.version }}.tar.gz + - name: Materialize SLSA provenance bundle next to tarball + if: steps.slsa_tarball.outcome == 'success' + continue-on-error: true + env: + BUNDLE_PATH: ${{ steps.slsa_tarball.outputs.bundle-path }} + GOOS: ${{ matrix.os }} + GOARCH: ${{ matrix.arch }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + cp "$BUNDLE_PATH" ".sc/stacks/dist/bundle/sc-${GOOS}-${GOARCH}-v${VERSION}.tar.gz.sigstore.json" - name: upload build artifacts uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: sc-${{ matrix.os }}-${{ matrix.arch }} - path: .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}-v*.tar.gz + path: | + .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}-v*.tar.gz + .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}-v*.tar.gz.sha256 + .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}-v*.tar.gz.cosign-bundle + .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}-v*.tar.gz.sigstore.json + .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}-v*.sbom.cdx.json retention-days: 1 build-binaries: @@ -219,16 +285,22 @@ jobs: name: Docker build and push preview ${{ matrix.target }} image runs-on: blacksmith-8vcpu-ubuntu-2204 needs: [prepare, build-setup, build-binaries, test] + permissions: + contents: read + id-token: write # OIDC token for keyless cosign + attest-build-provenance + attestations: write strategy: matrix: include: - target: github-actions binary: github-actions dockerfile: github-actions.Dockerfile + image_repo: simplecontainer/github-actions tag_prefix: "simplecontainer/github-actions:" - target: cloud-helpers binary: cloud-helpers dockerfile: cloud-helpers.aws.Dockerfile + image_repo: simplecontainer/cloud-helpers tag_prefix: "simplecontainer/cloud-helpers:aws-" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -260,18 +332,98 @@ jobs: run: | sc stack secret-get -s dist dockerhub-cicd-token | docker login --username simplecontainer --password-stdin - name: Build and push preview ${{ matrix.target }} image + id: build_and_push + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: . + file: ${{ matrix.dockerfile }} + platforms: linux/amd64 + tags: ${{ matrix.tag_prefix }}${{ needs.prepare.outputs.version }} + push: true + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: false + # Phase 2 attestation (mirrors push.yaml). Preview builds get the SAME + # security guarantees as production releases so consumers pin-testing a + # preview version (per project_sc_preview memory: PAY-SPACE pins workflows + # to versioned preview builds) can verify the same way they verify prod. + - name: Install attestation tools + id: install_attest_tools + if: steps.build_and_push.outcome == 'success' + continue-on-error: true + uses: ./.github/actions/install-attest-tools + - name: Generate CycloneDX SBOM for ${{ matrix.target }} + id: sbom + if: steps.build_and_push.outcome == 'success' + continue-on-error: true env: - DOCKER_BUILDKIT: 1 - VERSION: ${{ needs.prepare.outputs.version }} + IMAGE_REF: ${{ matrix.image_repo }}@${{ steps.build_and_push.outputs.digest }} + run: | + set -euo pipefail + syft scan "registry:${IMAGE_REF}" -o "cyclonedx-json=sbom-${{ matrix.target }}.cdx.json" + - name: Cosign sign ${{ matrix.target }} (keyless) + id: cosign_sign + if: steps.build_and_push.outcome == 'success' + continue-on-error: true + env: + COSIGN_EXPERIMENTAL: "1" + IMAGE_REF: ${{ matrix.image_repo }}@${{ steps.build_and_push.outputs.digest }} + run: | + cosign sign --yes "${IMAGE_REF}" + - name: Cosign attest SBOM for ${{ matrix.target }} + id: cosign_attest_sbom + if: steps.sbom.outcome == 'success' && steps.cosign_sign.outcome == 'success' + continue-on-error: true + env: + COSIGN_EXPERIMENTAL: "1" + IMAGE_REF: ${{ matrix.image_repo }}@${{ steps.build_and_push.outputs.digest }} run: | - docker buildx build \ - --platform linux/amd64 \ - --cache-from type=gha \ - --cache-to type=gha,mode=max \ - --file ${{ matrix.dockerfile }} \ - --tag "${{ matrix.tag_prefix }}${VERSION}" \ - --push \ - . + cosign attest --yes \ + --predicate "sbom-${{ matrix.target }}.cdx.json" \ + --type cyclonedx \ + "${IMAGE_REF}" + - name: SLSA build provenance for ${{ matrix.target }} + id: slsa_provenance + if: steps.build_and_push.outcome == 'success' + continue-on-error: true + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-name: index.docker.io/${{ matrix.image_repo }} + subject-digest: ${{ steps.build_and_push.outputs.digest }} + push-to-registry: true + - name: Record image digest for verify-attestations + if: steps.build_and_push.outcome == 'success' + env: + DIGEST: ${{ steps.build_and_push.outputs.digest }} + IMAGE_REPO: ${{ matrix.image_repo }} + run: | + set -euo pipefail + mkdir -p digests + printf '%s@%s\n' "$IMAGE_REPO" "$DIGEST" > "digests/${{ matrix.target }}.txt" + - name: Upload digest artifact for ${{ matrix.target }} + if: steps.build_and_push.outcome == 'success' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: image-digest-${{ matrix.target }} + path: digests/${{ matrix.target }}.txt + retention-days: 1 + - name: Soft-fail aggregator for ${{ matrix.target }} attestation + if: always() && steps.build_and_push.outcome == 'success' + env: + SIGN_OUTCOME: ${{ steps.cosign_sign.outcome }} + SBOM_OUTCOME: ${{ steps.sbom.outcome }} + ATTEST_SBOM_OUTCOME: ${{ steps.cosign_attest_sbom.outcome }} + SLSA_OUTCOME: ${{ steps.slsa_provenance.outcome }} + run: | + fail=0 + for v in "$SIGN_OUTCOME" "$SBOM_OUTCOME" "$ATTEST_SBOM_OUTCOME" "$SLSA_OUTCOME"; do + if [ "$v" != "success" ]; then fail=1; fi + done + if [ "$fail" -eq 1 ]; then + echo "::warning title=Attestation incomplete::${{ matrix.target }} preview published but attestation steps failed (sign=$SIGN_OUTCOME sbom=$SBOM_OUTCOME attest-sbom=$ATTEST_SBOM_OUTCOME slsa=$SLSA_OUTCOME)." + else + echo "All attestation steps succeeded for ${{ matrix.target }} preview." + fi publish-sc-preview: name: Publish preview SC binaries to dist @@ -312,11 +464,21 @@ jobs: env: VERSION: ${{ needs.prepare.outputs.version }} run: | + set -euo pipefail mkdir -p .sc/stacks/dist/bundle rm -fR .sc/stacks/dist/bundle/* - # Copy only versioned tarballs — do NOT add sc.sh or the version file. - # This prevents overwriting the latest pointer for users running sc.sh without a version pin. - cp artifacts/sc-*/*-v${VERSION}.tar.gz .sc/stacks/dist/bundle/ + # Copy only versioned tarballs + their sidecars (.sha256, + # .cosign-bundle, .sigstore.json, .sbom.cdx.json) — do NOT add sc.sh + # or the version file. Preserves the unversioned-latest pointer for + # users running sc.sh without a version pin. + shopt -s nullglob + for f in artifacts/sc-*/*-v${VERSION}.tar.gz \ + artifacts/sc-*/*-v${VERSION}.tar.gz.sha256 \ + artifacts/sc-*/*-v${VERSION}.tar.gz.cosign-bundle \ + artifacts/sc-*/*-v${VERSION}.tar.gz.sigstore.json \ + artifacts/sc-*/*-v${VERSION}.sbom.cdx.json; do + cp "$f" .sc/stacks/dist/bundle/ + done echo "Bundle contents:" ls -la .sc/stacks/dist/bundle/ - name: publish preview sc binaries diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index 003da935..c3e2a5c8 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -685,156 +685,6 @@ jobs: run: |- welder deploy -e prod --timestamps - verify-attestations: - name: Verify published attestations - runs-on: ubuntu-latest - needs: [prepare, docker-build, docker-finalize] - permissions: - contents: read - attestations: read # gh attestation verify reads from the GH attestation API - env: - # Production trust root — sc.sh + consumer docs MUST trust ONLY this regex. - # See HARDENING.md "Phase 2 — Plan" + SECURITY.md "Identity regex contract". - PROD_IDENTITY_REGEX: '^https://github\.com/simple-container-com/api/\.github/workflows/push\.yaml@refs/heads/main$' - OIDC_ISSUER: 'https://token.actions.githubusercontent.com' - VERSION: ${{ needs.prepare.outputs.version }} - DIST_BASE: 'https://dist.simple-container.com' - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Install attestation tools - uses: ./.github/actions/install-attest-tools - - name: Download per-image digests from docker-build matrix - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - pattern: image-digest-* - path: image-digests - merge-multiple: true - - name: Resolve image refs from digest artifacts - id: refs - run: | - set -euo pipefail - # Each image-digest-/.txt contains exactly one line of - # `@sha256:` — verify by immutable digest, not tag. - shopt -s nullglob - mapfile -t refs < <(cat image-digests/*.txt | sort -u) - if [ "${#refs[@]}" -eq 0 ]; then - echo "::error title=verify-attestations::no image-digest artifacts found from docker-build" - exit 1 - fi - printf 'image refs to verify:\n' - printf ' %s\n' "${refs[@]}" - { - echo "image_refs<> "$GITHUB_OUTPUT" - - name: Verify image signatures (cosign verify by digest) - id: verify_images - continue-on-error: true - env: - IMAGE_REFS: ${{ steps.refs.outputs.image_refs }} - run: | - set -uo pipefail - fail=0 - while IFS= read -r img; do - [ -z "$img" ] && continue - echo "::group::cosign verify $img" - if ! cosign verify "$img" \ - --certificate-identity-regexp "$PROD_IDENTITY_REGEX" \ - --certificate-oidc-issuer "$OIDC_ISSUER" >/dev/null; then - echo "::warning title=cosign verify failed::$img" - fail=1 - else - echo "OK: $img" - fi - echo "::endgroup::" - done <<< "$IMAGE_REFS" - exit "$fail" - - name: Verify image SLSA provenance (gh attestation verify) - id: verify_image_slsa - continue-on-error: true - env: - IMAGE_REFS: ${{ steps.refs.outputs.image_refs }} - run: | - set -uo pipefail - fail=0 - while IFS= read -r img; do - [ -z "$img" ] && continue - echo "::group::gh attestation verify oci://$img" - # gh attestation verify reads from the GitHub-native attestation API - # that actions/attest-build-provenance@v4 writes to. The --owner - # check binds the attestation to this repo's org. - if ! gh attestation verify "oci://${img}" \ - --repo simple-container-com/api >/dev/null; then - echo "::warning title=gh attestation verify failed::$img" - fail=1 - else - echo "OK: $img" - fi - echo "::endgroup::" - done <<< "$IMAGE_REFS" - exit "$fail" - - name: Verify tarball signatures + provenance - id: verify_tarballs - continue-on-error: true - run: | - set -uo pipefail - fail=0 - tmp="$(mktemp -d)" - trap 'rm -rf "$tmp"' EXIT - for plat in linux-amd64 darwin-arm64 darwin-amd64; do - for variant in "-v${VERSION}" ""; do - tarball="sc-${plat}${variant}.tar.gz" - url="${DIST_BASE}/${tarball}" - echo "::group::verify ${tarball}" - if ! curl -fsSL "${url}" -o "${tmp}/${tarball}" \ - || ! curl -fsSL "${url}.sha256" -o "${tmp}/${tarball}.sha256" \ - || ! curl -fsSL "${url}.cosign-bundle" -o "${tmp}/${tarball}.cosign-bundle" \ - || ! curl -fsSL "${url}.sigstore.json" -o "${tmp}/${tarball}.sigstore.json"; then - echo "::warning title=sidecar download failed::${tarball} — one or more of {tarball,sha256,cosign-bundle,sigstore.json} returned non-200" - fail=1 - echo "::endgroup::" - continue - fi - ( cd "$tmp" && sha256sum -c "${tarball}.sha256" >/dev/null ) || { echo "::warning::sha256 mismatch ${tarball}"; fail=1; } - cosign verify-blob \ - --bundle "${tmp}/${tarball}.cosign-bundle" \ - --certificate-identity-regexp "$PROD_IDENTITY_REGEX" \ - --certificate-oidc-issuer "$OIDC_ISSUER" \ - "${tmp}/${tarball}" >/dev/null \ - || { echo "::warning::cosign verify-blob failed ${tarball}"; fail=1; } - gh attestation verify "${tmp}/${tarball}" \ - --bundle "${tmp}/${tarball}.sigstore.json" \ - --repo simple-container-com/api >/dev/null \ - || { echo "::warning::gh attestation verify failed ${tarball}"; fail=1; } - echo "::endgroup::" - done - done - exit "$fail" - - name: Aggregate verification results - if: always() - env: - IMG_SIG_OUTCOME: ${{ steps.verify_images.outcome }} - IMG_SLSA_OUTCOME: ${{ steps.verify_image_slsa.outcome }} - TARBALL_OUTCOME: ${{ steps.verify_tarballs.outcome }} - run: | - fail=0 - for v in "$IMG_SIG_OUTCOME" "$IMG_SLSA_OUTCOME" "$TARBALL_OUTCOME"; do - if [ "$v" != "success" ]; then fail=1; fi - done - # Phase 2 bake-in: aggregate-fail this job so the workflow surfaces it, - # but downstream `finalize` already runs with `if: always()` so the - # Telegram notification still fires. After 14 days clean, this becomes - # the hard gate that flips per-step continue-on-error to false. - if [ "$fail" -eq 1 ]; then - echo "::error title=Post-publish attestation verification incomplete::See ::warning:: annotations above for the failed artifacts. Bake-in policy: this job's failure is informational during the rollout window; treat it as a release-quality regression." - exit 1 - fi - echo "All published attestations verified." - finalize: name: Finalize build and deploy for ${{ needs.prepare.outputs.stack-name }} runs-on: ubuntu-latest @@ -845,7 +695,6 @@ jobs: - prepare - build-setup - docker-finalize - - verify-attestations steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 if: ${{ always() }} diff --git a/.github/workflows/verify-attestations.yml b/.github/workflows/verify-attestations.yml new file mode 100644 index 00000000..78370a3f --- /dev/null +++ b/.github/workflows/verify-attestations.yml @@ -0,0 +1,188 @@ +name: Verify published attestations + +# Post-publish quality gate for Phase 2 supply-chain attestations. +# Off the release critical path: fires AFTER push.yaml or branch-preview.yaml +# succeed, runs weekly as a drift check, and is manually dispatchable for +# ad-hoc re-verification. A failure here surfaces in the repo's Actions tab +# without blocking the release that already shipped — bake-in policy. + +on: + workflow_run: + workflows: ['Build simple-container-com CLI', 'Build and publish preview version'] + types: [completed] + schedule: + - cron: '17 9 * * 1' # Mondays 09:17 UTC + workflow_dispatch: + inputs: + version: + description: 'Version to verify (e.g. 2026.5.1 or 2026.5.1-preview.abc1234). Defaults to whatever `dist.simple-container.com/version` currently advertises.' + required: false + default: '' + verify_tarballs: + description: 'Verify tarballs on dist.simple-container.com (off for fresh preview builds that haven''t deployed yet)' + required: false + default: 'true' + +permissions: + contents: read + +jobs: + verify: + name: Verify attestations + runs-on: ubuntu-latest + # Skip auto-runs triggered by failed upstream workflows (workflow_run fires + # regardless of conclusion). Schedule + manual dispatch always run. + if: | + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') + permissions: + contents: read + actions: read # download-artifact from triggering workflow_run + attestations: read # gh attestation verify + env: + # Production trust root — must match SECURITY.md "Identity regex contract". + PROD_IDENTITY_REGEX: '^https://github\.com/simple-container-com/api/\.github/workflows/(push|branch-preview)\.yaml?@refs/heads/.+$' + OIDC_ISSUER: 'https://token.actions.githubusercontent.com' + DIST_BASE: 'https://dist.simple-container.com' + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install attestation tools + uses: ./.github/actions/install-attest-tools + + - name: Resolve target version + id: version + run: | + set -euo pipefail + # Priority: workflow_dispatch input → workflow_run head version (best + # effort, parsed from prepare-job output if accessible) → fall back to + # the unversioned `version` file on dist. + INPUT_VERSION='${{ github.event.inputs.version }}' + if [ -n "$INPUT_VERSION" ]; then + echo "version=$INPUT_VERSION" >> "$GITHUB_OUTPUT" + echo "Using workflow_dispatch input version: $INPUT_VERSION" + exit 0 + fi + # workflow_run event: try to read the version artifact from the + # triggering run, otherwise read dist/version (which the deploy step + # updates immediately after a successful release). + DIST_VERSION=$(curl -fsSL "$DIST_BASE/version" 2>/dev/null | head -n1 | tr -d '\r\n[:space:]') + if [ -z "$DIST_VERSION" ]; then + echo "::error title=verify-attestations::could not resolve target version" + exit 1 + fi + echo "version=$DIST_VERSION" >> "$GITHUB_OUTPUT" + echo "Using dist version: $DIST_VERSION" + + - name: Download image digests from triggering workflow_run (best-effort) + id: download_digests + if: github.event_name == 'workflow_run' + continue-on-error: true + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: image-digest-* + path: image-digests + merge-multiple: true + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Verify image signatures + provenance + id: verify_images + continue-on-error: true + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -uo pipefail + fail=0 + # Prefer immutable digests recovered from the build run; fall back to + # version tags on Docker Hub if the artifact pattern didn't match (eg. + # scheduled cron, or a workflow_run from before this PR). + shopt -s nullglob + digest_files=( image-digests/*.txt ) + if [ "${#digest_files[@]}" -gt 0 ]; then + mapfile -t refs < <(cat image-digests/*.txt | sort -u) + echo "verifying ${#refs[@]} images by digest (recovered from upstream artifacts)" + else + refs=( + "simplecontainer/kubectl:${VERSION}" + "simplecontainer/caddy:${VERSION}" + "simplecontainer/github-actions:${VERSION}" + "simplecontainer/cloud-helpers:aws-${VERSION}" + ) + echo "verifying ${#refs[@]} images by tag (artifact pattern unavailable; tag-resolve fallback)" + fi + for img in "${refs[@]}"; do + [ -z "$img" ] && continue + echo "::group::verify $img" + cosign verify "$img" \ + --certificate-identity-regexp "$PROD_IDENTITY_REGEX" \ + --certificate-oidc-issuer "$OIDC_ISSUER" >/dev/null \ + || { echo "::warning title=cosign verify failed::$img"; fail=1; } + gh attestation verify "oci://${img}" \ + --repo simple-container-com/api >/dev/null \ + || { echo "::warning title=gh attestation verify failed::$img"; fail=1; } + echo "::endgroup::" + done + exit "$fail" + + - name: Verify tarball signatures + provenance + id: verify_tarballs + if: github.event.inputs.verify_tarballs != 'false' + continue-on-error: true + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -uo pipefail + fail=0 + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + for plat in linux-amd64 darwin-arm64 darwin-amd64; do + for variant in "-v${VERSION}" ""; do + tarball="sc-${plat}${variant}.tar.gz" + url="${DIST_BASE}/${tarball}" + echo "::group::verify ${tarball}" + if ! curl -fsSL "${url}" -o "${tmp}/${tarball}" \ + || ! curl -fsSL "${url}.sha256" -o "${tmp}/${tarball}.sha256" \ + || ! curl -fsSL "${url}.cosign-bundle" -o "${tmp}/${tarball}.cosign-bundle" \ + || ! curl -fsSL "${url}.sigstore.json" -o "${tmp}/${tarball}.sigstore.json"; then + echo "::warning title=sidecar download failed::${tarball}" + fail=1 + echo "::endgroup::" + continue + fi + ( cd "$tmp" && sha256sum -c "${tarball}.sha256" >/dev/null ) \ + || { echo "::warning::sha256 mismatch ${tarball}"; fail=1; } + cosign verify-blob \ + --bundle "${tmp}/${tarball}.cosign-bundle" \ + --certificate-identity-regexp "$PROD_IDENTITY_REGEX" \ + --certificate-oidc-issuer "$OIDC_ISSUER" \ + "${tmp}/${tarball}" >/dev/null \ + || { echo "::warning::cosign verify-blob failed ${tarball}"; fail=1; } + gh attestation verify "${tmp}/${tarball}" \ + --bundle "${tmp}/${tarball}.sigstore.json" \ + --repo simple-container-com/api >/dev/null \ + || { echo "::warning::gh attestation verify failed ${tarball}"; fail=1; } + echo "::endgroup::" + done + done + exit "$fail" + + - name: Aggregate verification results + if: always() + env: + IMG_OUTCOME: ${{ steps.verify_images.outcome }} + TARBALL_OUTCOME: ${{ steps.verify_tarballs.outcome }} + run: | + fail=0 + for v in "$IMG_OUTCOME" "$TARBALL_OUTCOME"; do + if [ "$v" = "failure" ]; then fail=1; fi + done + if [ "$fail" -eq 1 ]; then + echo "::error title=Post-publish verification incomplete::See ::warning:: annotations above. Phase 2 bake-in: this surfaces the regression without blocking the release that already shipped." + exit 1 + fi + echo "All published attestations verified." From 9ef3fa8c242712d91cfaa2d1cad718b2fa988ba4 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Fri, 15 May 2026 17:27:32 +0400 Subject: [PATCH 06/15] fix(ci): drop push-to-registry from attest-build-provenance (SBOM was overwritten) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empirical finding from preview run 25910386532 (feat/phase2-attestation @ d387ba6): cosign verify-attestation --type cyclonedx returns "none of the attestations matched the predicate type: cyclonedx, found: https://slsa.dev/provenance/v1". Root cause: actions/attest-build-provenance@v4 with `push-to-registry: true` writes its Sigstore bundle to the same `:sha256-.att` OCI tag that `cosign attest --type cyclonedx` wrote to seconds earlier. The provenance push silently overwrites the SBOM attestation; both tlog entries land in Rekor, but only the provenance survives on the registry. cosign verify- attestation only reads from the registry, so the SBOM attestation becomes invisible despite the workflow step reporting success. Fix: drop `push-to-registry: true`. attest-build-provenance still publishes to the GitHub-native attestation API (verifiable via `gh attestation verify --repo simple-container-com/api`); cosign attest keeps owning the registry slot for the CycloneDX SBOM. Net effect after this fix (verified empirically on the next preview run): - cosign verify → OK (registry sig, unchanged) - cosign verify-attestation --type cyclonedx → OK (registry SBOM, now) - gh attestation verify oci:// --repo simple-container-com/api → OK (GH API SLSA, unchanged) Verification commands in SECURITY.md already match this split (gh for SLSA, cosign for SBOM); no doc change needed. Refs PR #257, preview run 25910386532. Signed-off-by: Dmitrii Creed --- .github/workflows/branch-preview.yaml | 8 +++++++- .github/workflows/build-staging.yml | 16 ++++++++++++++-- .github/workflows/push.yaml | 8 +++++++- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/.github/workflows/branch-preview.yaml b/.github/workflows/branch-preview.yaml index cb3a252e..23f2808b 100644 --- a/.github/workflows/branch-preview.yaml +++ b/.github/workflows/branch-preview.yaml @@ -390,7 +390,13 @@ jobs: with: subject-name: index.docker.io/${{ matrix.image_repo }} subject-digest: ${{ steps.build_and_push.outputs.digest }} - push-to-registry: true + # push-to-registry: false (default). attest-build-provenance@v4 also pushes + # the attestation to the OCI registry under the same .att tag that cosign + # attest --type cyclonedx writes, silently overwriting the SBOM attestation. + # Provenance is published to the GitHub attestation API instead (queryable + # via `gh attestation verify`); SBOM attestation stays in the registry for + # `cosign verify-attestation --type cyclonedx`. Validated 2026-05-15 against + # preview run 25910386532 — cosign verify-attestation now resolves both. - name: Record image digest for verify-attestations if: steps.build_and_push.outcome == 'success' env: diff --git a/.github/workflows/build-staging.yml b/.github/workflows/build-staging.yml index ff746634..9ea2d47c 100644 --- a/.github/workflows/build-staging.yml +++ b/.github/workflows/build-staging.yml @@ -179,7 +179,13 @@ jobs: with: subject-name: index.docker.io/simplecontainer/github-actions subject-digest: ${{ steps.build_gha_staging.outputs.digest }} - push-to-registry: true + # push-to-registry: false (default). attest-build-provenance@v4 also pushes + # the attestation to the OCI registry under the same .att tag that cosign + # attest --type cyclonedx writes, silently overwriting the SBOM attestation. + # Provenance is published to the GitHub attestation API instead (queryable + # via `gh attestation verify`); SBOM attestation stays in the registry for + # `cosign verify-attestation --type cyclonedx`. Validated 2026-05-15 against + # preview run 25910386532 — cosign verify-attestation now resolves both. - name: SLSA build provenance for caddy staging id: slsa_caddy_staging @@ -189,7 +195,13 @@ jobs: with: subject-name: index.docker.io/simplecontainer/caddy subject-digest: ${{ steps.build_caddy_staging.outputs.digest }} - push-to-registry: true + # push-to-registry: false (default). attest-build-provenance@v4 also pushes + # the attestation to the OCI registry under the same .att tag that cosign + # attest --type cyclonedx writes, silently overwriting the SBOM attestation. + # Provenance is published to the GitHub attestation API instead (queryable + # via `gh attestation verify`); SBOM attestation stays in the registry for + # `cosign verify-attestation --type cyclonedx`. Validated 2026-05-15 against + # preview run 25910386532 — cosign verify-attestation now resolves both. - name: Soft-fail aggregator for staging attestation if: always() diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index c3e2a5c8..3b1e19d5 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -532,7 +532,13 @@ jobs: with: subject-name: index.docker.io/${{ matrix.image_repo }} subject-digest: ${{ steps.build_and_push.outputs.digest }} - push-to-registry: true + # push-to-registry: false (default). attest-build-provenance@v4 also pushes + # the attestation to the OCI registry under the same .att tag that cosign + # attest --type cyclonedx writes, silently overwriting the SBOM attestation. + # Provenance is published to the GitHub attestation API instead (queryable + # via `gh attestation verify`); SBOM attestation stays in the registry for + # `cosign verify-attestation --type cyclonedx`. Validated 2026-05-15 against + # preview run 25910386532 — cosign verify-attestation now resolves both. # Record the immutable digest so verify-attestations can verify by # @sha256:... instead of by mutable tag (closes the verify-by-tag TOCTOU # window flagged in PR #257 round-1 review). From 35bffc9be9ae3355c7deaf124bc0e713ae18fd14 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Fri, 15 May 2026 19:16:21 +0400 Subject: [PATCH 07/15] =?UTF-8?q?fix(ci):=20SBOM=20sidecar=20filename=20?= =?UTF-8?q?=E2=86=92=20.sbom.cdx.json=20(was=20missing=20.tar.gz)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Naming inconsistency caught during preview-run-2 verification (25920417671): the SBOM sidecar was named sc---v.sbom.cdx.json, whereas the other three sidecars all carry the .tar.gz prefix: sc---v.tar.gz sc---v.tar.gz.sha256 sc---v.tar.gz.cosign-bundle sc---v.tar.gz.sigstore.json sc---v.sbom.cdx.json ← inconsistent Consumers reasonably guessed the SBOM at .sbom.cdx.json (per the other three sidecars' pattern) and 404'd. Renamed at the syft output step + propagated through the upload-artifact globs, docker-finalize copy, branch-preview publish-sc-preview copy, and the sidecar count-assertion glob. Net: every tarball's four sidecars now share the same `.` prefix. Refs PR #257, preview run 25920417671. Signed-off-by: Dmitrii Creed --- .github/workflows/branch-preview.yaml | 6 +++--- .github/workflows/push.yaml | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/branch-preview.yaml b/.github/workflows/branch-preview.yaml index 23f2808b..843d8ecf 100644 --- a/.github/workflows/branch-preview.yaml +++ b/.github/workflows/branch-preview.yaml @@ -186,7 +186,7 @@ jobs: set -euo pipefail cd .sc/stacks/dist/bundle syft scan "dir:../../../../dist/${GOOS}-${GOARCH}" \ - -o "cyclonedx-json=sc-${GOOS}-${GOARCH}-v${VERSION}.sbom.cdx.json" + -o "cyclonedx-json=sc-${GOOS}-${GOARCH}-v${VERSION}.tar.gz.sbom.cdx.json" - name: Cosign sign-blob (keyless, bundle) for sc-${{ matrix.os }}-${{ matrix.arch }} id: cosign_sign_tarball continue-on-error: true @@ -225,7 +225,7 @@ jobs: .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}-v*.tar.gz.sha256 .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}-v*.tar.gz.cosign-bundle .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}-v*.tar.gz.sigstore.json - .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}-v*.sbom.cdx.json + .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}-v*.tar.gz.sbom.cdx.json retention-days: 1 build-binaries: @@ -482,7 +482,7 @@ jobs: artifacts/sc-*/*-v${VERSION}.tar.gz.sha256 \ artifacts/sc-*/*-v${VERSION}.tar.gz.cosign-bundle \ artifacts/sc-*/*-v${VERSION}.tar.gz.sigstore.json \ - artifacts/sc-*/*-v${VERSION}.sbom.cdx.json; do + artifacts/sc-*/*-v${VERSION}.tar.gz.sbom.cdx.json; do cp "$f" .sc/stacks/dist/bundle/ done echo "Bundle contents:" diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index 3b1e19d5..7322dc16 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -194,9 +194,9 @@ jobs: set -euo pipefail cd .sc/stacks/dist/bundle syft scan "dir:../../../../dist/${GOOS}-${GOARCH}" \ - -o "cyclonedx-json=sc-${GOOS}-${GOARCH}-v${VERSION}.sbom.cdx.json" - cp "sc-${GOOS}-${GOARCH}-v${VERSION}.sbom.cdx.json" \ - "sc-${GOOS}-${GOARCH}.sbom.cdx.json" + -o "cyclonedx-json=sc-${GOOS}-${GOARCH}-v${VERSION}.tar.gz.sbom.cdx.json" + cp "sc-${GOOS}-${GOARCH}-v${VERSION}.tar.gz.sbom.cdx.json" \ + "sc-${GOOS}-${GOARCH}.tar.gz.sbom.cdx.json" - name: Cosign sign-blob (keyless, bundle) for sc-${{ matrix.os }}-${{ matrix.arch }} id: cosign_sign_tarball continue-on-error: true @@ -273,7 +273,7 @@ jobs: .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}*.tar.gz.sha256 .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}*.tar.gz.cosign-bundle .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}*.tar.gz.sigstore.json - .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}*.sbom.cdx.json + .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}*.tar.gz.sbom.cdx.json retention-days: 1 build-binaries: @@ -634,7 +634,7 @@ jobs: artifacts/sc-*/*.tar.gz.sha256 \ artifacts/sc-*/*.tar.gz.cosign-bundle \ artifacts/sc-*/*.tar.gz.sigstore.json \ - artifacts/sc-*/*.sbom.cdx.json; do + artifacts/sc-*/*.tar.gz.sbom.cdx.json; do cp "$f" .sc/stacks/dist/bundle/ done - name: Sidecar count assertion (Phase 2 bake-in — warn-only) @@ -646,7 +646,7 @@ jobs: arr_shas=( sc-*.tar.gz.sha256 ); shas=${#arr_shas[@]} arr_bundles=( sc-*.tar.gz.cosign-bundle ); bundles=${#arr_bundles[@]} arr_provs=( sc-*.tar.gz.sigstore.json ); provs=${#arr_provs[@]} - arr_sboms=( sc-*.sbom.cdx.json ); sboms=${#arr_sboms[@]} + arr_sboms=( sc-*.tar.gz.sbom.cdx.json ); sboms=${#arr_sboms[@]} echo "tarballs=$tarballs sha256=$shas cosign-bundle=$bundles slsa=$provs sbom=$sboms" if [ "$tarballs" -eq 0 ]; then echo "::error title=No tarballs to publish::dist bundle has zero tarballs" From 9d87017b0510fa50543e05c366c821a0d69dd8f6 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Fri, 15 May 2026 20:29:51 +0400 Subject: [PATCH 08/15] fix(ci): tighten verify-attestations.yml trust-root + add SBOM verify (round-4 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-4 codex + gemini logic review converged on real gaps in verify-attestations.yml. All applied: * P1 (both) — Identity regex was `(push|branch-preview)\.yaml?@refs/heads/.+` for ALL paths. That accepts a branch-preview signature when verifying the production release, breaking the strict prod trust root documented in SECURITY.md. Replaced with three per-root regexes (PROD / STAGING / PREVIEW), and a Resolve-trust-root step that picks the correct one based on the trigger: - workflow_run: dispatched workflow name → root - schedule: prod (drift detection on the live release) - workflow_dispatch: input choice (prod / staging / preview) * P1 (gemini) — SBOM verification was missing. cosign-attest could soft-fail at sign time during the bake-in and we would never notice. Added `cosign verify-attestation --type cyclonedx` to the per-image loop, alongside the cosign sig check and gh attestation verify. * P1 (gemini) — `gh attestation verify` was called with `--repo` only, which accepts any workflow's attestation from the org. Added `--cert-identity-regex $IDENTITY_REGEX --cert-oidc-issuer $OIDC_ISSUER` so the GH-API path enforces the same identity binding as cosign does. * P2 (codex) — Version resolution fell back to dist/version even when triggered by branch-preview.yaml. dist/version deliberately stays at the latest prod release; the verify would target the wrong artifacts. For preview, derive the version by querying the git-tag list for the preview tag whose suffix matches the triggering run's head_sha. * P2 (codex) — Unversioned tarball lookup runs only for prod. Preview publishes only versioned; trying the empty variant would always 404. Switched the variants list to be trust-root scoped. * P2 (gemini) — Manual-dispatch fallback for preview hit the prod image list (kubectl, caddy + ga + cloud-helpers) instead of what preview actually publishes (ga + cloud-helpers). Tag-resolve fallback now branches on TRUST_ROOT. Out-of-scope (documented, not fixed here): * sc.sh bootstrap bypass (gemini P2) — `curl | bash` of sc.sh itself can be tampered. Closed by PR 2c (sc.sh verify-before-extract). * Mutable Docker tag pulls (gemini P2) — covered in SECURITY.md "Composite-action consumers — SHA-pin the underlying image". Velocity claim (+5-8% wall-clock vs baseline) was explicitly endorsed by gemini in this round. Refs PR #257. Signed-off-by: Dmitrii Creed --- .github/workflows/verify-attestations.yml | 229 +++++++++++++++++----- 1 file changed, 176 insertions(+), 53 deletions(-) diff --git a/.github/workflows/verify-attestations.yml b/.github/workflows/verify-attestations.yml index 78370a3f..07d9e6c2 100644 --- a/.github/workflows/verify-attestations.yml +++ b/.github/workflows/verify-attestations.yml @@ -14,12 +14,21 @@ on: - cron: '17 9 * * 1' # Mondays 09:17 UTC workflow_dispatch: inputs: + trust_root: + description: 'Which trust root to verify against' + required: true + type: choice + default: 'prod' + options: + - prod + - staging + - preview version: - description: 'Version to verify (e.g. 2026.5.1 or 2026.5.1-preview.abc1234). Defaults to whatever `dist.simple-container.com/version` currently advertises.' + description: 'Version to verify (e.g. 2026.5.1 or 2026.5.1-preview.abc1234). For prod, defaults to dist/version.' required: false default: '' verify_tarballs: - description: 'Verify tarballs on dist.simple-container.com (off for fresh preview builds that haven''t deployed yet)' + description: 'Verify tarballs on dist.simple-container.com' required: false default: 'true' @@ -41,11 +50,15 @@ jobs: actions: read # download-artifact from triggering workflow_run attestations: read # gh attestation verify env: - # Production trust root — must match SECURITY.md "Identity regex contract". - PROD_IDENTITY_REGEX: '^https://github\.com/simple-container-com/api/\.github/workflows/(push|branch-preview)\.yaml?@refs/heads/.+$' OIDC_ISSUER: 'https://token.actions.githubusercontent.com' DIST_BASE: 'https://dist.simple-container.com' GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Strict per-root regexes. Consumers verifying via SECURITY.md docs MUST + # see the same string. Updating any of these is a coordinated change + # gated by CODEOWNERS on .github/workflows + SECURITY.md. + PROD_IDENTITY_REGEX: '^https://github\.com/simple-container-com/api/\.github/workflows/push\.yaml@refs/heads/main$' + STAGING_IDENTITY_REGEX: '^https://github\.com/simple-container-com/api/\.github/workflows/build-staging\.yml@refs/heads/staging$' + PREVIEW_IDENTITY_REGEX: '^https://github\.com/simple-container-com/api/\.github/workflows/branch-preview\.yaml@refs/heads/.+$' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -54,31 +67,52 @@ jobs: - name: Install attestation tools uses: ./.github/actions/install-attest-tools - - name: Resolve target version - id: version + - name: Resolve trust root + id: trust + env: + UPSTREAM_WORKFLOW_NAME: ${{ github.event.workflow_run.name }} + DISPATCH_TRUST_ROOT: ${{ github.event.inputs.trust_root }} + EVENT_NAME: ${{ github.event_name }} run: | set -euo pipefail - # Priority: workflow_dispatch input → workflow_run head version (best - # effort, parsed from prepare-job output if accessible) → fall back to - # the unversioned `version` file on dist. - INPUT_VERSION='${{ github.event.inputs.version }}' - if [ -n "$INPUT_VERSION" ]; then - echo "version=$INPUT_VERSION" >> "$GITHUB_OUTPUT" - echo "Using workflow_dispatch input version: $INPUT_VERSION" - exit 0 - fi - # workflow_run event: try to read the version artifact from the - # triggering run, otherwise read dist/version (which the deploy step - # updates immediately after a successful release). - DIST_VERSION=$(curl -fsSL "$DIST_BASE/version" 2>/dev/null | head -n1 | tr -d '\r\n[:space:]') - if [ -z "$DIST_VERSION" ]; then - echo "::error title=verify-attestations::could not resolve target version" - exit 1 - fi - echo "version=$DIST_VERSION" >> "$GITHUB_OUTPUT" - echo "Using dist version: $DIST_VERSION" + case "$EVENT_NAME" in + workflow_dispatch) + root="$DISPATCH_TRUST_ROOT" + ;; + workflow_run) + case "$UPSTREAM_WORKFLOW_NAME" in + 'Build simple-container-com CLI') root=prod ;; + 'Build and publish preview version') root=preview ;; + *) + echo "::error title=verify-attestations::unknown upstream workflow '$UPSTREAM_WORKFLOW_NAME'" + exit 1 + ;; + esac + ;; + schedule) + # Weekly drift detection always targets production. + root=prod + ;; + *) + echo "::error::unsupported event $EVENT_NAME" + exit 1 + ;; + esac + case "$root" in + prod) regex="$PROD_IDENTITY_REGEX"; have_tarballs=true ;; + staging) regex="$STAGING_IDENTITY_REGEX"; have_tarballs=false ;; + preview) regex="$PREVIEW_IDENTITY_REGEX"; have_tarballs=true ;; + *) echo "::error::unknown trust root '$root'"; exit 1 ;; + esac + { + echo "trust_root=$root" + echo "identity_regex=$regex" + echo "have_tarballs=$have_tarballs" + } >> "$GITHUB_OUTPUT" + echo "Resolved trust_root=$root have_tarballs=$have_tarballs" + echo "identity=$regex" - - name: Download image digests from triggering workflow_run (best-effort) + - name: Download per-image digests from triggering workflow_run id: download_digests if: github.event_name == 'workflow_run' continue-on-error: true @@ -90,40 +124,110 @@ jobs: run-id: ${{ github.event.workflow_run.id }} github-token: ${{ secrets.GITHUB_TOKEN }} - - name: Verify image signatures + provenance + - name: Resolve target version + id: version + env: + INPUT_VERSION: ${{ github.event.inputs.version }} + TRUST_ROOT: ${{ steps.trust.outputs.trust_root }} + UPSTREAM_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + set -euo pipefail + if [ -n "$INPUT_VERSION" ]; then + echo "version=$INPUT_VERSION" >> "$GITHUB_OUTPUT" + echo "Using workflow_dispatch input version: $INPUT_VERSION" + exit 0 + fi + if [ "$TRUST_ROOT" = "preview" ]; then + # branch-preview.yaml pushes a `v-preview.` git + # tag at the end of publish-git-tag. Derive the short SHA from the + # triggering workflow_run's head_sha so we verify the SAME preview + # version that just shipped, not whatever dist/version currently + # points at (which deliberately stays at the latest production + # release since preview never updates the dist `version` file). + if [ -z "${UPSTREAM_HEAD_SHA:-}" ]; then + echo "::error title=verify-attestations::workflow_run.head_sha missing for preview verification" + exit 1 + fi + short_sha=$(printf '%s' "$UPSTREAM_HEAD_SHA" | cut -c1-7) + tag=$(gh api "repos/${GITHUB_REPOSITORY}/git/refs/tags" --paginate \ + --jq "[.[] | .ref | ltrimstr(\"refs/tags/v\")] | map(select(contains(\"preview.${short_sha}\"))) | last") + if [ -z "$tag" ] || [ "$tag" = "null" ]; then + echo "::error title=verify-attestations::could not resolve preview tag for head_sha=${short_sha}" + exit 1 + fi + echo "version=$tag" >> "$GITHUB_OUTPUT" + echo "Resolved preview version from git tag: $tag" + exit 0 + fi + # prod or staging: dist/version is the canonical pointer. + ver=$(curl -fsSL "$DIST_BASE/version" 2>/dev/null | head -n1 | tr -d '\r\n[:space:]' || true) + if [ -z "$ver" ]; then + echo "::error title=verify-attestations::could not resolve target version from $DIST_BASE/version" + exit 1 + fi + echo "version=$ver" >> "$GITHUB_OUTPUT" + echo "Resolved $TRUST_ROOT version from dist/version: $ver" + + - name: Verify image signatures + SBOM attestations + provenance id: verify_images continue-on-error: true env: VERSION: ${{ steps.version.outputs.version }} + IDENTITY_REGEX: ${{ steps.trust.outputs.identity_regex }} + TRUST_ROOT: ${{ steps.trust.outputs.trust_root }} run: | set -uo pipefail fail=0 - # Prefer immutable digests recovered from the build run; fall back to - # version tags on Docker Hub if the artifact pattern didn't match (eg. - # scheduled cron, or a workflow_run from before this PR). shopt -s nullglob digest_files=( image-digests/*.txt ) if [ "${#digest_files[@]}" -gt 0 ]; then mapfile -t refs < <(cat image-digests/*.txt | sort -u) - echo "verifying ${#refs[@]} images by digest (recovered from upstream artifacts)" + echo "Verifying ${#refs[@]} image(s) by digest (recovered from upstream artifacts)" else - refs=( - "simplecontainer/kubectl:${VERSION}" - "simplecontainer/caddy:${VERSION}" - "simplecontainer/github-actions:${VERSION}" - "simplecontainer/cloud-helpers:aws-${VERSION}" - ) - echo "verifying ${#refs[@]} images by tag (artifact pattern unavailable; tag-resolve fallback)" + # Tag-resolve fallback scoped to what each trust root actually + # publishes — avoids 'no manifest' failures on cron / dispatch. + case "$TRUST_ROOT" in + prod) + refs=( + "simplecontainer/kubectl:${VERSION}" + "simplecontainer/caddy:${VERSION}" + "simplecontainer/github-actions:${VERSION}" + "simplecontainer/cloud-helpers:aws-${VERSION}" + ) + ;; + preview) + refs=( + "simplecontainer/github-actions:${VERSION}" + "simplecontainer/cloud-helpers:aws-${VERSION}" + ) + ;; + staging) + refs=( + "simplecontainer/github-actions:staging" + "simplecontainer/caddy:staging" + ) + ;; + esac + echo "Verifying ${#refs[@]} image(s) by tag (tag-resolve fallback)" fi for img in "${refs[@]}"; do [ -z "$img" ] && continue - echo "::group::verify $img" + echo "::group::$img" + # 1. Image signature (cosign keyless). cosign verify "$img" \ - --certificate-identity-regexp "$PROD_IDENTITY_REGEX" \ + --certificate-identity-regexp "$IDENTITY_REGEX" \ --certificate-oidc-issuer "$OIDC_ISSUER" >/dev/null \ || { echo "::warning title=cosign verify failed::$img"; fail=1; } + # 2. CycloneDX SBOM attestation (registry slot, owned by cosign). + cosign verify-attestation "$img" --type cyclonedx \ + --certificate-identity-regexp "$IDENTITY_REGEX" \ + --certificate-oidc-issuer "$OIDC_ISSUER" >/dev/null \ + || { echo "::warning title=cosign verify-attestation cyclonedx failed::$img"; fail=1; } + # 3. SLSA build provenance (GH attestation API). gh attestation verify "oci://${img}" \ - --repo simple-container-com/api >/dev/null \ + --repo simple-container-com/api \ + --cert-identity-regex "$IDENTITY_REGEX" \ + --cert-oidc-issuer "$OIDC_ISSUER" >/dev/null \ || { echo "::warning title=gh attestation verify failed::$img"; fail=1; } echo "::endgroup::" done @@ -131,22 +235,35 @@ jobs: - name: Verify tarball signatures + provenance id: verify_tarballs - if: github.event.inputs.verify_tarballs != 'false' + if: | + github.event.inputs.verify_tarballs != 'false' && + steps.trust.outputs.have_tarballs == 'true' continue-on-error: true env: VERSION: ${{ steps.version.outputs.version }} + IDENTITY_REGEX: ${{ steps.trust.outputs.identity_regex }} + TRUST_ROOT: ${{ steps.trust.outputs.trust_root }} run: | set -uo pipefail fail=0 tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT + # Variant set is trust-root dependent: prod ships {versioned, unversioned}; + # preview ships only versioned. Unversioned variant ALWAYS verifies + # against the unversioned tarball, which dist serves as the + # latest-pointer alias for the most recent prod release. + case "$TRUST_ROOT" in + prod) variants=( "-v${VERSION}" "" ) ;; + preview) variants=( "-v${VERSION}" ) ;; + *) variants=( ) ;; + esac for plat in linux-amd64 darwin-arm64 darwin-amd64; do - for variant in "-v${VERSION}" ""; do + for variant in "${variants[@]}"; do tarball="sc-${plat}${variant}.tar.gz" url="${DIST_BASE}/${tarball}" - echo "::group::verify ${tarball}" - if ! curl -fsSL "${url}" -o "${tmp}/${tarball}" \ - || ! curl -fsSL "${url}.sha256" -o "${tmp}/${tarball}.sha256" \ + echo "::group::${tarball}" + if ! curl -fsSL "${url}" -o "${tmp}/${tarball}" \ + || ! curl -fsSL "${url}.sha256" -o "${tmp}/${tarball}.sha256" \ || ! curl -fsSL "${url}.cosign-bundle" -o "${tmp}/${tarball}.cosign-bundle" \ || ! curl -fsSL "${url}.sigstore.json" -o "${tmp}/${tarball}.sigstore.json"; then echo "::warning title=sidecar download failed::${tarball}" @@ -158,13 +275,15 @@ jobs: || { echo "::warning::sha256 mismatch ${tarball}"; fail=1; } cosign verify-blob \ --bundle "${tmp}/${tarball}.cosign-bundle" \ - --certificate-identity-regexp "$PROD_IDENTITY_REGEX" \ + --certificate-identity-regexp "$IDENTITY_REGEX" \ --certificate-oidc-issuer "$OIDC_ISSUER" \ "${tmp}/${tarball}" >/dev/null \ || { echo "::warning::cosign verify-blob failed ${tarball}"; fail=1; } gh attestation verify "${tmp}/${tarball}" \ --bundle "${tmp}/${tarball}.sigstore.json" \ - --repo simple-container-com/api >/dev/null \ + --repo simple-container-com/api \ + --cert-identity-regex "$IDENTITY_REGEX" \ + --cert-oidc-issuer "$OIDC_ISSUER" >/dev/null \ || { echo "::warning::gh attestation verify failed ${tarball}"; fail=1; } echo "::endgroup::" done @@ -176,13 +295,17 @@ jobs: env: IMG_OUTCOME: ${{ steps.verify_images.outcome }} TARBALL_OUTCOME: ${{ steps.verify_tarballs.outcome }} + TRUST_ROOT: ${{ steps.trust.outputs.trust_root }} + VERSION: ${{ steps.version.outputs.version }} run: | + echo "trust_root=$TRUST_ROOT version=$VERSION images=$IMG_OUTCOME tarballs=$TARBALL_OUTCOME" fail=0 - for v in "$IMG_OUTCOME" "$TARBALL_OUTCOME"; do - if [ "$v" = "failure" ]; then fail=1; fi - done + if [ "$IMG_OUTCOME" = "failure" ]; then fail=1; fi + # tarball step skipped for staging (have_tarballs=false) or when + # verify_tarballs=false; treat skipped as not a failure. + if [ "$TARBALL_OUTCOME" = "failure" ]; then fail=1; fi if [ "$fail" -eq 1 ]; then - echo "::error title=Post-publish verification incomplete::See ::warning:: annotations above. Phase 2 bake-in: this surfaces the regression without blocking the release that already shipped." + echo "::error title=Post-publish verification incomplete::trust_root=$TRUST_ROOT version=$VERSION — see ::warning:: annotations above. Bake-in policy: this surfaces the regression without blocking the release that already shipped." exit 1 fi - echo "All published attestations verified." + echo "All published attestations verified ($TRUST_ROOT $VERSION)." From f754aa8d2682487d8ee2be1c2fae4a940f8a3b25 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Fri, 15 May 2026 20:49:06 +0400 Subject: [PATCH 09/15] =?UTF-8?q?fix(docs+ci):=20thorough-review=20finding?= =?UTF-8?q?s=20=E2=80=94=20consumer-facing=20doc=20accuracy=20+=20step-out?= =?UTF-8?q?put=20for=20VERSION?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thorough-review (tier=large) round on top of round-4 logic review. Pre-flight (actionlint + SC semgrep ruleset) returned clean on the round-4 code; only INFO-level shellcheck pre-existing on main. Applied from the LLM pass: * P1 (codex + gemini agree) — SECURITY.md `gh attestation verify` examples only pinned `--repo simple-container-com/api`, accepting signatures from ANY workflow in the org repo. Added the matching `--cert-identity-regex` + `--cert-oidc-issuer` flags to BOTH examples (image verify + tarball verify), so the consumer docs enforce the same strict identity contract documented in the "Identity-regex contract" table just above. * P2 (codex) — SECURITY.md described production image tags as `:vYYYY.M.x` / `:aws-vYYYY.M.x`. The actual tags pushed by push.yaml are `:YYYY.M.x` / `:aws-YYYY.M.x` (no `v` prefix — `v` is only used on git tags and tarball filenames). Fixed in the trust-root table + the `crane digest` example. * P2 (codex) — SECURITY.md SIMPLE_CONTAINER_VERSION example said `SIMPLE_CONTAINER_VERSION=vYYYY.M.x`. sc.sh constructs the URL as `sc--v${SIMPLE_CONTAINER_VERSION}.tar.gz`, so passing the `v` prefix yields `-vvYYYY.M.x` and a 404. Documented as bare `YYYY.M.x` with the note that sc.sh adds the prefix itself. * P2 (codex) — build-staging.yml passed VERSION to a `with:` block via `${{ env.VERSION }}`, populated via `$GITHUB_ENV`. While GitHub Actions DOES propagate `$GITHUB_ENV` writes into the env context for later steps, actionlint cannot prove it statically and the path has not been empirically validated (build-staging.yml has no recent runs from this branch). Refactored to a step output (`steps.version.outputs.version`) which is unambiguously correct and silences the IDE warning. The `$GITHUB_ENV` write is preserved for the existing shell-only steps that reference `$VERSION`. Defended (no change): * 4 of 5 gemini P0s — same "space around @" hallucination pattern as round 1 (verified by direct grep against the files; tokens like `${{ matrix.image_repo }}@${{ steps.build_and_push.outputs.digest }}`, `printf '%s@%s\n'`, and `uses: @` are all space-free on disk). * Cosign sign/attest idempotency on workflow re-runs — by design. Keyless cosign signs with a fresh OIDC cert per run; re-runs add NEW immutable Rekor entries rather than overwriting. Consumer verification accepts any valid sig under the documented identity. Refs PR #257, thorough-review pass. Signed-off-by: Dmitrii Creed --- .github/workflows/build-staging.yml | 12 ++++++++++-- SECURITY.md | 23 ++++++++++++++++------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build-staging.yml b/.github/workflows/build-staging.yml index 9ea2d47c..374f2902 100644 --- a/.github/workflows/build-staging.yml +++ b/.github/workflows/build-staging.yml @@ -30,9 +30,17 @@ jobs: go-version: '1.24' - name: Set version for build + id: version run: | + # Expose VERSION via BOTH $GITHUB_OUTPUT (consumed by `with:` blocks + # of later steps — `env.VERSION` would also work but actionlint + # cannot statically prove a $GITHUB_ENV-set var is present, so the + # step-output form keeps the IDE clean) AND $GITHUB_ENV (so the + # existing shell-only steps that reference $VERSION as a bare env + # var keep working). VERSION="staging-${{ github.run_number }}-$(date -u +%Y%m%d-%H%M%S)" - echo "VERSION=$VERSION" >> $GITHUB_ENV + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" echo "🏷️ Set VERSION: $VERSION" - name: Cache CLI tools (SC + Welder) @@ -105,7 +113,7 @@ jobs: platforms: linux/amd64 tags: | simplecontainer/caddy:staging - simplecontainer/caddy:${{ env.VERSION }} + simplecontainer/caddy:${{ steps.version.outputs.version }} push: true cache-from: type=gha cache-to: type=gha,mode=max diff --git a/SECURITY.md b/SECURITY.md index 3c4ad4db..2552e3a3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -86,7 +86,7 @@ identities; **do not mix them**. | Trust root | Subject regex | Use for | |---|---|---| -| **Production** | `^https://github\.com/simple-container-com/api/\.github/workflows/push\.yaml@refs/heads/main$` | `sc.sh` installs; production Docker images (`:latest`, `:vYYYY.M.x`, `:aws-vYYYY.M.x`); release tarballs | +| **Production** | `^https://github\.com/simple-container-com/api/\.github/workflows/push\.yaml@refs/heads/main$` | `sc.sh` installs; production Docker images (`:latest`, `:YYYY.M.x`, `:aws-latest`, `:aws-YYYY.M.x`); release tarballs (`sc-…-vYYYY.M.x.tar.gz`) | | **Staging** | `^https://github\.com/simple-container-com/api/\.github/workflows/build-staging\.yml@refs/heads/staging$` | Consumers who **knowingly opt in** to `:staging` images via composite actions | | OIDC issuer (both) | `https://token.actions.githubusercontent.com` | — | @@ -103,14 +103,20 @@ because we publish provenance through `actions/attest-build-provenance@v4` ```bash IMG=docker.io/simplecontainer/github-actions -DIGEST=$(crane digest "$IMG:vYYYY.M.x") # pin to the immutable digest +# Image tags do NOT carry a `v` prefix (only git tags + tarballs do): +# simplecontainer/github-actions:YYYY.M.x +# simplecontainer/cloud-helpers:aws-YYYY.M.x +DIGEST=$(crane digest "$IMG:YYYY.M.x") # pin to the immutable digest cosign verify "$IMG@$DIGEST" \ --certificate-identity-regexp '^https://github\.com/simple-container-com/api/\.github/workflows/push\.yaml@refs/heads/main$' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com cosign verify-attestation "$IMG@$DIGEST" --type cyclonedx \ --certificate-identity-regexp '^https://github\.com/simple-container-com/api/\.github/workflows/push\.yaml@refs/heads/main$' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com -gh attestation verify "oci://$IMG@$DIGEST" --repo simple-container-com/api +gh attestation verify "oci://$IMG@$DIGEST" \ + --repo simple-container-com/api \ + --cert-identity-regex '^https://github\.com/simple-container-com/api/\.github/workflows/push\.yaml@refs/heads/main$' \ + --cert-oidc-issuer https://token.actions.githubusercontent.com ``` ### Verifying tarballs @@ -129,7 +135,9 @@ cosign verify-blob --bundle "$T.cosign-bundle" \ --certificate-identity-regexp '^https://github\.com/simple-container-com/api/\.github/workflows/push\.yaml@refs/heads/main$' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com "$T" gh attestation verify "$T" --bundle "$T.sigstore.json" \ - --repo simple-container-com/api + --repo simple-container-com/api \ + --cert-identity-regex '^https://github\.com/simple-container-com/api/\.github/workflows/push\.yaml@refs/heads/main$' \ + --cert-oidc-issuer https://token.actions.githubusercontent.com ``` `sc.sh` will run the tarball steps automatically when `cosign` is on @@ -175,9 +183,10 @@ known-vulnerable. Mitigation in this phase: `sc.sh` (Phase-2 PR 2c) defaults to fetching the **latest version** from a signed `version` manifest, not the unversioned tarball. Consumers who set -`SIMPLE_CONTAINER_VERSION=vYYYY.M.x` get the explicit version they -asked for; consumers who do not set it get the version the manifest -declares current. +`SIMPLE_CONTAINER_VERSION=YYYY.M.x` (bare; `sc.sh` adds the `v` +prefix itself when constructing the download URL) get the explicit +version they asked for; consumers who do not set it get the version +the manifest declares current. This residual risk is closed by TUF/RSTUF in Phase 6. From 71e6f1fce678a2baffb9f0c8315950700ae51274 Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Fri, 15 May 2026 23:33:23 +0400 Subject: [PATCH 10/15] chore(docs): move SECURITY.md to docs/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub recognises `SECURITY.md` at root, `.github/SECURITY.md`, and `docs/SECURITY.md` as equivalent locations for the security-policy discovery (https://docs.github.com/en/code-security/getting-started/adding-a-security-policy-to-your-repository). OpenSSF Scorecard's `Security-Policy` check accepts the same three paths. No compliance framework constrains location, only existence + content. This repo's mkdocs build reads from `docs/docs/` (not `docs/`), so `docs/SECURITY.md` does NOT get auto-rendered into the public docs site — it stays a pure GitHub-policy file that just happens to sit under `docs/`. Also: - Dropped the broken `[HARDENING.md](../HARDENING.md)` link inside the policy. That tracker is private (not committed), so the link was a 404 regardless of where SECURITY.md sat. Rephrased the surrounding sentence to be self-contained. - Updated CODEOWNERS path: `/SECURITY.md` → `/docs/SECURITY.md`. Refs PR #257. Signed-off-by: Dmitrii Creed --- CODEOWNERS | 2 +- SECURITY.md => docs/SECURITY.md | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) rename SECURITY.md => docs/SECURITY.md (98%) diff --git a/CODEOWNERS b/CODEOWNERS index 6de21dfe..bd4ca2d2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -78,5 +78,5 @@ # --------------------------------------------------------------------- # This policy + ownership file itself. # --------------------------------------------------------------------- -/SECURITY.md @Cre-eD @smecsia +/docs/SECURITY.md @Cre-eD @smecsia /CODEOWNERS @Cre-eD @smecsia diff --git a/SECURITY.md b/docs/SECURITY.md similarity index 98% rename from SECURITY.md rename to docs/SECURITY.md index 2552e3a3..42e8eddd 100644 --- a/SECURITY.md +++ b/docs/SECURITY.md @@ -141,9 +141,8 @@ gh attestation verify "$T" --bundle "$T.sigstore.json" \ ``` `sc.sh` will run the tarball steps automatically when `cosign` is on -`PATH` — that integration lands in the follow-up PR (see -[`HARDENING.md`](../HARDENING.md) Phase 2 plan; until it merges, the -commands above are the manual verification path). +`PATH` — that integration lands in a follow-up PR. Until it merges, +the commands above are the manual verification path. ### Composite-action consumers — SHA-pin the underlying image From 581fea77a648ad9a21e6425f3484e7a99798b78d Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Sat, 16 May 2026 00:43:47 +0400 Subject: [PATCH 11/15] feat(test): scripts/verify-preview.sh + negative_tests workflow input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Materializes the Phase 2 preview-build verification plan as both a dev-side script and a CI-side workflow_dispatch path. scripts/verify-preview.sh (new): Runnable bash that performs 34 checks against any branch-preview.yaml dispatch — workflow smoke, per-image cosign sig + SBOM attest + SLSA provenance, per-tarball sha256 + cosign-blob + gh attestation + SBOM well-formedness, and negative trust-root tests (a preview signature must FAIL against the prod and staging regexes; trust-root-leak regression guard). Auto-resolves the latest successful preview run on the current branch if no RUN_ID is passed. Prints PASS/FAIL per check, exits non-zero on any FAIL. Header includes the control-mapping (CICD-SEC-9 regression test, NIST SSDF PS.2 executable mechanism, CIS SSCS §5 verified-at-consume, SLSA Build L3 verification dimension, OWASP SCVS V6). .github/workflows/verify-attestations.yml: Adds `negative_tests: bool` workflow_dispatch input (default false). When set, runs a new `negative_tests` step after the main image + tarball verification: picks a representative image for the selected trust root and asserts cosign verify FAILS against each of the other two trust-root regexes. The aggregator counts this step's outcome alongside the existing two. Default-off so the regular workflow_run + cron paths stay fast and focused on positive verification. Devs (or a future hardening pass) flip the input to true to exercise the cross-trust-root separation. Refs PR #257. Signed-off-by: Dmitrii Creed --- .github/workflows/verify-attestations.yml | 59 ++++- scripts/verify-preview.sh | 258 ++++++++++++++++++++++ 2 files changed, 314 insertions(+), 3 deletions(-) create mode 100755 scripts/verify-preview.sh diff --git a/.github/workflows/verify-attestations.yml b/.github/workflows/verify-attestations.yml index 07d9e6c2..7e9cd47e 100644 --- a/.github/workflows/verify-attestations.yml +++ b/.github/workflows/verify-attestations.yml @@ -31,6 +31,10 @@ on: description: 'Verify tarballs on dist.simple-container.com' required: false default: 'true' + negative_tests: + description: 'Run trust-root negative tests (assert artifacts of the selected trust root FAIL to verify against the other two regexes). Regression guard for CICD-SEC-9.' + required: false + default: 'false' permissions: contents: read @@ -290,20 +294,69 @@ jobs: done exit "$fail" + - name: Negative trust-root tests (regression guard for CICD-SEC-9) + id: negative_tests + # Opt-in via workflow_dispatch input (default false). Asserts that an + # artifact signed under the SELECTED trust root fails verification + # under the OTHER two regexes — protects against the trust-root + # regex accidentally getting widened. Mirrors section 5 of + # scripts/verify-preview.sh. + if: github.event.inputs.negative_tests == 'true' + continue-on-error: true + env: + VERSION: ${{ steps.version.outputs.version }} + TRUST_ROOT: ${{ steps.trust.outputs.trust_root }} + run: | + set -uo pipefail + fail=0 + # Pick a representative image for each trust root. + case "$TRUST_ROOT" in + prod) img="simplecontainer/github-actions:${VERSION}" ;; + preview) img="simplecontainer/github-actions:${VERSION}" ;; + staging) img="simplecontainer/github-actions:staging" ;; + esac + # All three regexes, paired with the label we report. + declare -A OTHER_REGEXES + if [ "$TRUST_ROOT" != "prod" ]; then + OTHER_REGEXES["prod"]="$PROD_IDENTITY_REGEX" + fi + if [ "$TRUST_ROOT" != "staging" ]; then + OTHER_REGEXES["staging"]="$STAGING_IDENTITY_REGEX" + fi + if [ "$TRUST_ROOT" != "preview" ]; then + OTHER_REGEXES["preview"]="$PREVIEW_IDENTITY_REGEX" + fi + for other in "${!OTHER_REGEXES[@]}"; do + re="${OTHER_REGEXES[$other]}" + echo "::group::expect FAIL — $img under $other regex" + if cosign verify "$img" \ + --certificate-identity-regexp "$re" \ + --certificate-oidc-issuer "$OIDC_ISSUER" >/dev/null 2>&1; then + echo "::error title=Trust-root leak::$img verified under the $other regex (expected to fail). The $TRUST_ROOT identity-regex contract is weaker than documented in SECURITY.md." + fail=1 + else + echo "OK — correctly rejected" + fi + echo "::endgroup::" + done + exit "$fail" + - name: Aggregate verification results if: always() env: IMG_OUTCOME: ${{ steps.verify_images.outcome }} TARBALL_OUTCOME: ${{ steps.verify_tarballs.outcome }} + NEGATIVE_OUTCOME: ${{ steps.negative_tests.outcome }} TRUST_ROOT: ${{ steps.trust.outputs.trust_root }} VERSION: ${{ steps.version.outputs.version }} run: | - echo "trust_root=$TRUST_ROOT version=$VERSION images=$IMG_OUTCOME tarballs=$TARBALL_OUTCOME" + echo "trust_root=$TRUST_ROOT version=$VERSION images=$IMG_OUTCOME tarballs=$TARBALL_OUTCOME negative=$NEGATIVE_OUTCOME" fail=0 if [ "$IMG_OUTCOME" = "failure" ]; then fail=1; fi - # tarball step skipped for staging (have_tarballs=false) or when - # verify_tarballs=false; treat skipped as not a failure. + # tarball + negative steps skip when not applicable; treat skipped + # as not a failure (only explicit "failure" outcomes count). if [ "$TARBALL_OUTCOME" = "failure" ]; then fail=1; fi + if [ "$NEGATIVE_OUTCOME" = "failure" ]; then fail=1; fi if [ "$fail" -eq 1 ]; then echo "::error title=Post-publish verification incomplete::trust_root=$TRUST_ROOT version=$VERSION — see ::warning:: annotations above. Bake-in policy: this surfaces the regression without blocking the release that already shipped." exit 1 diff --git a/scripts/verify-preview.sh b/scripts/verify-preview.sh new file mode 100755 index 00000000..72a2f932 --- /dev/null +++ b/scripts/verify-preview.sh @@ -0,0 +1,258 @@ +#!/usr/bin/env bash +# verify-preview.sh — end-to-end verification of a Phase 2 preview release. +# +# Usage: +# scripts/verify-preview.sh [RUN_ID] +# +# RUN_ID — GitHub Actions run id of a `branch-preview.yaml` dispatch. +# If omitted, defaults to the latest successful run on the +# current branch. +# +# Environment: +# GH_TOKEN — must be set; needs `repo:read` + `actions:read` scope +# on simple-container-com/api. +# +# What this script verifies (34 checks total): +# 1. The preview workflow succeeded and every matrix item is green. +# 2. Every published image carries a valid cosign keyless signature +# bound to the PREVIEW trust root. +# 3. Every published image carries a CycloneDX SBOM attestation in +# the registry .att slot, NOT overwritten by SLSA provenance +# (round-1 regression guard for #257). +# 4. Every published image's SLSA L3 provenance verifies via the +# GH-native attestation API. +# 5. Every published tarball has its .sha256, .cosign-bundle, and +# .sigstore.json sidecars reachable on dist + its SBOM sidecar +# is well-formed CycloneDX 1.5. +# 6. Cosign verify-blob succeeds against the .cosign-bundle. +# 7. gh attestation verify succeeds against the .sigstore.json. +# 8. Trust-root separation negative checks: a preview signature +# MUST NOT verify against the PROD identity regex or the STAGING +# identity regex. (Trust-root-leak regression guard.) +# +# Controls strengthened by this script + the matching workflow inputs: +# - CICD-SEC-9 (Improper artifact integrity validation) — gains an +# executable regression test for both positive and negative paths. +# - NIST SSDF PS.2 (Mechanism to verify software integrity) — gives +# PS.2 a runnable, repeatable mechanism rather than only docs. +# - CIS SSCS §5 (Deployment verified at consume) — the documented +# consumer commands become testable; SECURITY.md paste-and-go +# correctness is asserted on every release. +# - SLSA Build L3 verification — proves the spec's verification side +# is achievable in practice, not just claimed. +# - OWASP SCVS V6 (Pedigree + provenance) — verification dimension. +# +# Companion: .github/workflows/verify-attestations.yml runs the same +# checks (minus the negative tests by default; flip the +# `negative_tests` dispatch input to include them in CI). + +set -uo pipefail + +REPO="simple-container-com/api" +DIST_BASE="https://dist.simple-container.com" +OIDC="https://token.actions.githubusercontent.com" + +PREVIEW_RE='^https://github\.com/simple-container-com/api/\.github/workflows/branch-preview\.yaml@refs/heads/.+$' +PROD_RE='^https://github\.com/simple-container-com/api/\.github/workflows/push\.yaml@refs/heads/main$' +STAGING_RE='^https://github\.com/simple-container-com/api/\.github/workflows/build-staging\.yml@refs/heads/staging$' + +PASS=0 +FAIL=0 +FAIL_DETAILS=() + +# ------------------------------------------------------------------ +# Output helpers +# ------------------------------------------------------------------ +log_check() { # log_check "