Explorar el Código

feat(ci): sign container images with cosign keyless and attest SLSA provenance (#3737)

Signed-off-by: Warwick Peatey <warwick@automatic.systems>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alex Meijer <ameijer@users.noreply.github.com>
Warwick hace 2 días
padre
commit
fdbf076fb7

+ 115 - 0
.github/actions/sign-image/action.yaml

@@ -0,0 +1,115 @@
+name: 'Sign Container Image'
+description: >-
+  Sign a container image and attest SLSA v1 build provenance using cosign
+  keyless (Sigstore) with GitHub Actions OIDC. Callers must check out the
+  source tree (e.g. `actions/checkout`) before invoking this action — the
+  built commit is resolved with `git rev-parse HEAD` so the attestation
+  records the revision that was actually built rather than `github.sha`,
+  which on a tag push reflects the tag-pointed commit and may differ from
+  the branch tip the release workflow checks out. The calling job must
+  have `id-token: write`.
+
+inputs:
+    image:
+        description: 'Full image reference (registry/repo:tag) to sign by digest.'
+        required: true
+    workflow-path:
+        description: 'Path to the workflow file (repo-relative) that triggered the build.'
+        required: true
+    run-started-at:
+        description: >-
+          ISO-8601 workflow run start time, typically `${{ github.run_started_at }}`
+          from the caller. Recorded as `runDetails.metadata.startedOn` in the
+          SLSA provenance predicate.
+        required: true
+
+runs:
+    using: "composite"
+    steps:
+      - name: Install cosign
+        uses: sigstore/cosign-installer@v3
+
+      - name: Install crane
+        uses: imjasonh/setup-crane@v0.5
+
+      - name: Resolve image digest
+        id: digest
+        shell: bash
+        env:
+          IMAGE: ${{ inputs.image }}
+        run: |
+          set -euo pipefail
+          DIGEST="$(crane digest "${IMAGE}")"
+          REPO="${IMAGE%:*}"
+          REF="${REPO}@${DIGEST}"
+          echo "Resolved ${IMAGE} -> ${REF}"
+          echo "REF=${REF}" >> "$GITHUB_OUTPUT"
+          echo "DIGEST=${DIGEST}" >> "$GITHUB_OUTPUT"
+
+      - name: Sign image with cosign (keyless)
+        shell: bash
+        env:
+          REF: ${{ steps.digest.outputs.REF }}
+        run: |
+          set -euo pipefail
+          cosign sign --yes "${REF}"
+
+      - name: Generate SLSA v1 provenance predicate
+        shell: bash
+        env:
+          WORKFLOW_PATH: ${{ inputs.workflow-path }}
+          STARTED_ON: ${{ inputs.run-started-at }}
+        run: |
+          set -euo pipefail
+          RESOLVED_GIT_COMMIT="$(git rev-parse HEAD)"
+          export RESOLVED_GIT_COMMIT
+          python3 - <<'PY' > predicate.json
+          import json
+          import os
+
+          repo = os.environ["GITHUB_REPOSITORY"]
+          commit = os.environ["RESOLVED_GIT_COMMIT"]
+          run_id = os.environ["GITHUB_RUN_ID"]
+          run_attempt = os.environ["GITHUB_RUN_ATTEMPT"]
+
+          predicate = {
+              "buildDefinition": {
+                  "buildType": "https://github.com/opencost/opencost/build/workflow@v1",
+                  "externalParameters": {
+                      "workflow": {
+                          "ref": os.environ["GITHUB_REF"],
+                          "repository": f"https://github.com/{repo}",
+                          "path": os.environ["WORKFLOW_PATH"],
+                      }
+                  },
+                  "internalParameters": {},
+                  "resolvedDependencies": [
+                      {
+                          "uri": f"git+https://github.com/{repo}@{commit}",
+                          "digest": {"gitCommit": commit},
+                      }
+                  ],
+              },
+              "runDetails": {
+                  "builder": {
+                      "id": f"https://github.com/{repo}/actions/runs/{run_id}",
+                  },
+                  "metadata": {
+                      "invocationId": f"https://github.com/{repo}/actions/runs/{run_id}/attempts/{run_attempt}",
+                      "startedOn": os.environ["STARTED_ON"],
+                  },
+              },
+          }
+          print(json.dumps(predicate, indent=2))
+          PY
+
+      - name: Attest SLSA provenance with cosign
+        shell: bash
+        env:
+          REF: ${{ steps.digest.outputs.REF }}
+        run: |
+          set -euo pipefail
+          cosign attest --yes \
+            --predicate predicate.json \
+            --type slsaprovenance1 \
+            "${REF}"

+ 9 - 2
.github/workflows/build-and-publish-develop.yml

@@ -23,9 +23,12 @@ jobs:
     permissions:
     permissions:
       contents: read
       contents: read
       packages: write
       packages: write
+      id-token: write
     steps:
     steps:
       - name: Checkout Repo
       - name: Checkout Repo
         uses: actions/checkout@v6.0.2
         uses: actions/checkout@v6.0.2
+        with:
+          ref: ${{ github.event.workflow_run.head_sha }}
       - name: Set SHA
       - name: Set SHA
         id: sha
         id: sha
         run: |
         run: |
@@ -61,5 +64,9 @@ jobs:
           echo "Copying $IMAGE_TAG to ${NEW_TAG}"
           echo "Copying $IMAGE_TAG to ${NEW_TAG}"
           crane copy "$IMAGE_TAG" "${NEW_TAG}"
           crane copy "$IMAGE_TAG" "${NEW_TAG}"
 
 
-
-
+      - name: Sign image and attest SLSA provenance
+        uses: ./.github/actions/sign-image
+        with:
+          image: ${{ steps.tags.outputs.IMAGE_TAG }}
+          workflow-path: .github/workflows/build-and-publish-develop.yml
+          run-started-at: ${{ github.run_started_at }}

+ 12 - 0
.github/workflows/build-and-publish-release.yml

@@ -26,6 +26,7 @@ jobs:
     permissions:
     permissions:
       contents: read
       contents: read
       packages: write
       packages: write
+      id-token: write
     steps:
     steps:
       - name: Get Version From Tag
       - name: Get Version From Tag
         id: tag
         id: tag
@@ -111,3 +112,14 @@ jobs:
         run: |
         run: |
           crane copy "$IMAGE_TAG" "$IMAGE_TAG_LATEST"
           crane copy "$IMAGE_TAG" "$IMAGE_TAG_LATEST"
           crane copy "$IMAGE_TAG" "$IMAGE_TAG_VERSION"
           crane copy "$IMAGE_TAG" "$IMAGE_TAG_VERSION"
+
+      - name: Sign image and attest SLSA provenance
+        # Only sign tag-triggered releases; workflow_dispatch runs produce a
+        # non-tag GITHUB_REF, so the Fulcio certificate identity would not
+        # match the `refs/tags/vX.Y.Z` pattern documented in SECURITY.md.
+        if: github.event_name == 'push'
+        uses: ./.github/actions/sign-image
+        with:
+          image: ${{ steps.tags.outputs.IMAGE_TAG_VERSION }}
+          workflow-path: .github/workflows/build-and-publish-release.yml
+          run-started-at: ${{ github.run_started_at }}

+ 128 - 0
SECURITY.md

@@ -10,6 +10,134 @@ Application code is version controlled using GitHub. All code changes are tracke
 
 
 OpenCost has [Dependabot](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-supply-chain-security#what-is-dependabot) enabled for assessing dependencies in the project.
 OpenCost has [Dependabot](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-supply-chain-security#what-is-dependabot) enabled for assessing dependencies in the project.
 
 
+## Image Signing and Verification
+
+OpenCost container images published from this repository by the tag-triggered
+release workflow and the `develop` branch publishing workflow are signed with
+[Sigstore cosign](https://docs.sigstore.dev/cosign/signing/signing_with_containers/)
+using **keyless** signatures. Signing is driven by GitHub Actions OIDC — there
+are no long-lived signing keys to manage or rotate. Each signature is recorded
+in the public [Rekor](https://docs.sigstore.dev/logging/overview/) transparency
+log, and every signed image is additionally accompanied by a
+[SLSA v1](https://slsa.dev/spec/v1.0/) build provenance attestation produced
+with `cosign attest`.
+
+> **Note:** `workflow_dispatch` runs of `build-and-publish-release.yml`
+> intentionally skip signing. A manual dispatch runs from a branch ref rather
+> than a tag, so the Fulcio certificate identity would not match the
+> `refs/tags/vX.Y.Z` pattern that verification tooling and the Kyverno policy
+> below pin to — a signature produced under a branch identity would be
+> silently rejected by those admission policies anyway. To produce a
+> verifiable release, push a `vX.Y.Z` tag and let the tag event trigger the
+> workflow.
+
+### What is signed
+
+| Artifact | Registry | Signed by workflow |
+|----------|----------|--------------------|
+| Release images (`:latest`, `:X.Y.Z`, `:<shorthash>`) | `ghcr.io/opencost/opencost` | `.github/workflows/build-and-publish-release.yml` |
+| Develop images (`:develop-latest`, `:develop-<shorthash>`) | `ghcr.io/opencost/opencost` | `.github/workflows/build-and-publish-develop.yml` |
+| Helm chart OCI artifacts | `ghcr.io/opencost/opencost-helm-chart` | `opencost/opencost-helm-chart` — `.github/workflows/publish.yml` |
+
+Signatures are attached to the image **by digest**, so any tag that resolves
+to a signed manifest is verifiable regardless of tag mutation.
+
+### Expected signing identity
+
+| Field | Value |
+|-------|-------|
+| `--certificate-oidc-issuer` | `https://token.actions.githubusercontent.com` |
+| `--certificate-identity` (release tag `vX.Y.Z`) | `https://github.com/opencost/opencost/.github/workflows/build-and-publish-release.yml@refs/tags/vX.Y.Z` |
+| `--certificate-identity-regexp` (any release) | `^https://github\.com/opencost/opencost/\.github/workflows/build-and-publish-release\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$` |
+| `--certificate-identity-regexp` (develop) | `^https://github\.com/opencost/opencost/\.github/workflows/build-and-publish-develop\.yml@refs/heads/develop$` |
+
+### Verifying an image signature
+
+Install cosign (`go install github.com/sigstore/cosign/v2/cmd/cosign@latest`
+or see the [install guide](https://docs.sigstore.dev/cosign/system_config/installation/)),
+then verify a specific release:
+
+```bash
+VERSION=1.115.0 # replace with the release you are verifying
+
+cosign verify \
+  --certificate-identity "https://github.com/opencost/opencost/.github/workflows/build-and-publish-release.yml@refs/tags/v${VERSION}" \
+  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
+  "ghcr.io/opencost/opencost:${VERSION}"
+```
+
+A successful verification prints the signed payload and confirms the Rekor
+inclusion proof.
+
+### Verifying SLSA provenance
+
+Each image also has a SLSA v1 provenance attestation. Inspect it with:
+
+```bash
+VERSION=1.115.0 # replace with the release you are verifying
+
+cosign verify-attestation \
+  --type slsaprovenance1 \
+  --certificate-identity-regexp "^https://github\.com/opencost/opencost/\.github/workflows/build-and-publish-release\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$" \
+  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
+  "ghcr.io/opencost/opencost:${VERSION}"
+```
+
+### Verifying the Helm chart signature
+
+Helm chart OCI artifacts published by
+[`opencost/opencost-helm-chart`](https://github.com/opencost/opencost-helm-chart)
+are signed by the same keyless pattern. Verify a pulled chart with:
+
+```bash
+CHART_VERSION=1.45.0 # replace with the chart version you are verifying
+
+cosign verify \
+  --certificate-identity-regexp "^https://github\.com/opencost/opencost-helm-chart/\.github/workflows/publish\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$" \
+  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
+  "ghcr.io/opencost/opencost-helm-chart/opencost:${CHART_VERSION}"
+```
+
+### Admission-time enforcement
+
+The following [Kyverno](https://kyverno.io/) `ClusterPolicy` blocks any pod
+whose image is pulled from `ghcr.io/opencost/opencost` unless it carries a
+valid keyless signature from the release workflow. An equivalent pattern
+works with [Sigstore policy-controller](https://docs.sigstore.dev/policy-controller/overview/)
+and [Connaisseur](https://sse-secure-systems.github.io/connaisseur/).
+
+```yaml
+apiVersion: kyverno.io/v2beta1
+kind: ClusterPolicy
+metadata:
+  name: verify-opencost-image-signatures
+spec:
+  validationFailureAction: Enforce
+  background: false
+  webhookTimeoutSeconds: 30
+  rules:
+    - name: verify-opencost-signed-by-release-workflow
+      match:
+        any:
+          - resources:
+              kinds:
+                - Pod
+      verifyImages:
+        - imageReferences:
+            - "ghcr.io/opencost/opencost:*"
+          attestors:
+            - entries:
+                - keyless:
+                    subjectRegExp: "^https://github\\.com/opencost/opencost/\\.github/workflows/build-and-publish-release\\.yml@refs/tags/v[0-9]+\\.[0-9]+\\.[0-9]+$"
+                    issuer: "https://token.actions.githubusercontent.com"
+                    rekor:
+                      url: "https://rekor.sigstore.dev"
+```
+
+If your cluster pulls development builds, extend `imageReferences` with
+`ghcr.io/opencost/opencost:develop-*` and add a second attestor entry whose
+`subjectRegExp` targets the `build-and-publish-develop.yml` workflow.
+
 ## Supported Versions
 ## Supported Versions
 
 
 OpenCost provides security updates for the two most recent minor versions released on GitHub.
 OpenCost provides security updates for the two most recent minor versions released on GitHub.