Skip to content

feat(recipe): signed catalog provenance for V1 closed supply chain#1216

Merged
mchmarny merged 4 commits into
mainfrom
feat/recipe-catalog-provenance
Jun 8, 2026
Merged

feat(recipe): signed catalog provenance for V1 closed supply chain#1216
mchmarny merged 4 commits into
mainfrom
feat/recipe-catalog-provenance

Conversation

@mchmarny

@mchmarny mchmarny commented Jun 8, 2026

Copy link
Copy Markdown
Member

Summary

Adds retrievable provenance and a consumer verification path for the recipe catalog (registry.yaml + validators/catalog.yaml), filling the recipe-data signing workstream of the V1 closed-supply-chain bar.

Motivation / Context

The V1 "Closed Supply Chain" acceptance criterion requires every artifact — binary, image, recipe data, bundle — to have retrievable provenance and a documented verification path. Today the recipe catalog is shipped unsigned with no verification path; this PR closes that gap.

Fixes: #1155
Related: #1149 (parent epic)

Type of Change

  • New feature (non-breaking change that adds functionality)

Component(s) Affected

  • CLI (pkg/cli)
  • Recipe engine / data (pkg/recipe, new pkg/recipe/catalog)
  • Bundlers (pkg/bundler/attestation — additive BuildType field)
  • Build/CI/tooling (.goreleaser.yaml)

Implementation Notes

