NVIDIA / NVIDIA/cluster-readiness-engine
Epic: Software supply chain security — verifiable release artifacts
@mchmarny is already working on this.
Since Sep 2, 2026.
- Dominant language
- Go
- Stars
- 55
- Forks
- 19
- Avg merge
- 1d 5h
- Merged PRs (30d)
- 107
Description
Problem
Every artifact this project publishes is consumed by operators who run it with cluster-admin-adjacent RBAC on GPU fleets. Today a consumer cannot answer "did NVIDIA build this, from what source, containing what?" for most of what we ship.
The container image is signed and carries an SBOM. Nothing else is. And the image's own coverage has defects:
- Wrong SBOM subject.
publish.yml:96generates one SBOM against the multi-architecture index digest. Syft resolves an index to a single platform, so thelinux/arm64image is described by alinux/amd64SBOM. An SBOM describes exactly one root filesystem; its subject must be a per-platform manifest digest. - No build provenance anywhere. No SLSA statement links any artifact to the source commit, the workflow, or the run that produced it. A signature proves "someone in this repo signed it," not "this came from tag
vX.Y.Z." - The published verification command does not verify what it claims.
SECURITY.md:68instructs users to pass--certificate-identity-regexp='https://github.com/NVIDIA/cluster-readiness-engine'. That regexp is unanchored and workflow-agnostic.publish.ymlalso runs on push tomain(publish.yml:7-9), signingmain-<sha>images under.../publish.yml@refs/heads/main— which that regexp accepts. A user following our own documentation will accept an unreleased main-branch build as a release. - The signing toolchain is not pinned.
anchore/sbom-action@v0.24.2(publish.yml:94) is a floating tag while every other action in the repo is SHA-pinned.cosign-installer(publish.yml:86) has nocosign-release, so the cosign version — and with it the attestation bundle format and Rekor backend — is an inherited default that moves without a commit here. - Nothing ever verifies. The post-publish gate at
release.yml:274-304checks SHA-256 digests and the version string. It never runscosign verify. A release where signing silently no-ops ships green. - The release notes make a claim the release never checked.
release.yml:226tells users the image is.../manager:<tag>. That image was published by a different workflow run (publish.yml) with no digest handoff.release.ymlnever confirms it exists, never learns its digest, and never verifies its signature.
Unsigned entirely: the Helm chart, all four nvcrectl binaries, and the installer script that our own README tells users to curl | bash.
Status — 2026-09-04
Phases 0–3 are complete. Phase 4 is underway. v0.2.0-rc.1 ran the whole pipeline end to
end in 10m28s: run 33753017998,
release. The gate
drafted the release, verified every artifact, published it, and re-checked it anonymously.
v0.2.0-rc.2 proved the gate rejects a tampered artifact; #302 then proved it rejects an
absent one, which is a different set of branches — the ones that run when a signing step
silently no-ops.
| Count | Issues | |
|---|---|---|
| Closed | 9 | #263, #264, #265, #266, #267, #268, #269, #271, #276 |
| Open — 9 of 11 criteria met | 1 | #270 |
| Open — enforced only on a fork | 1 | #274 (draft PR #295, verified sound) |
| Open — parked, needs rework | 1 | #272 |
| Open — not started | 3 | #273, #275, #301 |
Every artifact this epic set out to sign is signed, attested, verified at release time, and
documented with commands that CI executes verbatim. The identity hole in SECURITY.md:68 — a
regexp that accepted a main build as a release — is closed and the fix is enforced by tests.
Landed since the last review
- #276 closed (#305). The weekly image scan ships. Dispatched against
mainon 2026-09-04
and posted a real Slack alert, so the notification path is verified end to end rather than
assumed. It found a genuine issue on its first run:v0.1.0ships
google.golang.org/grpc@v1.83.0with fixable HighGHSA-vp52-pcj8-j9qc, already fixed on
mainby #261 the day afterv0.1.0shipped. That is precisely the gap this epic's Phase 4
exists to close — a CVE published against a release nobody would otherwise notice. A release
is planned for Monday 2026-09-08, well insideSECURITY.md's 30-day window. - Suppression policy consolidated (#306, #307).
.grype.yamlignore rules were replaced by
.openvex.json, consumed as grype--vex, with.grype.yamlenforced empty of suppressions.
Grype applies both in the same run, so two homes meant the impact analysis for a CVE could
live in either file and the weaker-enforced one was the easier to reach for. OpenVEX has no
expiry field, so the deadline became a 180-day re-affirmation rule, checked before the scan
fans out. Triage procedure in.claude/skills/managing-openvex.md. This changes a non-goal
below — see the corrected bullet. - #302 proved the release gate rejects absence, not only tampering, and corrected an ADR-074
claim that described logic which was never built.
What the RC proved
What the RC proved
Re-verified from a workstation with only cosign v3.1.3, crane and jq, rather than trusting
the pipeline's own output.
| Check | Result |
|---|---|
| Image index signature and SLSA provenance | OK |
| Provenance predicate | repository=NVIDIA/cluster-readiness-engine, ref=refs/tags/v0.2.0-rc.1, commit=934f472a… — the tagged commit exactly |
| Per-platform CycloneDX SBOMs (amd64, arm64) | OK, bound to their own manifest digests |
blob subject kind — first ever execution |
13 bundles, 25 assets, no filename collision |
| All 5 binaries + installer, all 4 SBOM sidecars | OK |
| Chart signature and provenance | OK |
| All 4 digests in the release notes | resolve |
| Gate: draft → verify → publish → anonymous re-check | passed; retraction step correctly skipped |
Gate rejects a tampered artifact (rc.2) |
ok checksums.txt above two signature failures |
The identity does real work:
identity for v0.1.0 -> REJECTED
old documented regexp -> ACCEPTED (the hole this epic closed, still reproducible)
What remains
- The gate rejects tampering; it has not been shown to reject absence. #270's three open
criteria — a deleted asset, a skipped signing step, a legacy-format bundle — are different
code paths from the onerc.2exercised. The first two are testable without cutting a tag,
using the stub-harness patterntest/releasepolicyalready uses for the installer. - Four hardcoded asset lists can drift from what the release uploads (#274). Adding a
platform ships an unsigned binary with every gate green. - A consumer-compatibility gap that was not in the original plan. The bundle format we
publish is not readable by Kyverno's legacyverifyImagespath or by Sigstore
policy-controller at its chart default. Documented on the verification page; enforcement
samples are #272. - Nothing re-verifies after publication (#273) and nothing watches the transparency log
for signatures we did not make (#275).
The recurring failure in this epic
Worth recording, because it happened at least seven times and every instance was caught by
review or by running the thing rather than by reading it: checks that look protective and are
not. A 2>&1 that let a gh upgrade notice corrupt a draft-state variable. A skip-verify test
searching for a string the file never contains. A cosign stub that ignored its arguments. A docs
gate that skipped and read as green. A grype suppression test bypassable two ways. In each case
the code read correctly. Reading correctly is what all of them had in common.
Per-issue evidence is posted on #267, #268, #269, #270 and #271.
Goal
Every artifact a user can download or pull from a tagged release carries a Sigstore signature, a SLSA Build Provenance v1 statement, and an SBOM — produced by a tamper-resistant builder, verified by the release itself before publication, verified again daily after publication, and documented with an identity contract precise enough that following it rejects anything we did not release.
Post-release assurance extends to vulnerability posture: the image we shipped is scanned weekly against current advisories, because a CVE published the day after a release affects every operator running it and nothing in this repository would otherwise notice.
Non-goals
- Migrating the release to GoReleaser. Separate epic if we ever want it.
- A private Sigstore / KMS signing path. Public-good Sigstore keyless only.
- Signing the docs site, test fixtures, or UAT artifacts.
- An
nvcrectl verifysubcommand.cosignandghcover it; a bespoke verifier is surface area we would have to keep correct. OpenVEX vulnerability triage.Adopted 2026-09-04 (#306). The original reasoning was that triage needed a process to exist first, with.grype.yamlsuppressions starting it. That process ran for exactly one finding before it became clear that keeping two suppression mechanisms was the problem, not the sequencing: grype applies.grype.yamlignores and OpenVEX statements in the same run, so the impact analysis for a CVE could live in either file and the weaker-enforced one was the easier to reach for..openvex.jsonis now the single home, consumed as--vex, with.grype.yamlenforced empty of suppressions. Publishing a signed OpenVEX attestation remains out of scope — ADR-074's artifact contract still has no OpenVEX row, and decision 5 keeps both publication routes open.
Target artifact contract
| Artifact | Signature | SBOM | Provenance | Subject |
|---|---|---|---|---|
manager image, per platform |
cosign, new bundle format | CycloneDX-JSON, one per platform | — | per-platform manifest digest |
manager image, index |
cosign | — | SLSA Build Provenance v1 | index digest |
| Helm chart (OCI) | cosign | — | SLSA Build Provenance v1 | chart OCI digest |
nvcrectl-{linux,darwin}-{amd64,arm64} |
cosign attest-blob bundle |
CycloneDX-JSON per binary | SLSA Build Provenance v1 | file digest |
installer |
cosign attest-blob bundle |
— | SLSA Build Provenance v1 | file digest |
Every *.cyclonedx.json release asset |
cosign attest-blob bundle |
— | — | file digest |
Verification identity contract
All release attestations are produced by one reusable workflow, so a consumer pins exactly one identity:
--certificate-oidc-issuer https://token.actions.githubusercontent.com
--certificate-identity https://github.com/NVIDIA/cluster-readiness-engine/.github/workflows/attest.yml@refs/tags/<TAG>
Exact, not a regexp. It names the workflow and the tag. A main build cannot satisfy it, and neither can a signature from release vX presented as release vY.
Key design decisions
| # | Fork | Decision | Rationale |
|---|---|---|---|
| D1 | Adopt GoReleaser vs. extend the existing hand-rolled build | Extend what's there | release.yml carries hard-won guards from #194/#195 (version stamping, installer 404). A GoReleaser migration churns all of it and is a separate epic. Reversible later. |
| D2 | Attest inline vs. from a reusable workflow | Reusable attest.yml, targeting SLSA Build L2 (corrected 2026-09-02) |
It collapses signing into one stable certificate identity users pin — which is what fixes the main-branch-looks-like-a-release hole — and isolates the signing token from caller-defined build steps. It does not reach L3: the builds stay in the callers, and L3 requires the build itself to run in the protected reusable workflow. Reaching L3 is deferred to its own decision. See ADR-074 decision 4. |
| D3 | SBOM format | Stay on CycloneDX-JSON (reversed 2026-09-02) | Both are first-class cosign predicate types and both are formally standardized (CycloneDX ECMA-424, SPDX ISO/IEC 5962), so the original "first-class type" and "the ISO standard" arguments for switching favored neither. What remained was cross-repo consistency, worth one flag value — against a breaking change for anyone parsing the predicate today, and the loss of CycloneDX's native VEX carriage, which matters given #276. See ADR-074 Alternatives Considered #5. |
| D4 | Binary attestation transport | cosign attest-blob .sigstore.json sidecars as release assets |
Verifiable with cosign alone, offline, no GitHub API dependency. Registry-attached actions/attest-build-provenance is used for the image, where it's the right fit. |
Phases
| Phase | Outcome | Subtasks |
|---|---|---|
| 0 — Foundation | Design of record; toolchain pinned so results are reproducible | #263, #264 |
| 1 — Produce | Every artifact signed, attested, SBOM'd | #265, #266, #267, #268, #269 |
| 2 — Verify | A release that does not verify does not publish | #270 |
| 3 — Document | Users and admission controllers can act on it | #271, #272 |
| 4 — Assure | Tampering and new vulnerabilities after publication are detected | #273, #274, #275, #276 |
Subtasks
| # | Subtask | Phase | Depends on | Size | Status |
|---|---|---|---|---|---|
| #263 | ADR-074: supply chain artifact and verification contract | 0 | — | S | ✅ Closed — #277 |
| #264 | Pin and harden the release signing toolchain | 0 | — | S | ✅ Closed — #278 |
| #265 | Reusable attestation workflow (attest.yml) — isolated signer |
1 | #263, #264 | L | ✅ Closed — #281, #282 |
| #266 | Container image: SLSA provenance and per-platform SBOMs | 1 | #265 | M | ✅ Closed — #286 |
| #267 | Helm chart: signature and provenance | 1 | #265 | M | ✅ Closed — #289 |
| #268 | CLI binaries and installer: provenance, SBOMs, signed sidecars | 1 | #265 | L | ✅ Closed — #290, #298 |
| #269 | Digest handoff between publish and release | 1 | #266, #267 | M | ✅ Closed — #292 |
| #270 | Release-time verification gate | 2 | #266, #267, #268, #269 | M | 🟡 9 of 11 criteria. #293 built it, #302 proved it rejects absence. Two open — see below |
| #271 | User-facing artifact verification documentation | 3 | #270 | M | ✅ Closed — #299 |
| #272 | Admission-policy samples (Kyverno, Sigstore policy-controller) | 3 | #271 | S | ⬜ Parked — needs rework for the bundle-format gap |
| #273 | Daily release re-verification | 4 | #270 | L | ⬜ Not started |
| #274 | Workflow policy regression tests | 4 | #265, #270 | M | 🟢 Draft PR #295 satisfies all 5 criteria, mutation-verified; blocked on 9 lint findings |
| #275 | Rekor signing-identity monitoring (stretch) | 4 | #273 | M | ⬜ Not started |
| #276 | Weekly image vulnerability scan with Slack notification | 4 | #266 | M | ✅ Closed — #305, #306, #307 |
| #301 | ADR-074 D2: correct the Build Level reasoning, and gate the reusable-workflow boundary | 4 | #265, #266 | M | ⬜ Not started — not blocking v0.2.0 |
#270's two open criteria.
- "Skipping any signing step fails the gate (test by dispatch with the step disabled)." #302
covers the job level: the threeneeds.<job>.resultguards are driven throughskipped,
failureandcancelled. A signing step disabled inside a job that still succeeds passes
all three.verify-releasere-checks signatures against the registry and would catch it, but
that path is untested and the criterion prescribes a dispatch. - "A legacy-format bundle is rejected rather than read." #302 established that no such logic
exists anywhere in the release path — ADR-074 claimed it did, and that claim has been
corrected. This criterion therefore describes work that was never done, not a test that is
missing. It needs a decision: implement it, or strike it with reasoning.
#274 is close, and the work is not ours. Draft PR #295 (asivanadi0, on a fork) satisfies
all five acceptance criteria. Verified on 2026-09-04 by mutating each defect rather than
reading the tests, since this issue's own standard is that a test which does not fail on the
reintroduced defect is decoration: a rogue cosign sign in publish.yml, a rogue
actions/attest-build-provenance in release.yml, a second trigger on attest.yml,
id-token: write on a non-signing job, an action unpinned to @v7, and a fifth platform in
NVCRECTL_PLATFORMS — every one caught. No network, no cluster, passes alongside the existing
suite.
It is blocked only on lint: 9 mechanical findings (5 lll, 2 modernize, 1 prealloc, 1
goconst), no logic changes. Reviewed with specifics at
https://github.com/NVIDIA/cluster-readiness-engine/pull/295#pullrequestreview-5114169047.
This issue stays open until that merges. Nothing is enforced on main today — a rogue
signer, an unpinned action, or a new platform would all pass CI. The C4 pinning check is not
hypothetical: dependabot moves anchore/scan-action weekly under patterns: ["*"], and #306
had to add a runtime assertion because nothing guaranteed that pin stays a pin.
Phases 0–2 are this epic's definition of done. Phases 3–4 are in scope but cuttable if the release date moves.
Sequencing
#263 and #264 are parallel. #265 gates all of Phase 1. #266/#267/#268 are parallel once it lands. #269 needs #266 and #267. #270 needs all of Phase 1. #271 → #272 and #273 → #275 are parallel tails. #276 needs only #266 and can run alongside either tail. #274 can start as soon as #265 exists and should land before #270 merges, so the gate is born with a regression test.
Validation checkpoint
Most of this epic changes CI workflows whose real behaviour only appears when a tag is pushed — the attestation certificate identity, the digest handoff, and the release-time gate cannot be fully proven by a dry run. #269 has landed, so the RC is ready to cut once #270 merges; taking it after #270 means one tag exercises the gate as well, and a gate defect withholds a draft rather than publishing an unverified release. vX.Y.Z-rc.N clears all three tag validators and is marked as a prerelease automatically.
Findings from that run feed back into #266–#270 before any of them is considered done.
Acceptance criteria deferred to that RC run, carried here so they survive their issue closing:
| From | Criterion |
|---|---|
| #264 | cosign version printed in the publish job log matches the pinned COSIGN_VERSION |
| #264 | A published image carries attestations discoverable via cosign download attestation on the referrers path |
| #267 | The chart's signature and provenance verify against the tag identity, and helm pull by digest returns the chart that was verified |
| #267 | Whether Flux OCIRepository (.spec.verify.provider: cosign) and Argo CD can consume the signed chart — believed yes for Flux, unconfirmed against a real version |
| #268 | The blob subject kind has never executed; the RC is its first run. All 13 bundles emit with no filename collision |
| #268 | The installer's own fail-closed verification, cosign bootstrap and --skip-verify — deferred until the bundles exist to test against |
| #269 | release.yml builds the image it advertises, and the release notes carry digests that resolve |
| #270 | The gate runs, the release is drafted, verification passes, and only then does it become visible |
| #270 | A deliberately broken artifact is withheld — the gate must be shown to reject, not only to pass |
Tool pins that no updater currently watches are tracked separately in #279.
Epic acceptance criteria
- For a freshly cut tag, every row of the artifact contract table verifies against the pinned identity from a clean machine with only
cosignandghinstalled. - Deleting any signing step from any workflow fails CI.
- Deleting a published release asset, or repointing a tag to a different digest, opens a security issue within 24 hours.
-
SECURITY.mdand the new verification page contain no unanchored identity regexps. - No release artifact is produced by an unpinned tool version.
- A HIGH+ vulnerability in the released image reaches a human within a week, with the package, the advisory ID, and a triage path.
Risks
| Risk | Mitigation |
|---|---|
| Sigstore outage fails a release | Retry with backoff around every cosign call; liveness probes in the daily monitor so an outage reads as operational, not as tampering |
| SBOM format switch breaks a consumer | Repo is pre-1.0; announce in release notes for the first release that carries it |
| Reusable-workflow refactor breaks the release path | Land it behind a dry-run dispatch and cut a -rc tag before the first real release uses it |
| ghcr.io does not implement the OCI referrers API | Attestations land via the spec's referrers tag fallback; transparent to cosign, but documented commands must use cosign rather than a raw referrers call |
Open questions
SPDX vs. CycloneDX (D3).Resolved 2026-09-02: staying on CycloneDX. Recorded as a rejected alternative in ADR-074 so the reasoning is not relitigated.Digest handoff approach.Resolved 2026-09-02 (#292): the image build moved into a reusablebuild-image.yml.release.ymlcalls it on a tag and receives the index and both platform digests as job outputs;publish.ymltriggers onmainonly and ownsmain-<sha>dev images. The two workflows were not deliberately independent — they both fired on a tag and raced, and the release asserted an image tag it had never resolved. The race is removed rather than mitigated.Resolved 2026-09-02: fails closed. The soft fallback contradicted the epic's own premise that a same-origin unsigned checksum proves nothing.installerbehavior without cosign.installernow bootstraps a digest-pinned cosign, and skipping verification requires an explicit--skip-verifyflag. See ADR-074 Consequences.- Rekor identity monitoring (#275) — stretch, or in the committed set? Still open. Phases 0–2 are the definition of done, so this is cuttable.
- Milestone. Proposed v0.2.0 for Phases 0–2. No milestones exist in the repo yet.
- Slack webhook secret (#276). Needs a maintainer with repo admin to create
SLACK_WEBHOOK_PATHand pick a destination channel. Blocks the notification path, not the scan itself. - Scan target (#276). Proposal scans both the latest released image and
main. If only one, keep the released image.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.