Skip to content

Commit 99262d2

Browse files
committed
ci(release): sign container images + attach SLSA provenance/SBOM (cosign keyless)
Achieves SLSA Build L2 for all pushed container images. Note: Scorecard's Signed-Releases check reads only GitHub Release *assets* (tarballs/wheels), not container registries, so this does not change the Scorecard score. The signing is for genuine supply-chain security: consumers of the GHCR images can verify who built them, via what workflow, from what commit. Design choices -------------- * Digest capture: `--metadata-file metadata.json` on `docker buildx build`; `jq -r '."containerimage.digest"'` extracts the immutable sha256 index digest. Signing is done against `IMAGE@DIGEST` — never against a mutable tag. * Signature: `cosign sign --yes` (keyless/Sigstore OIDC). The Rekor transparency-log entry embeds the GitHub Actions workflow identity. * Provenance: `actions/attest-build-provenance` with `push-to-registry: true`. This generates a SLSA provenance attestation signed with the OIDC token and stores it in the registry. Rationale for using both cosign + attest: cosign produces the OCI referrer signature object; attest-build-provenance produces a GitHub-native SLSA provenance attestation — they are complementary and independently verifiable. * SBOM: `--sbom=true` on the BuildKit invocation injects an in-toto SBOM attestation into the image at build time (no separate step needed). * `--provenance=true` on the BuildKit build also injects the BuildKit-level SLSA provenance attestation directly into the OCI index. prod-containers job ------------------- * Added `id-token: write` + `attestations: write` permissions. * Added `--provenance=true --sbom=true --metadata-file metadata.json` to the buildx invocation. * Added cosign-installer, `cosign sign`, and attest-build-provenance steps after the build+push step. prod-manifests job ------------------ * Added `id-token: write` + `attestations: write` permissions. * cosign-installer step added before the manifest-creation step. * After each `imagetools create`, the manifest-list digest is captured via `docker buildx imagetools inspect --format '{{json .Manifest}}'` and collected; a trailing `cosign sign` step signs all unique manifest-list digests. The per-python images (the leaves) are already signed in prod-containers; this closes the chain for the convenience tags. dev-containers job (container-pipeline) ---------------------------------------- * Applied identical signing (cosign + attest-build-provenance) gated on `steps.build.outputs.pushed == 'true'`, so PR builds and local-only builds skip signing entirely. Push paths (push to main/develop, workflow_dispatch with push_images=true) receive the same supply-chain guarantees as prod.
1 parent 0fbbe07 commit 99262d2

2 files changed

Lines changed: 105 additions & 0 deletions

File tree

.github/workflows/dev-containers.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ jobs:
103103
contents: read
104104
packages: write
105105
security-events: write
106+
# Required for keyless cosign signing (ambient OIDC token) and
107+
# actions/attest-build-provenance on pushed images.
108+
id-token: write
109+
attestations: write
106110

107111
steps:
108112
- name: Checkout code
@@ -134,6 +138,7 @@ jobs:
134138
run: make build
135139