Implementation reuses pkg/bundler/attestation (Sigstore keyless OIDC + DSSE + in-toto + SLSA Provenance v1) and pkg/bundler/verifier (Fulcio identity pinning), so catalog attestations are structurally identical to bundle attestations — same bundle file format, same predicate type, different buildType URI (https://aicr.nvidia.com/recipe-catalog/v1).

  • pkg/recipe/catalog (new): ComputeDigest (deterministic SHA-256 over serializer.MarshalYAMLDeterministic of both files), Sign (delegates to attestation.Attester), Verify (delegates to verifier.VerifyBinaryAttestation with NVIDIA CI identity pinning).
  • pkg/bundler/attestation: StatementMetadata.BuildType is optional — empty falls back to BundleBuildType, so existing bundle attestation callers are unchanged. New CatalogBuildType constant.
  • pkg/recipe: RegistryFileName/CatalogFileName exported; unexported aliases preserved for internal callers (zero churn elsewhere).
  • CLI: hidden aicr recipe sign-catalog --output <path> (CI-only, reuses the same OIDC precedence as aicr bundle --attest); user-facing aicr recipe verify-catalog <bundle-path> with optional --identity-pattern for testing.
  • Release: goreleaser after-hook signs the catalog once per release (gated on linux/amd64 + SLSA_PREDICATE env var, same guard as the binary attestation hook so local goreleaser runs skip the OIDC flow). The bundle is published as a recipe-catalog.sigstore.json release asset and included in the aicr tarball.
  • Optional runtime verification hook in LayeredDataProvider was intentionally not implemented; the CLI subcommand satisfies the V1 verification-path requirement and avoids a recipe → catalog → recipe import cycle.

Testing

make qualify

Results:

  • All unit tests pass with race detector (pkg/recipe/catalog 69.5% statement coverage)
  • Total coverage 76.5% (threshold 75%)
  • go vet: pass
  • golangci-lint: 0 issues

New tests cover determinism, missing-file error paths, NoOp signing, digest equivalence between ComputeDigest and Sign, bundle-not-found and invalid-bundle verification paths, and CLI subcommand registration (visible/hidden).

Risk Assessment

  • Low — Isolated, additive change; existing code paths unchanged. BuildType field defaults to empty → BundleBuildType (no regression). The goreleaser after-hook is gated on SLSA_PREDICATE env presence so local/PR builds are unaffected.

Rollout notes: The first release after this PR merges will publish the signature; older releases remain unsigned. Verification is opt-in via the new CLI subcommand.

Checklist

  • Tests pass locally (make test with -race)
  • Linter passes (make lint)
  • I did not skip/disable tests to make CI green
  • I added tests for new functionality
  • N/A — no user-facing behavior shipped to existing commands; new commands documented inline (--help)
  • Changes follow existing patterns in the codebase (pkg/errors, slog, deterministic YAML, Sigstore primitive reuse)
  • Commits are cryptographically signed (git commit -S)

@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Coverage Report ✅

Metric Value
Coverage 76.2%
Threshold 75%
Status Pass
Coverage Badge
![Coverage](https://img.shields.io/badge/coverage-76.2%25-green)

Merging this branch changes the coverage (2 decrease, 1 increase)

Impacted Packages Coverage Δ 🤖
github.com/NVIDIA/aicr/pkg/bundler/attestation 78.30% (-0.10%) 👎
github.com/NVIDIA/aicr/pkg/cli 62.38% (-0.70%) 👎
github.com/NVIDIA/aicr/pkg/recipe 86.00% (ø)
github.com/NVIDIA/aicr/pkg/recipe/catalog 85.25% (+85.25%) 🌟

Coverage by file

Changed files (no unit tests)

Changed File Coverage Δ Total Covered Missed 🤖
github.com/NVIDIA/aicr/pkg/bundler/attestation/statement.go 92.31% (-2.14%) 39 (+3) 36 (+2) 3 (+1) 👎
github.com/NVIDIA/aicr/pkg/cli/recipe.go 83.76% (ø) 117 98 19
github.com/NVIDIA/aicr/pkg/cli/recipe_sign_catalog.go 7.14% (+7.14%) 14 (+14) 1 (+1) 13 (+13) 👍
github.com/NVIDIA/aicr/pkg/cli/recipe_verify_catalog.go 30.00% (+30.00%) 10 (+10) 3 (+3) 7 (+7) 🌟
github.com/NVIDIA/aicr/pkg/recipe/catalog/digest.go 100.00% (+100.00%) 18 (+18) 18 (+18) 0 🌟
github.com/NVIDIA/aicr/pkg/recipe/catalog/doc.go 0.00% (ø) 0 0 0
github.com/NVIDIA/aicr/pkg/recipe/catalog/sign.go 78.26% (+78.26%) 23 (+23) 18 (+18) 5 (+5) 🌟
github.com/NVIDIA/aicr/pkg/recipe/catalog/verify.go 80.00% (+80.00%) 20 (+20) 16 (+16) 4 (+4) 🌟
github.com/NVIDIA/aicr/pkg/recipe/provider.go 90.50% (ø) 221 200 21

Please note that the "Total", "Covered", and "Missed" counts above refer to code statements instead of lines of code. The value in brackets refers to the test coverage of that file in the old version of the code.

@mchmarny mchmarny force-pushed the feat/recipe-catalog-provenance branch from e216f3b to 53a9913 Compare June 8, 2026 18:51
Adds retrievable provenance and a consumer verification path for the
recipe catalog (registry.yaml + validators/catalog.yaml), filling the
recipe-data signing workstream of the V1 closed supply chain bar.

Implementation reuses pkg/bundler/attestation (signing primitive,
Sigstore keyless OIDC, DSSE + in-toto + SLSA Provenance v1) and
pkg/bundler/verifier (Fulcio identity pinning) so catalog attestations
are structurally identical to bundle attestations — same bundle file
format, same predicate type, different buildType URI
(https://aicr.nvidia.com/recipe-catalog/v1).

Highlights:
- pkg/recipe/catalog: ComputeDigest (deterministic SHA-256 over
  serializer.MarshalYAMLDeterministic of both files), Sign (delegates
  to attestation.Attester), Verify (delegates to
  verifier.VerifyBinaryAttestation with NVIDIA CI identity pinning)
- pkg/bundler/attestation: StatementMetadata.BuildType (optional;
  empty falls back to BundleBuildType — no regression for existing
  callers) + CatalogBuildType constant
- pkg/recipe: export RegistryFileName/CatalogFileName (unexported
  aliases preserved for internal callers)
- CLI: hidden 'aicr recipe sign-catalog' (CI-only) +
  user-facing 'aicr recipe verify-catalog <bundle-path>'
- Release: goreleaser after-hook signs the catalog (gated on
  linux/amd64 + SLSA_PREDICATE so local builds skip OIDC); published
  as recipe-catalog.sigstore.json release asset and bundled into
  the aicr tarball

Closes #1155.
@mchmarny mchmarny force-pushed the feat/recipe-catalog-provenance branch from 53a9913 to 4e89973 Compare June 8, 2026 18:56
@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR implements signed recipe catalog provenance for AICR by adding deterministic SHA-256 digest computation over embedded registry and validator catalogs, Sigstore keyless signing via OIDC attesters, and verification against bundled signatures. Changes include new CatalogBuildType attestation support; core catalog.Sign and catalog.Verify library functions with comprehensive test coverage; CLI subcommands aicr recipe sign-catalog and aicr recipe verify-catalog; and release automation that invokes signing during builds and publishes signatures as release assets.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • NVIDIA/aicr#1208: Modifies pkg/cli/recipe.go command list—related CLI wiring changes.

Suggested labels

area/recipes, area/docs

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding signed catalog provenance for the V1 closed supply chain, which directly reflects the core objective.
Description check ✅ Passed The description comprehensively addresses the changeset, explaining objectives, implementation details, testing results, and risk assessment related to the signed catalog provenance feature.
Linked Issues check ✅ Passed The PR successfully implements all objectives from #1155: provides retrievable recipe catalog provenance via Sigstore attestations, implements signing via pkg/recipe/catalog/sign.go with deterministic digest computation, adds user-facing verification CLI (aicr recipe verify-catalog), and publishes signatures as release assets.
Out of Scope Changes check ✅ Passed All changes are scoped to the recipe catalog signing objectives. The goreleaser changes, new catalog package, CLI commands, attestation metadata additions, and test coverage are all directly aligned with issue #1155 requirements.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/recipe-catalog-provenance

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/recipe/catalog/digest.go`:
- Around line 40-41: The return in digest.go that currently uses errors.Wrap
when deterministicYAML (and similar calls) fail should use
errors.PropagateOrWrap so existing structured error codes (e.g.,
ErrCodeInternal, ErrCodeTimeout) are preserved; replace the errors.Wrap usage in
the function that returns attestation.AttestSubject (the error path calling
deterministicYAML) with errors.PropagateOrWrap and change the message to
accurately reflect the operation (e.g., "failed to process registry/catalog
content" or "failed to parse/serialize registry file") so parse/marshal/timeout
errors are not masked; apply the same replacement pattern to the similar wrap at
the other occurrence mentioned (lines 46-47).
- Line 87: The call to serializer.MarshalYAMLDeterministic(v) should have its
returned error wrapped using errors.PropagateOrWrap to preserve context; replace
the direct return with capturing the bytes and error, and if err != nil return
errors.PropagateOrWrap(err, "serializer.MarshalYAMLDeterministic failed")
(keeping the original return value on success). Update the surrounding function
that currently returns serializer.MarshalYAMLDeterministic (the line with
serializer.MarshalYAMLDeterministic(v)) to use this pattern so errors are
wrapped consistently.

In `@pkg/recipe/catalog/sign.go`:
- Around line 28-90: Add a unit test that exercises Sign's bundle-write path by
calling Sign with a SignOptions whose Attester returns non-nil bundle bytes and
whose Output is a temp file path; verify writeBundle(path, data) is invoked by
asserting the file exists and its contents equal the attester's bytes.
Specifically, create a mock attester implementing attestation.Attester that
returns []byte(`{"test":"bundle"}`) from its Attest method, call Sign(ctx,
provider, SignOptions{Attester: mockAttester, Output: outPath, ToolVersion:
"v0.0.0-test"}), then os.Stat/out.ReadFile the outPath and compare content to
the expected bytes to cover the Sign -> writeBundle branch.

In `@pkg/recipe/catalog/verify_test.go`:
- Around line 28-52: Add a positive-path unit test that asserts catalog.Verify
returns no error and expected result for a valid bundle: create a new test
(e.g., TestVerify_Success) that uses either a pre-signed bundle fixture written
to a temp file or injects a mock provider/verifier which causes
verifier.VerifyBinaryAttestation to succeed for a known artifact digest; call
catalog.Verify(ctx, bundlePath, newFullProvider() or the mocked provider,
catalog.VerifyOptions{}) and assert err == nil and the returned verification
object matches expected values. Ensure the test references catalog.Verify and
the verifier interface used by newFullProvider (or the mock) so it exercises the
success path without altering existing error-path tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: b9a54c67-b59b-448f-ac12-4cc41a4569a4

📥 Commits

Reviewing files that changed from the base of the PR and between ec95e20 and 53a9913.

📒 Files selected for processing (14)
  • .goreleaser.yaml
  • pkg/bundler/attestation/statement.go
  • pkg/cli/recipe.go
  • pkg/cli/recipe_sign_catalog.go
  • pkg/cli/recipe_test.go
  • pkg/cli/recipe_verify_catalog.go
  • pkg/recipe/catalog/digest.go
  • pkg/recipe/catalog/digest_test.go
  • pkg/recipe/catalog/doc.go
  • pkg/recipe/catalog/sign.go
  • pkg/recipe/catalog/sign_test.go
  • pkg/recipe/catalog/verify.go
  • pkg/recipe/catalog/verify_test.go
  • pkg/recipe/provider.go

Comment thread pkg/recipe/catalog/digest.go Outdated
Comment thread pkg/recipe/catalog/digest.go Outdated
Comment thread pkg/recipe/catalog/sign.go
Comment thread pkg/recipe/catalog/verify_test.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/cli/recipe_verify_catalog.go`:
- Around line 28-55: Add support for standard output formats by adding an
output-format flag to recipeVerifyCatalogCmd and implementing format-aware
output in runRecipeVerifyCatalogCmd: add a cli.StringFlag (e.g.,
"output-format") to recipeVerifyCatalogCmd with accepted values "json", "yaml",
"table" (and an env var if desired), then in runRecipeVerifyCatalogCmd detect
the requested format and emit a structured result object (success boolean,
verified_digest string, bundle_path string, error message when applicable)
serialized as JSON/YAML or rendered as a simple table for the "table" mode; keep
human-readable text only for the default/interactive mode and ensure non-zero
exit on verification failure while returning machine-readable output in the
chosen format.

In `@pkg/recipe/catalog/digest.go`:
- Around line 53-56: The combined digest is ambiguous because it hashes regBytes
|| catBytes without a delimiter; change the construction in digest.go (the
sha256.New() / combined.Write sequence that produces combinedHex) to encode the
pair injectively by prefixing each byte slice with its length (e.g., write a
fixed-size or varint length for regBytes then regBytes, then length for catBytes
then catBytes) before hashing, so the combined.Sum uses an unambiguous
representation; update the variables/usage around combined, regBytes, catBytes
and combinedHex accordingly.

In `@pkg/recipe/catalog/sign.go`:
- Around line 52-69: In Sign, guard against a nil Attester on the SignOptions to
avoid a panic: check opts.Attester == nil near the top of the Sign function
(before calling opts.Attester.Attest) and return a structured invalid-request
error (use the project's error helper similar to errors.PropagateOrWrap with an
appropriate invalid-request code, e.g., errors.ErrCodeInvalidRequest) with a
clear message like "missing attester in SignOptions"; reference Sign,
SignOptions, opts.Attester.Attest, and errors.PropagateOrWrap when making the
change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: a245b628-5018-4c2e-8421-5a4af3586487

📥 Commits

Reviewing files that changed from the base of the PR and between 53a9913 and 4b1dcf0.

📒 Files selected for processing (14)
  • .goreleaser.yaml
  • pkg/bundler/attestation/statement.go
  • pkg/cli/recipe.go
  • pkg/cli/recipe_sign_catalog.go
  • pkg/cli/recipe_test.go
  • pkg/cli/recipe_verify_catalog.go
  • pkg/recipe/catalog/digest.go
  • pkg/recipe/catalog/digest_test.go
  • pkg/recipe/catalog/doc.go
  • pkg/recipe/catalog/sign.go
  • pkg/recipe/catalog/sign_test.go
  • pkg/recipe/catalog/verify.go
  • pkg/recipe/catalog/verify_test.go
  • pkg/recipe/provider.go

Comment thread pkg/cli/recipe_verify_catalog.go
Comment thread pkg/recipe/catalog/digest.go
Comment thread pkg/recipe/catalog/sign.go
Comment thread pkg/recipe/catalog/verify.go

@njhensley njhensley left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-persona review — signed catalog provenance

Verdict: Request changes. One blocking security defect; otherwise a clean, well-structured PR that closes a real V1 supply-chain gap by reusing the existing attestation/verifier primitives. The reuse keeping catalog attestations structurally identical to bundle attestations is the right call.

Detailed, line-anchored findings are in the inline comments below. Index:

# Sev Where Finding
1 🔴 Blocking catalog/verify.go:65-70 Identity pinning is bypassable — --identity-pattern skips ValidateIdentityPattern
2 💡 Suggestion cli/recipe.go:135 Consider a top-level aicr catalog namespace (mirrors evidence) instead of recipe-nested verbs
3 🟠 High catalog/digest.go:40 ComputeDigest clobbers inner error code (timeout/internal → NotFound)
4 🟠 High docs Missing docs/user/cli-reference.md entry for the new user-facing verify-catalog (CLAUDE.md requires it in-PR)
5 🟠 High catalog/sign.go:83, cli/recipe_sign_catalog.go:103 Real-bundle write path + both CLI Actions are 0% covered; error tests assert err != nil not codes
6 🟡 Medium catalog/digest.go:84 Digest hashes re-serialized YAML, not shipped bytes (drops comments, silently truncates multi-doc)
7 🟡 Medium .goreleaser.yaml:48,162 Catalog bundle ships in all 4 archives; "same gate" comment inaccurate; loose asset not in checksums.txt
8 🟡 Medium .goreleaser.yaml:48 Catalog-signing failure aborts the whole release (Fulcio dependency, no retry)

Low / nits (not separately inlined): combined digest has no length delimiter (digest.go:54-55, also inlined); duplicated "recipe-catalog" literal (digest.go/sign.go:59) — extract a const; Sign(ctx, provider, opts) vs Verify(ctx, bundlePath, provider, opts) arg-order asymmetry; ComputeDigest exported with no external caller; redundant os.Stat in Verify (benign); SLSA_PREDICATE reused as a presence sentinel; verify-catalog never asserts the predicate buildType (defense-in-depth — replay already blocked by digest binding).

Confirmed compliant (no action): writeBundle Close()-error handling, the CI-only //nolint:gosec, slog-vs-cmd.Root().Writer split, the RegistryFileName/CatalogFileName export + unexported-alias trick, and {{ .Path }} / darwin-arm clean-skip in the goreleaser hook. Context-timeout omission on the package entry points is acceptable (the provider honors ctx.Err(); the sigstore paths bound their own I/O).

Bottom line: Fix #1 before merge — it nullifies the PR's central guarantee. #3#5 should land in the same PR. #2 is a suggestion worth weighing before release (CLI surface is compatibility-sensitive), but it's the author's call.

Reviewed across four personas (supply-chain security, Go conventions, release engineering, API/test design) and meta-reviewed for accuracy.

Comment thread pkg/recipe/catalog/verify.go
Comment thread pkg/cli/recipe.go
Comment thread pkg/recipe/catalog/digest.go
Comment thread pkg/recipe/catalog/digest.go Outdated
Comment thread pkg/recipe/catalog/digest.go Outdated
Comment thread pkg/recipe/catalog/sign.go
Comment thread pkg/cli/recipe_sign_catalog.go
Comment thread .goreleaser.yaml
Comment thread .goreleaser.yaml
mchmarny added 2 commits June 8, 2026 12:12
- Add TestSign_WritesBundleToFile exercising Sign's bundle-write path
  via a fake Attester that returns canned bytes; lifts package coverage
  from 69.5% → 82.3%.
- Wrap serializer.MarshalYAMLDeterministic errors with PropagateOrWrap
  so structured codes from the serializer survive the boundary, matching
  the pattern applied to provider.ReadFile and Attester.Attest calls.
…tests

Address review findings from #1216:

- BLOCKING: verify.go was passing --identity-pattern straight to
  VerifyBinaryAttestation without calling ValidateIdentityPattern, so
  --identity-pattern '.*' (or env override) silently disabled effective
  repo pinning. Enforce the documented "must contain NVIDIA/aicr"
  invariant up front and return ErrCodeInvalidRequest on violation.
- Hash raw provider.ReadFile bytes directly (drop the
  yaml.Unmarshal → MarshalYAMLDeterministic round-trip) so the digest
  covers the exact shipped bytes — preserves comments and tolerates
  multi-document YAML the round-trip would have silently truncated.
- Combine registry + catalog bytes with an injective length-prefixed
  encoding (u64-BE(len)||bytes) so two file-boundary splits cannot
  collide on the same combined digest.
- Sign now rejects a nil Attester with ErrCodeInvalidRequest instead
  of panicking on opts.Attester.Attest.
- Tests: assert structured error codes (ErrCodeNotFound /
  ErrCodeInvalidRequest) instead of just err != nil; add coverage for
  the --identity-pattern '.*' rejection, the nil-Attester guard, and
  the CLI Action's missing-positional path. Coverage rises from 82.3%
  to 85.2%.
- docs/user/cli-reference.md: add the aicr recipe verify-catalog
  reference entry (CLAUDE.md requires user-facing docs in-PR).
- .goreleaser.yaml: correct the inaccurate "same gate" comment on the
  signing hook; document why the bundle ships in every archive (signed
  once, arch-independent) and why the loose release asset is
  intentional despite living outside checksums.txt.
@mchmarny

mchmarny commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

@njhensley thanks for the multi-persona pass — that's exactly the level of scrutiny this corner of the supply chain needs. Disposition for all 8 findings from your review:

# Sev Finding Disposition
1 🔴 Blocking Identity pinning bypassable Fixed in 6326b4ce. Verify now calls verifier.ValidateIdentityPattern after the empty-string fallback; returns ErrCodeInvalidRequest on violation. New TestVerify_RejectsOverlyBroadIdentityPattern locks the --identity-pattern '.*' rejection contract.
2 💡 Suggestion Top-level aicr catalog namespace Kept under recipe — full reasoning on the inline thread. Filed #1217 to revisit before V1 GA.
3 🟠 High ComputeDigest error code clobber Fixed in 7ddad887 + 6326b4ce. PropagateOrWrap at both call sites; tests now assert structured codes via stderrors.Is.
4 🟠 High Missing cli-reference.md entry Fixed in 6326b4ce. Added ### aicr recipe verify-catalog section between recipe list and query with synopsis, flags, the curl + verify example, and the expected success output.
5 🟠 High Test gaps (real-bundle write + CLI Actions) Mostly fixed: bundle-write covered by TestSign_WritesBundleToFile (61c57d48); error-path tests now assert error codes (6326b4ce); TestSign_RejectsNilAttester + TestRecipeVerifyCatalog_RejectsMissingPositional added. Skipping the sign-catalog defensive-only BundleJSON == nil branch deliberately — full rationale on the inline thread. Coverage 69.5% → 85.2%.
6 🟡 Medium Digest covers re-serialized YAML Fixed in 6326b4ce. Dropped the yaml.Unmarshal → MarshalYAMLDeterministic round-trip entirely — digest now covers raw provider.ReadFile bytes. Comments preserved; multi-doc YAML no longer silently truncated.
7 🟡 Medium Goreleaser comment + archive globbing Fixed in 6326b4ce. Comment now describes the actual two-axis filter (SLSA_PREDICATElinux/amd64); the per-archive recipe-catalog.sigstore.json placement got a dedicated note explaining "signed once, arch-independent, ship everywhere"; loose-asset extra_files placement is now documented as deliberate (authority comes from the Sigstore signature, not the checksums manifest).
8 🟡 Medium Catalog-signing failure aborts release Confirmed acceptable; no behavior change. Same Fulcio dependency surface as the existing binary-attestation hook — failure modes are identical. If Fulcio outage becomes a recurring release pain point, the right fix is workflow-level (retry-with-backoff around the goreleaser invocation), not hook-level. Tracked under epic #1149.

Plus the low / nit items from your tail paragraph:

  • Length delimiter on combined digest — fixed alongside fix: add actions:read permission for artifact download #6 (u64-BE length prefix per file).
  • Duplicate "recipe-catalog" literal — extracted to catalogSubjectName const in digest.go, referenced by both ComputeDigest and Sign.
  • Sign(ctx, provider, opts) vs Verify(ctx, bundlePath, provider, opts) arg-order asymmetry, ComputeDigest exported with no external caller, redundant os.Stat in Verify, SLSA_PREDICATE reused as presence sentinel, verify-catalog doesn't assert predicate buildType — acknowledged, not changed. The asymmetry is unavoidable because verify-catalog's bundle-path is positional (UX) while Sign's output is a flag; ComputeDigest stays exported as a small public API for downstream callers that want to compute the digest without signing (which is also what makes the Verify happy-path testable from outside the package); the os.Stat gives Verify a clean ErrCodeNotFound before the verifier's more cryptic deeper error; SLSA_PREDICATE-as-sentinel is consistent with the existing binary-attest hook; the buildType assertion is genuinely defense-in-depth — once a malicious predicate is digest-bound to a tampered catalog, the digest mismatch fires first.

All 13 inline threads resolved. make qualify passes locally; CI re-run on the amended commit.

cc @coderabbitai — the verify-catalog output-format thread was pushed back on (skipping --format json|yaml|table to stay consistent with the other two verification commands in this repo); rationale on that inline thread.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/cli/recipe_test.go`:
- Around line 849-851: The test should assert the structured invalid-request
error contract instead of only checking message text: replace the brittle
strings.Contains(err.Error(), "usage") check with an errors.Is(err,
ErrInvalidRequest) (or the actual sentinel error name used by the package, e.g.,
ErrInvalidUsage/ErrInvalidRequest) to verify identity, and optionally keep the
message substring check for extra context; ensure you import the "errors"
package and reference the existing err variable in recipe_test.go so the
assertion becomes errors.Is(err, <SentinelError>) (and then t.Errorf if that
fails).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: 4c6b5a35-46de-4c0e-81e4-28785a148641

📥 Commits

Reviewing files that changed from the base of the PR and between 61c57d4 and 6326b4c.

📒 Files selected for processing (9)
  • .goreleaser.yaml
  • docs/user/cli-reference.md
  • pkg/cli/recipe_test.go
  • pkg/recipe/catalog/digest.go
  • pkg/recipe/catalog/digest_test.go
  • pkg/recipe/catalog/sign.go
  • pkg/recipe/catalog/sign_test.go
  • pkg/recipe/catalog/verify.go
  • pkg/recipe/catalog/verify_test.go

Comment thread pkg/cli/recipe_test.go
@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown

@mchmarny — thanks for the thorough disposition table. Working through each item:

Accepted as-fixed: Items 1, 3, 4, 5, 6, 7, and the low/nit items (u64-BE length prefix, catalogSubjectName const extraction). The coverage jump from 69.5% → 85.2% is a meaningful improvement and the structured error-code assertions in the new tests are the right pattern.

Accepted as-confirmed-acceptable: Items 2 and 8. The recipe namespace decision with #1217 as the V1 GA tracking issue is a clean deferral, and the Fulcio-failure-aborts-release reasoning is sound — workflow-level retry is the right abstraction layer for that.

On the --format json|yaml|table push-back (the cc): Agreed — adding a format flag to verify-catalog while sign-catalog and the existing binary verification commands emit plain text would be an inconsistency tax that outweighs the convenience. The current catalog verified sha256:<digest> identity:<san> output is machine-parseable enough for simple grep/awk pipelines. If structured output becomes a broader ask it should land as a cross-cutting flag across all verification subcommands, not a one-off here. No objection to skipping it.

All 13 threads resolved, make qualify green, rationale documented for the acknowledged-but-not-changed items. This looks good to me.

(≡^• ᴥ •^≡) 🐾

✅ Action performed

Comments resolved. Approval is disabled; enable reviews.request_changes_workflow to allow explicit top-level @coderabbitai resolve or @coderabbitai approve commands.

@lockwobr

lockwobr commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Review reconciliation — remaining open items

Reconciled across the three reviews on this PR (CodeRabbit, @njhensley's multi-persona review, and a separate pass of my own) against the current head 6326b4ce. The author's follow-up commits (61c57d4, 6326b4ce) already resolved every blocking and high-severity finding, so this just tracks what is left.

✅ Resolved since the original reviews

  • Identity-pin bypass (was 🔴 blocking): catalog.Verify now calls verifier.ValidateIdentityPattern before honoring --identity-pattern / AICR_CATALOG_IDENTITY_PATTERN, so the NVIDIA/aicr repo pin can no longer be silently disabled.
  • Error-code masking (was 🟠): ComputeDigest switched to errors.PropagateOrWrap, preserving inner ErrCodeTimeout/ErrCodeInternal codes.
  • Digest over re-serialized YAML (was 🟡): now hashes the exact shipped bytes (no YAML normalization), so comments and multi-doc files are covered.
  • Digest ambiguity: length-prefixed (u64-BE(len) ‖ bytes) injective encoding added.
  • Nil-attester panic guard, duplicated "recipe-catalog" literal → const, write-path test (TestSign_WritesBundleToFile), and the missing docs/user/cli-reference.md entry are all in.

Remaining (none blocking)

# Sev Where Item
1 💡 Design pkg/cli/recipe.go CLI surface: recipe sign-catalog / recipe verify-catalog vs a top-level aicr catalog {sign,verify} namespace (mirrors evidence). Worth settling before the first release that ships the command, since the surface is compatibility-sensitive. Author's call.
2 🟡 Release eng .goreleaser.yaml A Fulcio/OIDC hiccup during sign-catalog aborts the entire release (no continue-on-error/retry). This is consistent with the existing binary-attestation hook and fail-closed is defensible for a supply-chain release — please just confirm it is intended rather than incidental.
3 🔵 Enhancement pkg/cli/recipe_verify_catalog.go verify-catalog emits human-readable text only. A --format json|yaml would make the verdict consumable from CI/scripts. Optional.
4 🔵 Test pkg/recipe/catalog/verify_test.go, pkg/cli/recipe_sign_catalog.go No success-path test for catalog.Verify (needs a committed signed-bundle fixture; the network-dependent sigstore path is reasonably left uncovered), and runRecipeSignCatalogCmd is still 0% (OIDC resolution is hard to unit-test). Acceptable, noting for completeness.
5 🔵 Nit pkg/recipe/catalog/{sign,verify}.go Arg-order asymmetry: Sign(ctx, provider, opts) vs Verify(ctx, bundlePath, provider, opts). Cosmetic.

Bottom line: no blockers remain. Item 1 (namespace) and item 2 (confirm release-abort is intended) are the two worth a decision before merge; 3–5 are optional polish. Nice, clean reuse of the existing attestation/verifier primitives.

@mchmarny mchmarny enabled auto-merge (squash) June 8, 2026 19:29
@mchmarny mchmarny disabled auto-merge June 8, 2026 19:31

@njhensley njhensley left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — thanks for the fast, thorough turnaround.

The blocking identity-pin bypass is fixed (Verify now calls verifier.ValidateIdentityPattern and returns ErrCodeInvalidRequest, with TestVerify_RejectsOverlyBroadIdentityPattern locking in that --identity-pattern '.*' is rejected). The error-code clobbering (PropagateOrWrap), the docs entry, and the test gaps (real-bundle write path via fakeAttester, nil-attester guard, NArg guard, code-based assertions) are all addressed.

Nice resolution on the digest: hashing the raw ReadFile bytes with length-prefixed framing (u64-BE(len) || bytes) fixes both the re-serialization concern (what ships is what's signed, multi-doc safe) and the boundary-ambiguity nit in one change.

Remaining items are non-blocking and your calls: keeping the commands under recipe (reasonable either way), skipping the positive Verify path test (consistent with how aicr verify / evidence verify are tested), and the release-abort note (#8), which mirrors the existing binary hook. LGTM.

@mchmarny

mchmarny commented Jun 8, 2026

Copy link
Copy Markdown
Member Author

Thanks @lockwobr — taking each of the remaining items in turn:

1. CLI namespace (recipe sign-catalog vs aicr catalog {sign,verify}) — decision: keep under recipe. recipe list (which landed in #1208 the same week) already established the precedent that recipe-data operations live under recipe, and the catalog is recipe data. The cost of being wrong is one release of compat-shim aliasing (aicr catalog verifyaicr recipe verify-catalog) once we see how operators actually use it; the cost of an early namespace split is harder to walk back. Filed #1217 to revisit before V1 GA if a third catalog verb appears.

2. Release-abort on Fulcio failure — confirmed intentional. Fail-closed is the right posture for a supply-chain release: a release missing its catalog signature is worse than a delayed release. The Fulcio dependency surface is identical to the existing binary-attestation hook above it (same endpoints, same OIDC token, same network path), so this PR doesn't introduce a new failure mode, it just doubles the per-release call count to public-good Sigstore. If Fulcio outage ever becomes a recurring release pain point, the right fix is workflow-level retry-with-backoff wrapping the goreleaser invocation, not hook-level — tracked under the supply-chain hardening epic #1149.

3. --format json|yaml for verify-catalog — skipping for the same reason given on the inline thread: aicr verify and aicr evidence verify (the two parallel verification commands in this repo) both produce fixed human-readable text without a format flag. Adding one only to verify-catalog would diverge from the convention; adding it to all three at once is the right move when a downstream consumer surfaces a concrete need. Filed #1218 to track the cross-cutting --format addition.

4. Success-path tests — agreed, accepting the gap. The sigstore-go verification is exercised end-to-end by the existing pkg/bundler/verifier integration tests, and committing a pre-signed bundle fixture means regenerating it on every Sigstore trust-root rotation — net cost > coverage gain. The runRecipeSignCatalogCmd OIDC-resolution path stays uncovered for the same reason — instrumenting it would require an attester-injection seam purely for the test. The CI-only hidden command is exercised end-to-end by the goreleaser hook on every release, which is the real signal.

5. Arg-order asymmetry (Sign(ctx, provider, opts) vs Verify(ctx, bundlePath, provider, opts)) — acknowledged as cosmetic, leaving as-is. verify-catalog's bundle path is the positional argument users actually pass on the command line; threading it through VerifyOptions would push CLI shape into library shape. The asymmetry mirrors the equivalent in pkg/bundler/verifier (Verify(ctx, bundleDir, opts)).

All five threads were addressed via individual inline replies and resolved earlier. CI re-run on 6326b4ce is green. Ready to merge from my end.

@mchmarny mchmarny merged commit 0bf2267 into main Jun 8, 2026
33 checks passed
@mchmarny mchmarny deleted the feat/recipe-catalog-provenance branch June 8, 2026 19:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: recipe-data provenance (signed catalog/registry)

3 participants