Explorar o código

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

Add Sigstore cosign keyless signing and SLSA v1 build provenance attestation
to every image produced by the build-and-publish-release and
build-and-publish-develop workflows, driven by GitHub Actions OIDC (no
long-lived signing keys).

New composite action .github/actions/sign-image resolves the image digest
with crane, signs <repo>@<digest> via `cosign sign --yes`, generates a
SLSA v1 provenance predicate from GitHub run metadata, and attaches it via
`cosign attest --yes --type slsaprovenance1`. Signing is by digest, so all
tags pointing at the same manifest (:latest, :X.Y.Z, :<shorthash>,
:develop-latest, :develop-<shorthash>) are verifiable.

Both publishing workflows gain `id-token: write` so the runner can exchange
its OIDC token for a short-lived Fulcio certificate and publish the
signature plus Rekor transparency log entry alongside the image.

SECURITY.md gains a verification section covering the expected
certificate identity / OIDC issuer, copy-paste `cosign verify` and
`cosign verify-attestation` commands for images and for the sibling
opencost-helm-chart OCI artifacts, and an example Kyverno ClusterPolicy
for admission-time enforcement.

Closes #3734

Signed-off-by: Warwick Peatey <warwick@automatic.systems>
Assisted-by: Claude Code
Claude hai 1 mes
pai
achega
92701ad980

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

@@ -0,0 +1,98 @@
+name: 'Sign Container Image'
+description: >-
+  Sign a container image and attest SLSA v1 build provenance using cosign
+  keyless (Sigstore) with GitHub Actions OIDC. Requires `id-token: write` in
+  the calling job.
+
+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
+
+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 }}
+        run: |
+          set -euo pipefail
+          STARTED_ON="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+          jq -n \
+            --arg workflow_ref   "${GITHUB_REF}"                                 \
+            --arg repo_url       "https://github.com/${GITHUB_REPOSITORY}"       \
+            --arg workflow_path  "${WORKFLOW_PATH}"                              \
+            --arg source_uri     "git+https://github.com/${GITHUB_REPOSITORY}@${GITHUB_REF}" \
+            --arg git_commit     "${GITHUB_SHA}"                                 \
+            --arg builder_id     "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
+            --arg invocation_id  "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/attempts/${GITHUB_RUN_ATTEMPT}" \
+            --arg started_on     "${STARTED_ON}" \
+            '{
+              buildDefinition: {
+                buildType: "https://github.com/opencost/opencost/build/workflow@v1",
+                externalParameters: {
+                  workflow: {
+                    ref: $workflow_ref,
+                    repository: $repo_url,
+                    path: $workflow_path
+                  }
+                },
+                internalParameters: {},
+                resolvedDependencies: [
+                  {
+                    uri: $source_uri,
+                    digest: { gitCommit: $git_commit }
+                  }
+                ]
+              },
+              runDetails: {
+                builder: { id: $builder_id },
+                metadata: {
+                  invocationId: $invocation_id,
+                  startedOn: $started_on
+                }
+              }
+            }' > predicate.json
+
+      - 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}"

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

@@ -23,6 +23,7 @@ jobs:
     permissions:
       contents: read
       packages: write
+      id-token: write
     steps:
       - name: Checkout Repo
         uses: actions/checkout@v6.0.2
@@ -61,5 +62,8 @@ jobs:
           echo "Copying $IMAGE_TAG to ${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

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

@@ -26,6 +26,7 @@ jobs:
     permissions:
       contents: read
       packages: write
+      id-token: write
     steps:
       - name: Get Version From Tag
         id: tag
@@ -111,3 +112,9 @@ jobs:
         run: |
           crane copy "$IMAGE_TAG" "$IMAGE_TAG_LATEST"
           crane copy "$IMAGE_TAG" "$IMAGE_TAG_VERSION"
+
+      - name: Sign image and attest SLSA provenance
+        uses: ./.github/actions/sign-image
+        with:
+          image: ${{ steps.tags.outputs.IMAGE_TAG_VERSION }}
+          workflow-path: .github/workflows/build-and-publish-release.yml

+ 116 - 0
SECURITY.md

@@ -10,6 +10,122 @@ 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.
 
+## Image Signing and Verification
+
+OpenCost container images published from this repository 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 image is additionally accompanied by a
+[SLSA v1](https://slsa.dev/spec/v1.0/) build provenance attestation produced
+with `cosign attest`.
+
+### 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/.+$` |
+
+### 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
+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
 
 OpenCost provides security updates for the two most recent minor versions released on GitHub.