From a45c6e633d6b6610e1691a1cf55db29c0e61e64d Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Sun, 17 May 2026 01:02:53 +0400 Subject: [PATCH] feat: sign-and-attest composite (Step C-1 of pin-reduction) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encapsulates the four-step attestation pipeline that ships in every SC release workflow today (push.yaml, branch-preview.yaml, build-staging.yml): 1. Generate CycloneDX SBOM (syft scan on the image's registry layout). 2. Sign with cosign keyless (Fulcio + Rekor). 3. Attest the SBOM via `cosign attest --type cyclonedx`. 4. Publish SLSA Build L3 provenance via `actions/attest-build-provenance@v4` (subject-name + subject-digest), deliberately NOT pushing the attestation to the registry — that would silently overwrite the `cosign attest cyclonedx` slot under the `.att` tag. Provenance stays in the GH attestation API; SBOM stays in the registry. This cross-attestation contract is documented in SC's SECURITY.md. Per-step outcomes are surfaced as outputs (`sbom-outcome`, `sign-outcome`, `attest-outcome`, `slsa-outcome`) so the calling workflow can aggregate into a single ::warning:: summary the same way push.yaml does today. `soft-fail: true` (default during Phase 2 bake- in) makes each step `continue-on-error`; consumers flip to `false` after 14 days of clean post-publish verification. Caller responsibilities (documented in the composite description): - Set `permissions: id-token: write` + `attestations: write` on the calling job (OIDC for cosign keyless + the GitHub attestation API). - Pass `image-ref` WITH digest (`@sha256:…`). Verification by tag is unsafe (TOCTOU) and the composite rejects nothing — the caller must ensure digest-pinning at the upstream `docker/build-push-action` step. - Set `install-tools: false` if the calling job has already invoked `install-attest-tools` (idempotent, but saves ~10s). ## Step C in context This is **Step C-1** of the consolidation plan tracked in HARDENING.md "Consumer-side pin reduction": - Step A (PR #12, open): setup-sc-tooling composite - Step B (PR #12, open): install-attest-tools composite (promoted from api-local) - **Step C-1 (this PR)**: sign-and-attest composite — encapsulates the inner signing/attestation flow that consumer build workflows repeat per-image. - Step C-2 (next session): `sc-image-release.yml` reusable workflow that wraps `setup-sc-tooling` + `docker/build-push-action` + `sign-and-attest` + `notify-telegram` into a one-call build-and- publish-one-image flow. Driven by a matrix on the consumer side. ## Tests `.github/workflows/self-test-sign-and-attest.yml` is a real end-to-end integration test, not a static check: 1. Build a tiny alpine-based test image (Dockerfile generated inline). 2. Push to ghcr.io/${{ github.repository }}/sign-and-attest-test under a unique `run--attempt-` tag. 3. Invoke the composite against the freshly-pushed digest. 4. Run the three verification commands a real consumer would run: - `cosign verify` - `cosign verify-attestation --type cyclonedx` - `gh attestation verify --repo simple-container-com/actions` against the identity-regex `^https://github\.com/simple-container-com/actions/`. 5. Assert all four composite outputs (`sbom-outcome` / `sign-outcome` / `attest-outcome` / `slsa-outcome`) are `success`, and that the SBOM file is on disk. The test uses `soft-fail: false` so a transient sigstore-public outage surfaces as a clear CI failure instead of being swallowed. Wall-clock ~2 min per run. ## Coverage - actionlint clean - shellcheck clean - `bash semgrep-scan/run-tests.sh` — 0 findings full-repo Signed-off-by: Dmitrii Creed --- .../workflows/self-test-sign-and-attest.yml | 144 +++++++++++++++++ sign-and-attest/action.yml | 148 ++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 .github/workflows/self-test-sign-and-attest.yml create mode 100644 sign-and-attest/action.yml diff --git a/.github/workflows/self-test-sign-and-attest.yml b/.github/workflows/self-test-sign-and-attest.yml new file mode 100644 index 0000000..fe81e05 --- /dev/null +++ b/.github/workflows/self-test-sign-and-attest.yml @@ -0,0 +1,144 @@ +name: Self-test sign-and-attest + +# Integration test for the `sign-and-attest` composite. Builds a tiny +# alpine-based image, pushes to ghcr.io under this repo's namespace, +# invokes the composite, then runs the three verification commands a +# consumer would run: `cosign verify`, `cosign verify-attestation`, +# `gh attestation verify`. All three must succeed for the test to pass. +# +# Runs on every PR / push to main and on workflow_dispatch. + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + sign-and-verify: + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: write # push the test image to ghcr.io + id-token: write # cosign keyless + actions/attest-build-provenance + attestations: write # actions/attest-build-provenance API + env: + # ghcr.io path under this org. Each PR run gets a unique tag (the + # short SHA + run number) so concurrent runs don't collide. + IMAGE_REPO: ghcr.io/${{ github.repository }}/sign-and-attest-test + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Log in to ghcr.io + uses: docker/login-action@327cd5a69de6c009b9ce71bce8395f28e651bf99 # v3.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Create test Dockerfile + shell: bash + run: | + set -euo pipefail + mkdir -p test-fixture + cat > test-fixture/Dockerfile <<'EOF' + FROM alpine:3.21 + LABEL org.opencontainers.image.title="sign-and-attest self-test fixture" + LABEL org.opencontainers.image.source="https://github.com/simple-container-com/actions" + LABEL org.opencontainers.image.description="Throwaway image built by the sign-and-attest self-test. Safe to delete." + EOF + + - name: Build and push test image + id: build + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: ./test-fixture + push: true + tags: ${{ env.IMAGE_REPO }}:run-${{ github.run_id }}-attempt-${{ github.run_attempt }} + provenance: false # we want attest-build-provenance, not buildx attestations + + - name: Invoke sign-and-attest + id: signattest + uses: ./sign-and-attest + with: + image-ref: ${{ env.IMAGE_REPO }}@${{ steps.build.outputs.digest }} + image-name: sign-and-attest-test + subject-name: ${{ env.IMAGE_REPO }} + subject-digest: ${{ steps.build.outputs.digest }} + # Hard-fail on the self-test so transient sigstore-public outages + # surface as a clear CI failure instead of being swallowed by the + # default `continue-on-error`. + soft-fail: 'false' + + - name: Verify cosign signature + shell: bash + env: + IMAGE_REF: ${{ env.IMAGE_REPO }}@${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + cosign verify "${IMAGE_REF}" \ + --certificate-identity-regexp "^https://github\.com/simple-container-com/actions/" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + >/dev/null + echo "::notice::cosign verify OK" + + - name: Verify CycloneDX SBOM attestation + shell: bash + env: + IMAGE_REF: ${{ env.IMAGE_REPO }}@${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + cosign verify-attestation "${IMAGE_REF}" \ + --type cyclonedx \ + --certificate-identity-regexp "^https://github\.com/simple-container-com/actions/" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + >/dev/null + echo "::notice::cosign verify-attestation (cyclonedx) OK" + + - name: Verify SLSA build provenance + shell: bash + env: + IMAGE_REF: ${{ env.IMAGE_REPO }}@${{ steps.build.outputs.digest }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + gh attestation verify "oci://${IMAGE_REF}" \ + --repo ${{ github.repository }} \ + --cert-identity-regex "^https://github\.com/simple-container-com/actions/" \ + --cert-oidc-issuer "https://token.actions.githubusercontent.com" \ + >/dev/null + echo "::notice::gh attestation verify (SLSA) OK" + + - name: Verify composite outputs are populated + shell: bash + env: + SBOM_PATH: ${{ steps.signattest.outputs.sbom-path }} + SBOM_OUTCOME: ${{ steps.signattest.outputs.sbom-outcome }} + SIGN_OUTCOME: ${{ steps.signattest.outputs.sign-outcome }} + ATTEST_OUTCOME: ${{ steps.signattest.outputs.attest-outcome }} + SLSA_OUTCOME: ${{ steps.signattest.outputs.slsa-outcome }} + run: | + set -euo pipefail + [ -f "${SBOM_PATH}" ] || { echo "::error::SBOM file not found at ${SBOM_PATH}"; exit 1; } + for kv in "sbom-outcome=${SBOM_OUTCOME}" \ + "sign-outcome=${SIGN_OUTCOME}" \ + "attest-outcome=${ATTEST_OUTCOME}" \ + "slsa-outcome=${SLSA_OUTCOME}"; do + name="${kv%%=*}" + val="${kv#*=}" + if [ "${val}" != "success" ]; then + echo "::error::Composite output ${name}=${val} (expected 'success')" + exit 1 + fi + done + echo "::notice::All composite outputs OK; SBOM at ${SBOM_PATH}" diff --git a/sign-and-attest/action.yml b/sign-and-attest/action.yml new file mode 100644 index 0000000..35cadf4 --- /dev/null +++ b/sign-and-attest/action.yml @@ -0,0 +1,148 @@ +name: 'Sign and Attest OCI Image (CycloneDX SBOM + cosign keyless + SLSA L3)' +description: | + Full attestation flow for a freshly-built OCI image: + + 1. Generate a CycloneDX SBOM from the image's registry layout (syft). + 2. Sign the image with cosign keyless (Sigstore Fulcio + Rekor). + 3. Attest the SBOM via `cosign attest --type cyclonedx`. Lives in + the OCI registry under the image's `.att` tag — + `cosign verify-attestation` resolves it. + 4. Publish SLSA Build L3 provenance via the GitHub attestation API + (`actions/attest-build-provenance`). `gh attestation verify` + resolves it. Deliberately NOT pushed to the registry to avoid + overwriting the SBOM attestation under the same `.att` tag — + see SC's Phase 2 SECURITY.md for the cross-attestation contract. + + Each step is `continue-on-error` by default during Phase 2 bake-in + (set `soft-fail: false` once the consumer's verify-attestations job + has been green for 14 days). Per-step outcomes are surfaced as + outputs so the calling workflow can aggregate them into a single + ::warning:: summary. + + REQUIRES the calling job to set: + + permissions: + id-token: write # OIDC for cosign keyless + attest-build-provenance + attestations: write # actions/attest-build-provenance API + +inputs: + image-ref: + description: | + Fully-qualified image reference WITH digest — e.g. + `simplecontainer/foo@sha256:abc…`. Always pin by digest; + verification by tag is unsafe (TOCTOU). + required: true + image-name: + description: 'Short logical name used in step labels and the output SBOM filename (e.g. `caddy`).' + required: true + subject-name: + description: | + Subject name for `actions/attest-build-provenance`. Typically + `index.docker.io/` or `ghcr.io//` + (no tag or digest — the digest is the next input). + required: true + subject-digest: + description: '`sha256:…` digest for `actions/attest-build-provenance`. Usually the `digest` output of `docker/build-push-action`.' + required: true + install-tools: + description: 'Whether to install cosign + syft via pinned actions. Default `true`. Set `false` if the caller already invoked `install-attest-tools` (idempotent — calling twice is fine).' + required: false + default: 'true' + soft-fail: + description: 'When `true` (default during Phase 2 bake-in) each sub-step is `continue-on-error: true`. Set `false` after bake-in to make the gate strict.' + required: false + default: 'true' + cosign-version: + description: 'Cosign release tag (only used when `install-tools: true`).' + required: false + default: 'v2.4.1' + syft-version: + description: 'Syft release tag (only used when `install-tools: true`).' + required: false + default: 'v1.16.0' + +outputs: + sbom-path: + description: 'Path to the generated CycloneDX SBOM file (relative to the runner work dir).' + value: sbom-${{ inputs.image-name }}.cdx.json + sbom-outcome: + description: 'Outcome of the SBOM generation step (`success` / `failure` / `skipped`).' + value: ${{ steps.sbom.outcome }} + sign-outcome: + description: 'Outcome of the `cosign sign` step.' + value: ${{ steps.cosign_sign.outcome }} + attest-outcome: + description: 'Outcome of the `cosign attest` SBOM-attestation step.' + value: ${{ steps.cosign_attest.outcome }} + slsa-outcome: + description: 'Outcome of the `actions/attest-build-provenance` step.' + value: ${{ steps.slsa.outcome }} + +runs: + using: 'composite' + steps: + - name: Install cosign + if: inputs.install-tools == 'true' + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + with: + cosign-release: ${{ inputs.cosign-version }} + + - name: Install syft + if: inputs.install-tools == 'true' + uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + syft-version: ${{ inputs.syft-version }} + + - name: Generate CycloneDX SBOM (${{ inputs.image-name }}) + id: sbom + continue-on-error: ${{ inputs.soft-fail == 'true' }} + shell: bash + env: + IMAGE_REF: ${{ inputs.image-ref }} + SBOM_PATH: sbom-${{ inputs.image-name }}.cdx.json + run: | + set -euo pipefail + syft scan "registry:${IMAGE_REF}" -o "cyclonedx-json=${SBOM_PATH}" + ls -lh "${SBOM_PATH}" + + - name: Cosign sign (${{ inputs.image-name }}, keyless) + id: cosign_sign + continue-on-error: ${{ inputs.soft-fail == 'true' }} + shell: bash + env: + COSIGN_EXPERIMENTAL: "1" + IMAGE_REF: ${{ inputs.image-ref }} + run: | + set -euo pipefail + cosign sign --yes "${IMAGE_REF}" + + - name: Cosign attest CycloneDX SBOM (${{ inputs.image-name }}) + id: cosign_attest + if: steps.sbom.outcome == 'success' && steps.cosign_sign.outcome == 'success' + continue-on-error: ${{ inputs.soft-fail == 'true' }} + shell: bash + env: + COSIGN_EXPERIMENTAL: "1" + IMAGE_REF: ${{ inputs.image-ref }} + SBOM_PATH: sbom-${{ inputs.image-name }}.cdx.json + run: | + set -euo pipefail + cosign attest --yes \ + --predicate "${SBOM_PATH}" \ + --type cyclonedx \ + "${IMAGE_REF}" + + - name: SLSA build provenance (${{ inputs.image-name }}) + id: slsa + continue-on-error: ${{ inputs.soft-fail == 'true' }} + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-name: ${{ inputs.subject-name }} + subject-digest: ${{ inputs.subject-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 lives in the GitHub + # attestation API instead (queryable via `gh attestation verify`); + # SBOM attestation stays in the registry for + # `cosign verify-attestation --type cyclonedx`.