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