diff --git a/.github/actions/install-attest-tools/action.yml b/.github/actions/install-attest-tools/action.yml new file mode 100644 index 00000000..60699897 --- /dev/null +++ b/.github/actions/install-attest-tools/action.yml @@ -0,0 +1,34 @@ +name: 'Install attestation tools' +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: + 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' + +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: Verify gh CLI is available (for attestation verify) + shell: bash + run: | + 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 + gh --version | head -n 1 diff --git a/.github/workflows/branch-preview.yaml b/.github/workflows/branch-preview.yaml index e2a95cbc..74ba2c65 100644 --- a/.github/workflows/branch-preview.yaml +++ b/.github/workflows/branch-preview.yaml @@ -20,6 +20,17 @@ jobs: outputs: version: ${{ steps.version.outputs.version }} steps: + # Trust-root ref guard. Preview signing uses cosign keyless identity + # `branch-preview.yaml@refs/heads/` and the documented + # preview verification regex pins `@refs/heads/.+`. workflow_dispatch + # ALSO allows dispatching from a tag (`refs/tags/`), which + # would sign artifacts under an identity that no documented + # verifier accepts — fail fast in that case. + - name: Refuse to run on non-branch ref + if: "!startsWith(github.ref, 'refs/heads/')" + run: | + echo "::error title=branch-preview.yaml requires a branch ref::Got github.ref=$GITHUB_REF; this workflow signs preview artifacts under the preview trust root (refs/heads/* only). Re-dispatch from a branch." + exit 1 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -122,6 +133,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 +170,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}.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 + 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*.tar.gz.sbom.cdx.json retention-days: 1 build-binaries: @@ -219,16 +296,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 +343,104 @@ 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: 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: + 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 +481,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}.tar.gz.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/build-staging.yml b/.github/workflows/build-staging.yml index fe0f81ad..ae862797 100644 --- a/.github/workflows/build-staging.yml +++ b/.github/workflows/build-staging.yml @@ -10,11 +10,26 @@ 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 }} steps: + # Trust-root ref guard — mirrors the same guard in push.yaml. This + # workflow signs `:staging` images under the cosign keyless identity + # `build-staging.yml@refs/heads/staging`. workflow_dispatch from any + # non-staging ref would publish an image signed with a different + # identity that fails consumer verification. + - name: Refuse to run on non-staging ref + if: github.ref != 'refs/heads/staging' + run: | + echo "::error title=build-staging.yml restricted to staging::Got github.ref=$GITHUB_REF; this workflow signs :staging images under the staging trust root (refs/heads/staging only). Re-dispatch from staging." + exit 1 + - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -26,9 +41,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) @@ -77,37 +100,146 @@ 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:${{ steps.version.outputs.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 + 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 + 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: 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 + 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: 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() 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..f02eec35 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -23,6 +23,17 @@ jobs: outputs: version: ${{ steps.version.outputs.version }} steps: + # Trust-root ref guard. push.yaml signs production artifacts under + # the cosign keyless identity `push.yaml@refs/heads/main` (documented + # in docs/SECURITY.md). A `workflow_dispatch` from any non-main ref + # would sign + publish artifacts that fail consumer verification — + # the artifact ships looking-signed but no documented identity regex + # accepts it. Fail fast instead. + - name: Refuse to run on non-main ref + if: github.ref != 'refs/heads/main' + run: | + echo "::error title=push.yaml restricted to main::Got github.ref=$GITHUB_REF; this workflow signs artifacts under the production trust root (refs/heads/main only). Re-dispatch from main or remove workflow_dispatch from the trigger." + exit 1 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -124,6 +135,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 +172,102 @@ 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 + 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 + 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}.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 + 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 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}.sigstore.json" + 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 +278,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, .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.sigstore.json + .sc/stacks/dist/bundle/sc-${{ matrix.os }}-${{ matrix.arch }}*.tar.gz.sbom.cdx.json retention-days: 1 build-binaries: @@ -207,31 +324,10 @@ jobs: path: ${{ matrix.output }} retention-days: 1 - build-github-actions-staging: - name: Build github-actions-staging - runs-on: blacksmith-8vcpu-ubuntu-2204 - needs: [prepare, build-setup] - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Set up Go with Blacksmith caching - uses: useblacksmith/setup-go@647ac649bd5b480f2a262e3e3e5f4d150ed452ad # v6.0.1 - with: - go-version: '1.25' - - name: build github-actions-staging - env: - VERSION: ${{ needs.prepare.outputs.version }} - CGO_ENABLED: "0" - run: | - mkdir -p bin - go build -ldflags "-s -w -X=github.com/simple-container-com/api/internal/build.Version=${VERSION}" -a -installsuffix cgo -o bin/github-actions ./cmd/github-actions - - name: upload github-actions-staging binary - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: github-actions-staging-binary - path: bin/github-actions - retention-days: 1 + # Note (PR #257): the `build-github-actions-staging` job was removed + # alongside its sole consumer — the `github-actions-staging` matrix + # entry in docker-build. The `:staging` image tag is now exclusively + # published by build-staging.yml under the staging trust root. test: name: Run tests @@ -275,30 +371,42 @@ 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 + 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 - 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. - 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 }} @@ -306,12 +414,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 @@ -344,29 +446,128 @@ 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). + # + # `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 + 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 + 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: 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). + - 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.image }}.txt" + - name: Upload digest artifact for ${{ matrix.image }} + 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' + 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) runs-on: blacksmith-8vcpu-ubuntu-2204 - needs: [prepare, build-setup, build-platforms, build-binaries, build-github-actions-staging, test, build-docs, docker-build] + 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: @@ -405,8 +606,53 @@ 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, + # 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.sigstore.json \ + 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) + 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.sigstore.json ); provs=${#arr_provs[@]} + 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" + exit 1 + fi + missing=0 + # 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::${label} 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 }} diff --git a/.github/workflows/verify-attestations.yml b/.github/workflows/verify-attestations.yml new file mode 100644 index 00000000..3fe303f4 --- /dev/null +++ b/.github/workflows/verify-attestations.yml @@ -0,0 +1,371 @@ +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: + 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). For prod, defaults to dist/version.' + required: false + default: '' + verify_tarballs: + 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 + +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: + 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: + persist-credentials: false + + - name: Install attestation tools + uses: ./.github/actions/install-attest-tools + + - 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 + 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 per-image digests from triggering workflow_run + 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: 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 + 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[@]} image(s) by digest (recovered from upstream artifacts)" + else + # 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::$img" + # 1. Image signature (cosign keyless). + cosign verify "$img" \ + --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 \ + --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 + exit "$fail" + + - name: Verify tarball signatures + provenance + id: verify_tarballs + 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 "${variants[@]}"; do + tarball="sc-${plat}${variant}.tar.gz" + url="${DIST_BASE}/${tarball}" + 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" \ + || ! curl -fsSL "${url}.sbom.cdx.json" -o "${tmp}/${tarball}.sbom.cdx.json"; then + echo "::warning title=sidecar download failed::${tarball} (one of tar.gz/.sha256/.cosign-bundle/.sigstore.json/.sbom.cdx.json missing on dist)" + 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 "$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 \ + --cert-identity-regex "$IDENTITY_REGEX" \ + --cert-oidc-issuer "$OIDC_ISSUER" >/dev/null \ + || { echo "::warning::gh attestation verify failed ${tarball}"; fail=1; } + # SBOM sidecar well-formedness (matches scripts/verify-preview.sh + # section 4e). Catches the case where build-platforms' SBOM + # step soft-failed and an empty / malformed file ended up on + # dist; without this the gate would silently pass. + jq -e '.bomFormat == "CycloneDX"' "${tmp}/${tarball}.sbom.cdx.json" >/dev/null \ + || { echo "::warning::SBOM not well-formed CycloneDX ${tarball}.sbom.cdx.json"; fail=1; } + echo "::endgroup::" + done + 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 negative=$NEGATIVE_OUTCOME" + fail=0 + if [ "$IMG_OUTCOME" = "failure" ]; then fail=1; fi + # 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 + fi + echo "All published attestations verified ($TRUST_ROOT $VERSION)." 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/SECURITY.md deleted file mode 100644 index ed05c455..00000000 --- a/SECURITY.md +++ /dev/null @@ -1,78 +0,0 @@ -# Security Policy - -Simple Container (`sc`) is an OSS supply-chain tool that runs in consumer -CI/CD and provisions cloud resources in customer accounts. A vulnerability -in this codebase can propagate downstream to every consumer, so we treat -all reports as high priority. - -## Supported versions - -Security fixes are issued for the **most recent calver release** (the -tag pushed by [`.github/workflows/push.yaml`][push] on every merge to -`main`) and back-ported to the latest `vYYYY.M.x` line only when the -fix is non-trivial. Older versions receive no patches; consumers should -pin to a recent release tag (or a SHA) and update via Dependabot / -[`integrail/devops/.github/actions/install-sc`][install-sc] (or -equivalent) on at least a monthly cadence. - -| Version | Supported | -|---|---| -| `vYYYY.M.x` latest | ✅ | -| Previous calver release on the same month line | ✅ (best-effort back-port) | -| Anything older | ❌ | - -## Reporting a vulnerability - -**Do not file a public issue.** Use one of these channels in order of -preference: - -1. **[GitHub Security Advisory][gsa]** — preferred. Private to maintainers, - integrates with CVE issuance and the GitHub-side fix workflow. -2. **Email** `security@simple-container.com` if you can't use GitHub - Security Advisories. - -Please include: - -- A description of the issue and the security impact you observed. -- The exact `sc` version (or commit SHA) affected. -- Reproduction steps or a proof of concept where possible. -- Whether you've shared the report with any third party. - -We aim to acknowledge within **3 working days** and to ship a fix or -mitigation within **30 days** for HIGH/CRITICAL findings, **90 days** -for MEDIUM, longer for LOW. We'll keep you updated and credit you in -the advisory unless you ask to remain anonymous. - -## Out of scope - -These are intentionally outside the scope of this policy because they -sit in the *consumer's* infrastructure, not in this codebase: - -- Vulnerabilities in the consumer's cloud account (IAM misconfig, etc.) - caused by how they *use* `sc`. Reach out to the relevant cloud - provider or to the consumer. -- Vulnerabilities in third-party tools `sc` invokes (`pulumi`, `cosign`, - `syft`, `trivy`, `grype`, `gcloud`, `kubectl`). Report those upstream. -- The Caddy / cloud-helpers / kubectl Docker images' *upstream* OS - packages — we re-roll on each release and the deferred CVE log is - documented in PRs at merge time. - -## Hardening posture - -The repository is hardened against the relevant supply-chain risks -covered by CIS, OWASP CICD Top 10, SLSA, NIST SSDF, and the OpenSSF -Scorecard. Current control status is tracked in the hardening pipeline -(image scan, SBOM, Semgrep, Dependabot, secret scan run on every PR -and merge). For details on the threat model and the controls that -ship with each release, see the PR history. - -## Cryptographic primitives - -`sc` uses **only** primitives from the Go standard library and a small -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. - -[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 diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 00000000..3743cf12 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,194 @@ +# Security Policy + +Simple Container (`sc`) is an OSS supply-chain tool that runs in consumer +CI/CD and provisions cloud resources in customer accounts. A vulnerability +in this codebase can propagate downstream to every consumer, so we treat +all reports as high priority. + +## Supported versions + +Security fixes are issued for the **most recent calver release** (the +tag pushed by [`.github/workflows/push.yaml`][push] on every merge to +`main`) and back-ported to the latest `vYYYY.M.x` line only when the +fix is non-trivial. Older versions receive no patches; consumers should +pin to a recent release tag (or a SHA) and update via Dependabot / +[`integrail/devops/.github/actions/install-sc`][install-sc] (or +equivalent) on at least a monthly cadence. + +| Version | Supported | +|---|---| +| `vYYYY.M.x` latest | ✅ | +| Previous calver release on the same month line | ✅ (best-effort back-port) | +| Anything older | ❌ | + +## Reporting a vulnerability + +**Do not file a public issue.** Use one of these channels in order of +preference: + +1. **[GitHub Security Advisory][gsa]** — preferred. Private to maintainers, + integrates with CVE issuance and the GitHub-side fix workflow. +2. **Email** `security@simple-container.com` if you can't use GitHub + Security Advisories. + +Please include: + +- A description of the issue and the security impact you observed. +- The exact `sc` version (or commit SHA) affected. +- Reproduction steps or a proof of concept where possible. +- Whether you've shared the report with any third party. + +We aim to acknowledge within **3 working days** and to ship a fix or +mitigation within **30 days** for HIGH/CRITICAL findings, **90 days** +for MEDIUM, longer for LOW. We'll keep you updated and credit you in +the advisory unless you ask to remain anonymous. + +## Out of scope + +These are intentionally outside the scope of this policy because they +sit in the *consumer's* infrastructure, not in this codebase: + +- Vulnerabilities in the consumer's cloud account (IAM misconfig, etc.) + caused by how they *use* `sc`. Reach out to the relevant cloud + provider or to the consumer. +- Vulnerabilities in third-party tools `sc` invokes (`pulumi`, `cosign`, + `syft`, `trivy`, `grype`, `gcloud`, `kubectl`). Report those upstream. +- The Caddy / cloud-helpers / kubectl Docker images' *upstream* OS + packages — we re-roll on each release and the deferred CVE log is + documented in PRs at merge time. + +## Hardening posture + +The repository is hardened against the relevant supply-chain risks +covered by CIS, OWASP CICD Top 10, SLSA, NIST SSDF, and the OpenSSF +Scorecard. Current control status is tracked in the hardening pipeline +(image scan, SBOM, Semgrep, Dependabot, secret scan run on every PR +and merge). For details on the threat model and the controls that +ship with each release, see the PR history. + +## Cryptographic primitives + +`sc` uses **only** primitives from the Go standard library and a small +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`, `: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` | — | + +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 + +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 +# 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 \ + --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 + +The CDN ships these sidecars next to every tarball: + +- `.sha256` — SHA-256 checksum +- `.cosign-bundle` — cosign keyless bundle (cert + sig + Rekor entry) +- `.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,.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" +gh attestation verify "$T" --bundle "$T.sigstore.json" \ + --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 +`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 + +`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=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. + +[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 diff --git a/scripts/verify-preview.sh b/scripts/verify-preview.sh new file mode 100755 index 00000000..aa50ef67 --- /dev/null +++ b/scripts/verify-preview.sh @@ -0,0 +1,257 @@ +#!/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 "