Skip to content

Commit bc2622a

Browse files
waleedlatif1claude
andcommitted
feat(supply-chain): sign published images and generate the chart image inventory
Enterprise security reviews ask for three artifacts Sim did not publish: a signature proving who built an image, SLSA provenance describing how, and an SBOM listing what is inside. Add all three, plus the image inventory an operator needs to mirror Sim into a disconnected registry. attest-images runs after create-ghcr-manifests rather than inside the build. buildx's own provenance/sbom attestations stay off because the extra manifests they add to an index break the imagetools retagging promote-images depends on; attaching attestations to the finished index leaves it untouched, since they are stored as separate referrer manifests. The subject is the sha index digest — imagetools create is deterministic, so the version and latest indexes built from the same per-arch manifests share that digest and one attestation covers every tag a customer can pull. The digest comes from `{{json .Manifest}}` piped through jq rather than `{{.Manifest.Digest}}`: the latter resolves the index to the runner's own platform, which would attest one architecture and leave the pulled index unsigned. helm/sim/images.yaml is derived from the rendered chart, not from values.yaml, because one image is written directly into a template — the NVIDIA device plugin — and a values-derived list misses it in the case that is hardest to notice, where the mirror succeeds and one pod still pulls from the internet. Tags stay unresolved: digests belong to a release, and the chart's sim.image helper already accepts a per-image digest for pinning at install time. The check runs in the helm workflow, which has Helm set up; check:audits does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015BwsJTEQRzWJaY4BRCkPZt
1 parent fd8c0d0 commit bc2622a

6 files changed

Lines changed: 385 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,108 @@ jobs:
611611
"${IMAGE}:${SHA}-amd64" "${IMAGE}:${SHA}-arm64"
612612
fi
613613
614+
# Sign the published images and attach SLSA provenance and an SBOM to each.
615+
#
616+
# This runs after create-ghcr-manifests rather than inside the build because
617+
# buildx's own provenance/sbom attestations stay off (see the note in
618+
# .github/actions/docker-build): the extra manifests they add to an index
619+
# break the `imagetools create` retagging that promote-images depends on.
620+
# Attaching attestations here instead leaves the index itself untouched — they
621+
# are stored as separate referrer manifests that point at it.
622+
#
623+
# The subject is the sha index digest. `imagetools create` builds the version
624+
# and latest indexes from the same two per-arch manifests in the same order,
625+
# so all three tags resolve to one digest and a single attestation covers
626+
# every tag a customer can pull.
627+
attest-images:
628+
name: Attest Images
629+
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
630+
timeout-minutes: 15
631+
needs: [create-ghcr-manifests]
632+
if: >-
633+
!cancelled() &&
634+
needs.create-ghcr-manifests.result == 'success' &&
635+
github.event_name == 'push' && github.ref == 'refs/heads/main'
636+
permissions:
637+
contents: read
638+
packages: write
639+
# Sigstore signs against the runner's OIDC identity; no key material is stored.
640+
id-token: write
641+
attestations: write
642+
strategy:
643+
fail-fast: false
644+
matrix:
645+
image:
646+
- ghcr.io/simstudioai/simstudio
647+
- ghcr.io/simstudioai/migrations
648+
- ghcr.io/simstudioai/realtime
649+
- ghcr.io/simstudioai/pii
650+
- ghcr.io/simstudioai/cron
651+
652+
steps:
653+
- name: Login to GHCR
654+
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
655+
with:
656+
registry: ghcr.io
657+
username: ${{ github.repository_owner }}
658+
password: ${{ secrets.GITHUB_TOKEN }}
659+
660+
# Resolved once and reused by every step below: signing a tag would sign
661+
# whatever that tag points at when the step runs, which is not necessarily
662+
# what this run published.
663+
#
664+
# `{{json .Manifest}}` piped through jq, not `{{.Manifest.Digest}}` — the
665+
# latter resolves the index to the runner's own platform and prints that
666+
# manifest's digest instead, which would attest one architecture and leave
667+
# the index a customer actually pulls unsigned.
668+
- name: Resolve index digest
669+
id: digest
670+
run: |
671+
DIGEST="$(docker buildx imagetools inspect "${{ matrix.image }}:${{ github.sha }}" \
672+
--format '{{json .Manifest}}' | jq -r '.digest')"
673+
if [ -z "$DIGEST" ] || [ "$DIGEST" = "null" ]; then
674+
echo "::error::Could not resolve a digest for ${{ matrix.image }}:${{ github.sha }}"
675+
exit 1
676+
fi
677+
echo "value=${DIGEST}" >> "$GITHUB_OUTPUT"
678+
679+
- name: Generate SBOM
680+
uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2
681+
with:
682+
image: ${{ matrix.image }}@${{ steps.digest.outputs.value }}
683+
format: spdx-json
684+
output-file: sbom.spdx.json
685+
# The action's own release upload is for workflows triggered by a
686+
# release; these attach to the image instead.
687+
upload-artifact: false
688+
upload-release-assets: false
689+
690+
- name: Attest SBOM
691+
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
692+
with:
693+
subject-name: ${{ matrix.image }}
694+
subject-digest: ${{ steps.digest.outputs.value }}
695+
sbom-path: sbom.spdx.json
696+
# Stored alongside the image so a mirrored registry carries the
697+
# attestation with it, rather than only being retrievable from GitHub.
698+
push-to-registry: true
699+
700+
- name: Attest build provenance
701+
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
702+
with:
703+
subject-name: ${{ matrix.image }}
704+
subject-digest: ${{ steps.digest.outputs.value }}
705+
push-to-registry: true
706+
707+
- name: Install Cosign
708+
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
709+
710+
# The attestations above prove how the image was built; this is the plain
711+
# signature that admission controllers (Kyverno, the Sigstore policy
712+
# controller) verify before admitting a pod.
713+
- name: Sign image
714+
run: cosign sign --yes "${{ matrix.image }}@${{ steps.digest.outputs.value }}"
715+
614716
# Check if docs changed
615717
# Smallest runner on purpose: a depth-2 checkout plus a path filter, no
616718
# install and no build.