136140
- name: Build and scan container image
141+
id: build
137142
shell: bash
138143
env:
139144
REGISTRY: ${{ needs.config.outputs.container-registry }}
@@ -169,12 +174,23 @@ jobs:
169174
--build-arg "VCS_REF=$(git rev-parse --short HEAD)" \
170175
--build-arg "PYTHON_VERSION=$PYTHON_VERSION" \
171176
--build-arg "PACKAGE_NAME_SHORT=$PACKAGE_NAME_SHORT" \
177+
--provenance=true \
178+
--sbom=true \
179+
--metadata-file metadata.json \
172180
--cache-from type=gha \
173181
--cache-to type=gha,mode=max \
174182
--push \
175183
-t "$REGISTRY/$IMAGE_NAME:$VERSION-python$PYTHON_VERSION" \
176184
.
177185
186+
# Extract the immutable digest for the signing steps below.
187+
DIGEST=$(jq -r '."containerimage.digest"' metadata.json)
188+
{
189+
echo "digest=${DIGEST}"
190+
echo "image=${REGISTRY}/${IMAGE_NAME}"
191+
echo "pushed=true"
192+
} >> "$GITHUB_OUTPUT"
193+
178194
# Pull back a single-arch image for local Trivy scanning.
179195
# Multi-arch manifests cannot be loaded/scanned; we use the first
180196
# entry of build-platforms (SCAN_PLATFORM) rather than hardcoding amd64.
@@ -195,8 +211,29 @@ jobs:
195211
--load \
196212
-t "$REGISTRY/$IMAGE_NAME:$VERSION-python$PYTHON_VERSION" \
197213
.
214+
echo "pushed=false" >> "$GITHUB_OUTPUT"
198215
fi
199216
217+
- name: Install cosign
218+
# Only needed when an image was pushed; skip on PR / local-only builds.
219+
if: steps.build.outputs.pushed == 'true'
220+
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
221+
222+
- name: Sign container image (keyless / OIDC)
223+
if: steps.build.outputs.pushed == 'true'
224+
env:
225+
DIGEST: ${{ steps.build.outputs.digest }}
226+
IMAGE: ${{ steps.build.outputs.image }}
227+
run: cosign sign --yes "$IMAGE@$DIGEST"
228+
229+
- name: Attach SLSA provenance attestation
230+
if: steps.build.outputs.pushed == 'true'
231+
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
232+
with:
233+
subject-name: ${{ steps.build.outputs.image }}
234+
subject-digest: ${{ steps.build.outputs.digest }}
235+
push-to-registry: true
236+
200237
- name: Run Trivy security scan
201238
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
202239
with:

.github/workflows/prod-release.yml

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ jobs:
9595
contents: read
9696
packages: write
9797
security-events: write
98+
# Required for keyless cosign signing (ambient OIDC token) and
99+
# actions/attest-build-provenance.
100+
id-token: write
101+
attestations: write
98102

99103
steps:
100104
- name: Checkout code
@@ -119,6 +123,7 @@ jobs:
119123
path: dist/
120124

121125
- name: Build and push container image
126+
id: build
122127
env:
123128
REGISTRY: ${{ needs.config.outputs.container-registry }}
124129
IMAGE_NAME: ${{ needs.config.outputs.container-image }}
@@ -134,17 +139,49 @@ jobs:
134139
--build-arg "VCS_REF=$(git rev-parse --short HEAD)" \
135140
--build-arg "PYTHON_VERSION=$PYTHON_VERSION" \
136141
--build-arg "PACKAGE_NAME_SHORT=$PACKAGE_NAME_SHORT" \
142+
--provenance=true \
143+
--sbom=true \
144+
--metadata-file metadata.json \
137145
--cache-from type=gha \
138146
--cache-to type=gha,mode=max \
139147
--push \
140148
-t "$REGISTRY/$IMAGE_NAME:$VERSION-python$PYTHON_VERSION" \
141149
.
142150
151+
# Extract the immutable digest from the BuildKit metadata output.
152+
# containerimage.digest is the multi-arch index digest (sha256:…).
153+
DIGEST=$(jq -r '."containerimage.digest"' metadata.json)
154+
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
155+
echo "image=${REGISTRY}/${IMAGE_NAME}" >> "$GITHUB_OUTPUT"
156+
143157
# Trivy cannot scan a multi-arch manifest — pull back a single-arch image.
144158
# Use the first entry of build-platforms (same approach as dev-containers.yml).
145159
SCAN_PLATFORM=$(echo "$BUILD_PLATFORMS" | cut -d, -f1)
146160
docker pull --platform "$SCAN_PLATFORM" "$REGISTRY/$IMAGE_NAME:$VERSION-python$PYTHON_VERSION"
147161
162+
- name: Install cosign
163+
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
164+
165+
- name: Sign container image (keyless / OIDC)
166+
# Signs the immutable digest — not a mutable tag — using the GitHub OIDC
167+
# ambient credential. The Rekor transparency-log entry records the
168+
# workflow identity, giving consumers a verifiable supply-chain link.
169+
env:
170+
DIGEST: ${{ steps.build.outputs.digest }}
171+
IMAGE: ${{ steps.build.outputs.image }}
172+
run: cosign sign --yes "$IMAGE@$DIGEST"
173+
174+
- name: Attach SLSA provenance attestation
175+
# Generates a SLSA Build L2-conformant provenance attestation signed with
176+
# the GitHub OIDC token and pushes it to the registry alongside the image.
177+
# Complements the cosign signature: signature proves who signed; provenance
178+
# records exactly what was built and how (workflow, inputs, commit SHA).
179+
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
180+
with:
181+
subject-name: ${{ steps.build.outputs.image }}
182+
subject-digest: ${{ steps.build.outputs.digest }}
183+
push-to-registry: true
184+
148185
- name: Run Trivy security scan
149186
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
150187
with:
@@ -171,6 +208,9 @@ jobs:
171208
permissions:
172209
contents: read
173210
packages: write
211+
# Required for keyless cosign signing of the manifest-list digests.
212+
id-token: write
213+
attestations: write
174214

