diff --git a/.github/workflows/attest.yml b/.github/workflows/attest.yml new file mode 100644 index 0000000..ed1adf1 --- /dev/null +++ b/.github/workflows/attest.yml @@ -0,0 +1,84 @@ +# Reusable workflow that generates artifact PROVENANCE in isolation from the build +# jobs that call it. A reusable workflow runs in its own execution context with its +# own OIDC identity — the caller can inject neither steps nor secrets — so the +# signing material is unreachable by the build's user-defined steps. That isolation +# is the SLSA Build L3 requirement; GitHub artifact attestations generated in-job +# are only L2. Provenance is still written to GitHub's attestation API, so +# `gh attestation verify` keeps working — but the signer-workflow is now this file: +# +# gh attestation verify --repo \ +# --signer-workflow /.github/workflows/attest.yml --source-ref refs/tags/ +# +# SLSA note: L3 makes the signed provenance unforgeable; it does not, by itself, +# make the build hermetic. The build job still computes the checksums/digest it +# passes in — L3's guarantee is that the signature cannot be tampered with because +# the key lives only in this isolated workflow. + +name: Attest (reusable) + +on: + workflow_call: + inputs: + checksums-artifact: + description: Name of an uploaded artifact containing checksums.txt (binary provenance). + type: string + required: false + default: '' + subject-name: + description: Fully-qualified image/chart name without tag/digest (OCI provenance). + type: string + required: false + default: '' + subject-digest: + description: Image/chart digest in sha256:... form (OCI provenance). + type: string + required: false + default: '' + push-to-registry: + description: Attach the OCI provenance attestation to the registry. + type: boolean + required: false + default: false + +permissions: {} + +jobs: + attest: + name: Attest + runs-on: ubuntu-24.04 + permissions: + id-token: write # OIDC token minted HERE, isolated from the build job + attestations: write # write provenance to GitHub's attestation API + contents: read + packages: write # used only for the OCI attestation (push-to-registry) + steps: + - name: Download checksums + if: ${{ inputs.checksums-artifact != '' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.checksums-artifact }} + + - name: Attest binary checksums + if: ${{ inputs.checksums-artifact != '' }} + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-checksums: checksums.txt + + # The OCI provenance is attached to the registry as a referrer, so this + # isolated job needs its own GHCR login — the build job's docker login does + # not carry into the reusable workflow's separate runner. + - name: Log in to GitHub Container Registry + if: ${{ inputs.subject-digest != '' }} + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Attest OCI provenance + if: ${{ inputs.subject-digest != '' }} + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-name: ${{ inputs.subject-name }} + subject-digest: ${{ inputs.subject-digest }} + push-to-registry: ${{ inputs.push-to-registry }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a29a909..6ba533a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -80,8 +80,6 @@ jobs: - resolve-release permissions: contents: write - id-token: write - attestations: write steps: - name: Check out repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -151,10 +149,31 @@ jobs: RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }} run: gh release upload "$RELEASE_TAG" dist/release-assets/* --clobber - - name: Attest release checksums - uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + - name: Upload checksums for isolated attestation + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - subject-checksums: dist/release-assets/checksums.txt + name: release-checksums + path: dist/release-assets/checksums.txt + if-no-files-found: error + retention-days: 1 + + # Generate binary provenance in an ISOLATED reusable workflow (SLSA L3): the + # signing OIDC token is minted in attest.yml, unreachable by the build steps. + attest-binaries: + needs: + - binary-release-assets + permissions: + id-token: write + attestations: write + contents: read + # attest.yml's shared job declares packages: write (for the OCI + # attestation's push-to-registry). A reusable workflow cannot request more + # permissions than its caller grants, so every caller must grant it — even + # the binary one, whose attestation never pushes to a registry. + packages: write + uses: ./.github/workflows/attest.yml + with: + checksums-artifact: release-checksums # Build the per-arch signed apk on a native runner (no QEMU). Each runner mints # its own ephemeral melange key (distinct filename) and uploads its apk plus its @@ -359,15 +378,22 @@ jobs: sbom-path: image.spdx.json push-to-registry: true - # SLSA provenance (GitHub artifact attestation). This is the attestation the - # chart's optional Kyverno policy verifies; the signer stays release.yml here. - # Moving it into an isolated reusable workflow (SLSA L3) is a later change. - - name: Attest container image - uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 - with: - subject-name: ${{ steps.manifest.outputs.name }} - subject-digest: ${{ steps.manifest.outputs.digest }} - push-to-registry: true + # Generate container provenance in the ISOLATED reusable workflow (SLSA L3). This + # is the attestation the chart's optional Kyverno policy verifies — its signer is + # now attest.yml, which the chart's Kyverno defaults trust. + attest-image: + needs: + - container-image-release + permissions: + id-token: write + attestations: write + packages: write + contents: read + uses: ./.github/workflows/attest.yml + with: + subject-name: ${{ needs.container-image-release.outputs.image-name }} + subject-digest: ${{ needs.container-image-release.outputs.image-digest }} + push-to-registry: true helm-chart-release: name: Helm Chart Release @@ -378,10 +404,8 @@ jobs: permissions: contents: read packages: write - id-token: write - attestations: write - artifact-metadata: write outputs: + chart-name: ${{ steps.push-chart.outputs.name }} chart-digest: ${{ steps.push-chart.outputs.digest }} steps: - name: Check out repository @@ -486,14 +510,23 @@ jobs: exit "$push_rc" fi + echo "name=$CHART_NAME" >> "$GITHUB_OUTPUT" echo "digest=$digest" >> "$GITHUB_OUTPUT" - - name: Attest Helm chart - uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 - with: - subject-name: ${{ env.CHART_NAME }} - subject-digest: ${{ steps.push-chart.outputs.digest }} - push-to-registry: true + # Generate Helm chart provenance in the ISOLATED reusable workflow (SLSA L3). + attest-chart: + needs: + - helm-chart-release + permissions: + id-token: write + attestations: write + packages: write + contents: read + uses: ./.github/workflows/attest.yml + with: + subject-name: ${{ needs.helm-chart-release.outputs.chart-name }} + subject-digest: ${{ needs.helm-chart-release.outputs.chart-digest }} + push-to-registry: true release-inspection-summary: name: Release Inspection Summary @@ -503,6 +536,9 @@ jobs: - binary-release-assets - container-image-release - helm-chart-release + - attest-binaries + - attest-image + - attest-chart permissions: {} steps: - name: Write inspection summary @@ -523,7 +559,7 @@ jobs: echo '```sh' echo "gh release view $RELEASE_TAG --repo $GITHUB_REPOSITORY --json isDraft,assets" echo "asset=\"template-k8s_${RELEASE_VERSION}_\$(go env GOOS)_\$(go env GOARCH)\"" - echo "gh attestation verify \"dist/release-assets/\${asset}\" --repo \"$GITHUB_REPOSITORY\" --signer-workflow \"$GITHUB_REPOSITORY/.github/workflows/release.yml\" --source-ref \"refs/tags/$RELEASE_TAG\" --deny-self-hosted-runners" + echo "gh attestation verify \"dist/release-assets/\${asset}\" --repo \"$GITHUB_REPOSITORY\" --signer-workflow \"$GITHUB_REPOSITORY/.github/workflows/attest.yml\" --source-ref \"refs/tags/$RELEASE_TAG\" --deny-self-hosted-runners" echo '```' echo echo "Container verification commands:" @@ -532,7 +568,8 @@ jobs: echo "docker login ghcr.io" echo "docker pull \"${IMAGE_NAME}:${RELEASE_TAG}\"" echo "docker run --rm \"${IMAGE_NAME}:${RELEASE_TAG}\" --help" - echo "gh attestation verify \"oci://${IMAGE_NAME}@${IMAGE_DIGEST}\" --repo \"$GITHUB_REPOSITORY\" --signer-workflow \"$GITHUB_REPOSITORY/.github/workflows/release.yml\" --source-ref \"refs/tags/$RELEASE_TAG\" --deny-self-hosted-runners" + echo "gh attestation verify \"oci://${IMAGE_NAME}@${IMAGE_DIGEST}\" --repo \"$GITHUB_REPOSITORY\" --signer-workflow \"$GITHUB_REPOSITORY/.github/workflows/attest.yml\" --source-ref \"refs/tags/$RELEASE_TAG\" --deny-self-hosted-runners" + echo "cosign verify \"${IMAGE_NAME}@${IMAGE_DIGEST}\" --certificate-identity-regexp \"^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release.yml@.*\" --certificate-oidc-issuer https://token.actions.githubusercontent.com" echo '```' echo echo "Helm chart verification commands:" @@ -541,7 +578,7 @@ jobs: echo "helm show chart \"${CHART_REF}\" --version \"${RELEASE_VERSION}\"" echo "helm pull \"${CHART_REF}\" --version \"${RELEASE_VERSION}\"" echo "helm install template-k8s \"${CHART_REF}\" --version \"${RELEASE_VERSION}\" --namespace template-k8s-system --create-namespace" - echo "gh attestation verify \"${CHART_REF}@${CHART_DIGEST}\" --repo \"$GITHUB_REPOSITORY\" --signer-workflow \"$GITHUB_REPOSITORY/.github/workflows/release.yml\" --source-ref \"refs/tags/$RELEASE_TAG\" --deny-self-hosted-runners" + echo "gh attestation verify \"${CHART_REF}@${CHART_DIGEST}\" --repo \"$GITHUB_REPOSITORY\" --signer-workflow \"$GITHUB_REPOSITORY/.github/workflows/attest.yml\" --source-ref \"refs/tags/$RELEASE_TAG\" --deny-self-hosted-runners" echo '```' echo echo "Publish or reject the draft release manually after inspection. The container image and Helm chart are already available in GHCR because GHCR does not have a draft release state." diff --git a/DELETE_ME.md b/DELETE_ME.md index 9ddab1f..ec09b7f 100644 --- a/DELETE_ME.md +++ b/DELETE_ME.md @@ -220,8 +220,8 @@ that shape, trim the release files before the first release. - `charts/template-k8s/values.yaml` - Update `image.repository`. - Update `kyverno.imageVerification.attestor.subjectRegExp` so optional - Kyverno image verification trusts the generated repository's release - workflow. + Kyverno image verification trusts the generated repository's provenance + signer, the reusable `.github/workflows/attest.yml` (not `release.yml`). - Add, remove, or rename values for real controller runtime options. - Keep fixed image tags or digests; do not default to `latest`. @@ -243,7 +243,7 @@ that shape, trim the release files before the first release. - `charts/template-k8s/templates/kyverno-image-policy.yaml` - Update the policy name helper and default attestor subject if the chart or - release workflow identity changes. + the provenance signer workflow (`attest.yml`) identity changes. - Keep it optional unless Kyverno is a hard prerequisite for the generated repository. @@ -290,6 +290,12 @@ that shape, trim the release files before the first release. - Update Helm chart paths, rendered-output assertions, install examples, and release inspection summary commands. +- `.github/workflows/attest.yml` + - The reusable workflow that signs binary/image/chart provenance in isolation + (SLSA Build L3). It has no project-specific identifiers, but it IS the signer + identity the Kyverno policy and the release inspection summary trust — keep it + in sync with `kyverno.imageVerification.attestor.subjectRegExp`. + - `.github/workflows/release-dry-run.yml` - Update image and chart refs. - Update binary validation names, dry-run image names, OCI archive names, diff --git a/charts/template-k8s/values.yaml b/charts/template-k8s/values.yaml index ac4e70f..0ad1e5d 100644 --- a/charts/template-k8s/values.yaml +++ b/charts/template-k8s/values.yaml @@ -23,7 +23,7 @@ kyverno: attestor: issuer: https://token.actions.githubusercontent.com subject: "" - subjectRegExp: ^https://github\.com/meigma/template-k8s/\.github/workflows/release\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$ + subjectRegExp: ^https://github\.com/meigma/template-k8s/\.github/workflows/attest\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$ rekor: url: https://rekor.sigstore.dev attestation: diff --git a/test/chart/rbac_test.go b/test/chart/rbac_test.go index 038b270..0d629e1 100644 --- a/test/chart/rbac_test.go +++ b/test/chart/rbac_test.go @@ -101,7 +101,7 @@ func TestKyvernoImageVerificationPolicyRendersGitHubAttestationPolicy(t *testing requireNestedString( t, keyless, - "^https://github\\.com/meigma/template-k8s/\\.github/workflows/release\\.yml@refs/tags/"+ + "^https://github\\.com/meigma/template-k8s/\\.github/workflows/attest\\.yml@refs/tags/"+ "v[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$", "subjectRegExp", )