.github/workflows/helm.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@ jobs:
4343
- name: Scheduler parity (docker/crontab vs helm cronjobs)
4444
run: bun run scripts/check-cron-parity.ts
4545

46+
# helm/sim/images.yaml is what an operator mirrors into a disconnected
47+
# registry, so a chart change that adds an image has to update it. Lives
48+
# here rather than in `check:audits` because it renders the chart, and the
49+
# audits job has no Helm. The script imports only node builtins.
50+
- name: Image inventory is current
51+
run: bun run images:check
52+
4653
- name: Helm lint
4754
run: helm lint helm/sim --values helm/sim/ci/default-values.yaml
4855

helm/sim/images.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Generated by `bun run images:generate`. Do not edit this file directly.
2+
#
3+
# Every container image a complete Sim install pulls, rendered from this chart
4+
# with all optional components enabled. Mirror all of them into a disconnected
5+
# registry before installing, then point the chart at your registry with
6+
# `global.imageRegistry` and pin each image's `digest` to what your mirror
7+
# resolved.
8+
chartVersion: 1.8.0
9+
appVersion: v0.8.18
10+
images:
11+
- busybox:1.36
12+
- curlimages/curl:8.5.0
13+
- ghcr.io/simstudioai/copilot:v0.8.18
14+
- ghcr.io/simstudioai/migrations:v0.8.18
15+
- ghcr.io/simstudioai/pii:v0.8.18
16+
- ghcr.io/simstudioai/realtime:v0.8.18
17+
- ghcr.io/simstudioai/simstudio:v0.8.18
18+
- nvcr.io/nvidia/k8s-device-plugin:v0.18.2
19+
- ollama/ollama:0.23.2
20+
- otel/opentelemetry-collector-contrib:0.91.0
21+
- pgvector/pgvector:pg17
22+
- postgres:17-alpine
23+
- redis:7-alpine

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@
7979
"tool-metadata:check": "bun run scripts/sync-tool-metadata.ts --check",
8080
"deployment-config:generate": "bun run scripts/generate-deployment-config.ts",
8181
"deployment-config:check": "bun run scripts/generate-deployment-config.ts --check",
82+
"images:generate": "bun run scripts/generate-image-manifest.ts",
83+
"images:check": "bun run scripts/generate-image-manifest.ts --check",
8284
"integration-catalog:check": "bun run scripts/check-integration-catalog.ts",
8385
"docs:check": "bun run scripts/generate-docs.ts --check",
8486
"mship-tools:generate": "bun run scripts/sync-tool-catalog.ts",
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { collectImages, renderManifest } from './generate-image-manifest'
3+
4+
describe('collectImages', () => {
5+
it('finds images across every container key a pod spec can use', () => {
6+
const images = collectImages([
7+
{
8+
kind: 'Deployment',
9+
spec: {
10+
template: {
11+
spec: {
12+
initContainers: [{ image: 'ghcr.io/simstudioai/migrations:v1' }],
13+
containers: [{ image: 'ghcr.io/simstudioai/simstudio:v1' }],
14+
ephemeralContainers: [{ image: 'busybox:1.36' }],
15+
},
16+
},
17+
},
18+
},
19+
])
20+
21+
expect(images).toEqual([
22+
'busybox:1.36',
23+
'ghcr.io/simstudioai/migrations:v1',
24+
'ghcr.io/simstudioai/simstudio:v1',
25+
])
26+
})
27+
28+
it('reaches containers nested below a workload wrapper', () => {
29+
const images = collectImages([
30+
{
31+
kind: 'CronJob',
32+
spec: {
33+
jobTemplate: {
34+
spec: { template: { spec: { containers: [{ image: 'curlimages/curl:8.5.0' }] } } },
35+
},
36+
},
37+
},
38+
])
39+
40+
expect(images).toEqual(['curlimages/curl:8.5.0'])
41+
})
42+
43+
it('deduplicates the same image pulled by several workloads', () => {
44+
const container = { containers: [{ image: 'redis:7-alpine' }] }
45+
const images = collectImages([
46+
{ spec: { template: { spec: container } } },
47+
{ spec: { template: { spec: container } } },
48+
])
49+
50+
expect(images).toEqual(['redis:7-alpine'])
51+
})
52+
53+
it('ignores an image field that is not a container image', () => {
54+
const images = collectImages([{ metadata: { annotations: { image: 'not-a-container' } } }])
55+
56+
expect(images).toEqual([])
57+
})
58+
59+
it('skips a container whose image is absent or empty', () => {
60+
const images = collectImages([
61+
{ spec: { containers: [{ name: 'no-image' }, { image: '' }, { image: 'busybox:1.36' }] } },
62+
])
63+
64+
expect(images).toEqual(['busybox:1.36'])
65+
})
66+
67+
it('tolerates null entries rather than throwing on a sparse render', () => {
68+
const images = collectImages([null, { spec: { containers: [null, { image: 'redis:7' }] } }])
69+
70+
expect(images).toEqual(['redis:7'])
71+
})
72+
})
73+
74+
describe('renderManifest', () => {
75+
it('renders versions and a sorted image list', () => {
76+
const manifest = renderManifest({
77+
chartVersion: '1.8.0',
78+
appVersion: 'v0.8.18',
79+
images: ['busybox:1.36', 'redis:7-alpine'],
80+
})
81+
82+
expect(manifest).toContain('chartVersion: 1.8.0')
83+
expect(manifest).toContain('appVersion: v0.8.18')
84+
expect(manifest).toContain(' - busybox:1.36\n - redis:7-alpine\n')
85+
})
86+
87+
it('ends with a trailing newline so the checked-in file is POSIX-clean', () => {
88+
const manifest = renderManifest({ chartVersion: '1.0.0', appVersion: 'v1', images: ['a:1'] })
89+
90+
expect(manifest.endsWith('\n')).toBe(true)
91+
expect(manifest.endsWith('\n\n')).toBe(false)
92+
})
93+
})

0 commit comments

Comments
 (0)