175215
steps:
176216
- name: Checkout code
@@ -183,7 +223,11 @@ jobs:
183223
username: ${{ github.actor }}
184224
password: ${{ secrets.GITHUB_TOKEN }}
185225

226+
- name: Install cosign
227+
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
228+
186229
- name: Create production tag manifests
230+
id: manifests
187231
shell: bash
188232
env:
189233
REGISTRY: ${{ needs.config.outputs.container-registry }}
@@ -206,6 +250,9 @@ jobs:
206250
fi
207251
done < <(make get-container-tags-env)
208252
253+
# Collect all created manifest-list digests so the signing step can sign them.
254+
MANIFEST_REFS=""
255+
209256
# Create primary tags (latest, version)
210257
if [[ -n "${primary_tags:-}" ]]; then
211258
IFS=',' read -ra TAGS <<< "$primary_tags"
@@ -214,6 +261,9 @@ jobs:
214261
docker buildx imagetools create \
215262
-t "$REGISTRY/$IMAGE:$tag" \
216263
"$REGISTRY/$IMAGE:$VERSION-python$DEFAULT_PYTHON"
264+
# Resolve the digest of the newly-created manifest list.
265+
DIGEST=$(docker buildx imagetools inspect --format '{{json .Manifest}}' "$REGISTRY/$IMAGE:$tag" | jq -r '.digest')
266+
MANIFEST_REFS="${MANIFEST_REFS} $REGISTRY/$IMAGE@$DIGEST"
217267
done
218268
fi
219269
@@ -226,9 +276,27 @@ jobs:
226276
docker buildx imagetools create \
227277
-t "$REGISTRY/$IMAGE:$tag" \
228278
"$REGISTRY/$IMAGE:$VERSION-python$py_ver"
279+
DIGEST=$(docker buildx imagetools inspect --format '{{json .Manifest}}' "$REGISTRY/$IMAGE:$tag" | jq -r '.digest')
280+
MANIFEST_REFS="${MANIFEST_REFS} $REGISTRY/$IMAGE@$DIGEST"
229281
done
230282
fi
231283
284+
# Deduplicate refs (multiple tags may resolve to the same digest).
285+
UNIQUE_REFS=$(echo "$MANIFEST_REFS" | tr ' ' '\n' | sort -u | tr '\n' ' ')
286+
echo "manifest-refs=${UNIQUE_REFS}" >> "$GITHUB_OUTPUT"
287+
288+
- name: Sign manifest-list images (keyless / OIDC)
289+
# The per-python images were already signed in prod-containers; signing the
290+
# manifest lists (latest, version, pythonX.Y tags) closes the chain so
291+
# consumers of the convenience tags can also verify a signature.
292+
env:
293+
MANIFEST_REFS: ${{ steps.manifests.outputs.manifest-refs }}
294+
run: |
295+
for ref in $MANIFEST_REFS; do
296+
echo "Signing manifest list: $ref"
297+
cosign sign --yes "$ref"
298+
done
299+
232300
prod-pypi:
233301
name: Publish to PyPI
234302
needs: [config, build-wheel]

0 commit comments

Comments
 (0)