Skip to content

chore(dispatcher): Add attestation verifier package for Sigstore bundle verification#1670

Merged
hatayama merged 2 commits into
v3-betafrom
feat/attestation-verifier
Jul 10, 2026
Merged

chore(dispatcher): Add attestation verifier package for Sigstore bundle verification#1670
hatayama merged 2 commits into
v3-betafrom
feat/attestation-verifier

Conversation

@hatayama

@hatayama hatayama commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Summary

  • Introduces cli/dispatcher/internal/attestation/, a self-contained verifier package for GitHub Artifact Attestation bundles. Unused at this commit; runtime behavior unchanged.
  • Phase B2 (update.go) and B3 (dispatcher_download.go) will wire it in — keeping this PR narrow shrinks the review surface before the call sites change.

What lands

  • internal/attestation/verifier.go: Verify(trustedMaterial, VerifyOptions) error, backed by sigstore-go v1.2.2. Enforces (a) local asset digest ∈ bundle subjects, (b) SAN in a closed allowlist (workflow@refs/heads/{v3-beta,main}), and (c) Fulcio OID 1.3.6.1.4.1.57264.1.13 == release tag's commit SHA — binding the attestation to a specific tree so a leaked OIDC token cannot be reused on a different tag.
  • internal/attestation/fetcher.go: FetchBundle (<asset>.sigstore.json HTTP GET) and FetchTagCommitSHA (git-refs REST). Every non-2xx (incl. 403/429) is ErrBundleFetch — never treated as "skip verification" or an attacker can strip it. GITHUB_TOKEN/GH_TOKEN is forwarded when set. FetchTagCommitSHA follows annotated-tag indirection.
  • internal/attestation/trusted_root.json (embedded): Sigstore public-good root, fetched once via the new cmd/refresh-attestation-trusted-root helper (drives sigstore-go's TUF client). Keeps verification offline and deterministic.
  • internal/attestation/trusted_root_test.go CI guard: fails 90 days before any embedded Fulcio / Rekor / CT log / TSA authority expires, so a missed quarterly refresh surfaces as a red build, not a fleet-wide self-update outage after Rekor v2 or a Fulcio CA rotation. Refresh commits must be fix(dispatcher): so release-please cuts a dispatcher release that actually ships the refreshed root.
  • cmd/refresh-attestation-trusted-root/main.go: one-shot Go program that fetches the current trusted_root.json via TUF and writes it under internal/attestation/. Only compiled during go build ./... — never bundled into the released dispatcher binary because scripts/build-go-cli.sh builds ./cmd/dispatcher exclusively.
  • Architecture guard allowlist gets attestation alongside dispatcher, install, nativepath, uninstall, update.

Test plan

  • scripts/check-go-cli.sh — fmt/vet/lint/build/test all green
  • go test ./internal/attestation/... — 12 tests: happy / digest-mismatch / SAN-mismatch / commit-SHA-mismatch / malformed-bundle / 404 / 403 / 429 / empty-body / lightweight-tag / annotated-tag / server-error
  • Happy fixture uses the real backfilled dispatcher-v3.0.1-beta.12 arm64 bundle, so tests exercise the same bundle shape production callers will hand the verifier

Review in cubic

Introduces cli/dispatcher/internal/attestation/, a self-contained package
that verifies GitHub Artifact Attestation bundles emitted by the release
workflows. The verifier is unused at this commit — Phase B2 and B3 will
wire it into dispatcher self-update (update.go) and project-runner
download (dispatcher_download.go) respectively — so the dispatcher binary
still behaves exactly as before. Landing the package alone keeps the
review surface small before the runtime call sites change.

Trust anchor is the embedded Sigstore public-good trusted_root.json,
fetched once via the new cli/dispatcher/cmd/refresh-attestation-trusted-root
helper (which drives sigstore-go's TUF client). Embedding keeps
verification offline and deterministic; a companion Go test in
trusted_root_test.go fails the build 90 days before any embedded
authority expires so a missed quarterly refresh surfaces as a red CI
rather than a fleet-wide self-update outage after Rekor or Fulcio
rotates. Refresh commits must land as fix(dispatcher) so release-please
cuts a new dispatcher release that actually ships the refreshed root.

Verification policy binds each attestation to (a) the local asset's
SHA-256 digest appearing among the bundle's subjects, (b) a SAN allowlist
of exact workflow@ref identities for hatayama/unity-cli-loop, and (c)
the Fulcio Source Repository Digest extension (OID 1.3.6.1.4.1.57264.1.13)
matching the release tag's commit SHA. The commit SHA binding prevents
a leaked OIDC token from being reused on a different tag. The SAN is a
closed allowlist (v3-beta and main branches for either
dispatcher-publish.yml or native-cli-publish.yml at the call site) rather
than a regex so an unrelated ref cannot forge releases.

Fetcher helpers fail closed on every non-2xx status (including 403 and
429) so an attacker cannot strip verification by DoS'ing the attestation
endpoint. GITHUB_TOKEN/GH_TOKEN authorization is passed through when
present to lift rate limits in CI.

Tests cover the five required patterns (happy path, digest mismatch,
identity mismatch by SAN, identity mismatch by commit SHA, malformed
bundle) plus fetcher fail-closed behavior on 404/403/429/empty-body and
tag lookup for both lightweight and annotated tag objects. The happy
path fixture is the real backfilled dispatcher-v3.0.1-beta.12 arm64
bundle so tests exercise a bundle with the same media type production
callers will hand the verifier.

The release-automation architecture guard now allows internal/attestation
alongside the existing dispatcher, install, nativepath, uninstall, update
packages.
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fc4cecd2-c10b-4674-86f9-f287ba200276

📥 Commits

Reviewing files that changed from the base of the PR and between a2964f7 and 71398fa.

📒 Files selected for processing (3)
  • cli/dispatcher/internal/attestation/fetcher.go
  • cli/dispatcher/internal/attestation/trusted_root_test.go
  • cli/dispatcher/internal/attestation/verifier.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • cli/dispatcher/internal/attestation/trusted_root_test.go
  • cli/dispatcher/internal/attestation/verifier.go
  • cli/dispatcher/internal/attestation/fetcher.go

📝 Walkthrough

Walkthrough

Changes

Attestation verification

Layer / File(s) Summary
Trusted root embedding and refresh
cli/dispatcher/internal/attestation/trusted_root.*, cli/dispatcher/cmd/refresh-attestation-trusted-root/main.go
Embeds Sigstore trust anchors, validates authority validity coverage, and adds a CLI that refreshes and writes trusted_root.json.
Release bundle and tag fetching
cli/dispatcher/internal/attestation/errors.go, cli/dispatcher/internal/attestation/fetcher.go, cli/release-automation/internal/architecture/architecture_test.go
Adds classified errors and HTTP helpers for downloading bundles, resolving GitHub tags, applying optional authorization, and extracting asset names.
Bundle identity and digest verification
cli/dispatcher/internal/attestation/verifier.go
Adds Fulcio identity constraints, commit and digest validation, trusted-material loading, and fail-closed Sigstore verification.
Verification and fetcher validation
cli/dispatcher/internal/attestation/verifier_test.go, cli/dispatcher/internal/attestation/testdata/*
Adds fixture-backed tests for successful verification, mismatch rejection, malformed bundles, HTTP failures, and lightweight or annotated tag resolution.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant Verify
  participant TrustedMaterial
  participant SigstoreVerifier
  Caller->>Verify: provide bundle, digest, commit SHA, and identity
  Verify->>TrustedMaterial: use trusted root material
  Verify->>SigstoreVerifier: verify signature, log, timestamp, certificate, and digest
  SigstoreVerifier-->>Verify: verification result
  Verify-->>Caller: success or classified error
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 76.19% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title concisely and accurately summarizes the main change: adding a Sigstore attestation verifier package for dispatcher.
Description check ✅ Passed The description is clearly related to the attestation verifier package, trusted root refresh, and related tests.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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/attestation-verifier

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
cli/dispatcher/internal/attestation/verifier_test.go (1)

230-302: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add tests for FetchTagCommitSHA error paths and fetchGitRef decode failure.

The code handles three error paths that lack test coverage:

  1. Unexpected object type (verifier.go line 98–100): ref returns type "tree" or "blob".
  2. Bad SHA format (verifier.go line 101–103): ref returns a non-hex or wrong-length SHA.
  3. JSON decode failure (fetcher.go line 126–128): 200 response with invalid JSON body.

These are fail-closed security paths — a regression here could silently accept invalid tag resolutions.

🧪 Suggested test additions
// Verifies FetchTagCommitSHA fails closed on unexpected object type.
func TestFetchTagCommitSHA_UnexpectedObjectType(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		fmt.Fprintln(w, `{"object":{"sha":"1eb1ebb9841b1bcb8fc7dec3fa282568a1c31a4f","type":"tree"}}`)
	}))
	defer server.Close()

	original := githubAPIBase()
	setGithubAPIBase(server.URL)
	defer setGithubAPIBase(original)

	_, err := FetchTagCommitSHA(context.Background(), "hatayama/unity-cli-loop", "any")
	if err == nil {
		t.Fatalf("expected unexpected object type to fail")
	}
	if !errors.Is(err, ErrTagRefFetch) {
		t.Fatalf("expected ErrTagRefFetch, got: %v", err)
	}
}

// Verifies FetchTagCommitSHA fails closed on a non-hex SHA.
func TestFetchTagCommitSHA_BadSHA(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		fmt.Fprintln(w, `{"object":{"sha":"not-a-hex-sha-but-40-chars-long-xxxxxxxxx","type":"commit"}}`)
	}))
	defer server.Close()

	original := githubAPIBase()
	setGithubAPIBase(server.URL)
	defer setGithubAPIBase(original)

	_, err := FetchTagCommitSHA(context.Background(), "hatayama/unity-cli-loop", "any")
	if err == nil {
		t.Fatalf("expected bad SHA to fail")
	}
	if !errors.Is(err, ErrTagRefFetch) {
		t.Fatalf("expected ErrTagRefFetch, got: %v", err)
	}
}

// Verifies fetchGitRef fails closed on invalid JSON.
func TestFetchTagCommitSHA_InvalidJSON(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		fmt.Fprintln(w, `this is not json`)
	}))
	defer server.Close()

	original := githubAPIBase()
	setGithubAPIBase(server.URL)
	defer setGithubAPIBase(original)

	_, err := FetchTagCommitSHA(context.Background(), "hatayama/unity-cli-loop", "any")
	if err == nil {
		t.Fatalf("expected invalid JSON to fail")
	}
	if !errors.Is(err, ErrTagRefFetch) {
		t.Fatalf("expected ErrTagRefFetch, got: %v", err)
	}
}
🤖 Prompt for 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.

In `@cli/dispatcher/internal/attestation/verifier_test.go` around lines 230 - 302,
Add coverage for the untested fail-closed paths in FetchTagCommitSHA: add
separate HTTP-server tests for an unexpected object type, an invalid-length or
non-hex SHA, and invalid JSON returned by fetchGitRef. For each test, configure
githubAPIBase to the test server, invoke FetchTagCommitSHA, assert an error is
returned, and verify errors.Is(err, ErrTagRefFetch); restore the original API
base and close the server as existing tests do.
🤖 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 `@cli/dispatcher/internal/attestation/fetcher.go`:
- Around line 61-64: Limit the bundle response size before reading it in
FetchBundle: wrap resp.Body with io.LimitReader using a 10 MB cap (and account
for an over-limit response), then pass the limited reader to io.ReadAll and
return an appropriate ErrBundleFetch error when the limit is exceeded.

In `@cli/dispatcher/internal/attestation/trusted_root_test.go`:
- Around line 107-125: Update isValidBeyond to parse and require validFor.start
to be at or before the evaluation time, while preserving the existing end-date
and unlimited-end handling. Adjust its call site to provide the current time
separately from the horizon (or derive it as
horizon.Add(-trustedRootWarningHorizon)), and ensure both timestamp precisions
are supported consistently.

---

Nitpick comments:
In `@cli/dispatcher/internal/attestation/verifier_test.go`:
- Around line 230-302: Add coverage for the untested fail-closed paths in
FetchTagCommitSHA: add separate HTTP-server tests for an unexpected object type,
an invalid-length or non-hex SHA, and invalid JSON returned by fetchGitRef. For
each test, configure githubAPIBase to the test server, invoke FetchTagCommitSHA,
assert an error is returned, and verify errors.Is(err, ErrTagRefFetch); restore
the original API base and close the server as existing tests do.
🪄 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: CHILL

Plan: Pro

Run ID: 097c0ecb-2906-49c5-887b-fbfd3ac48482

📥 Commits

Reviewing files that changed from the base of the PR and between c42158c and a2964f7.

⛔ Files ignored due to path filters (5)
  • cli/dispatcher/go.mod is excluded by none and included by none
  • cli/dispatcher/go.sum is excluded by !**/*.sum and included by none
  • cli/dispatcher/internal/attestation/testdata/happy_asset_digest.txt is excluded by none and included by none
  • cli/dispatcher/internal/attestation/testdata/happy_commit_sha.txt is excluded by none and included by none
  • cli/go.work.sum is excluded by !**/*.sum and included by none
📒 Files selected for processing (10)
  • cli/dispatcher/cmd/refresh-attestation-trusted-root/main.go
  • cli/dispatcher/internal/attestation/errors.go
  • cli/dispatcher/internal/attestation/fetcher.go
  • cli/dispatcher/internal/attestation/testdata/happy_bundle.json
  • cli/dispatcher/internal/attestation/trusted_root.go
  • cli/dispatcher/internal/attestation/trusted_root.json
  • cli/dispatcher/internal/attestation/trusted_root_test.go
  • cli/dispatcher/internal/attestation/verifier.go
  • cli/dispatcher/internal/attestation/verifier_test.go
  • cli/release-automation/internal/architecture/architecture_test.go

Comment on lines +61 to +64
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("%w: read body: %v", ErrBundleFetch, err)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Unbounded io.ReadAll on bundle response risks OOM.

FetchBundle reads the entire response body without a size cap. A compromised CDN, buggy server, or MITM could send a multi-GB response within the 30s client timeout, exhausting memory in the dispatcher process. Sigstore bundles are typically a few KB; a generous limit (e.g., 10 MB) would protect this security-sensitive path without rejecting valid bundles.

🛡️ Proposed fix: wrap body in `io.LimitReader`
 body, err := io.ReadAll(resp.Body)
 if err != nil {
   return nil, fmt.Errorf("%w: read body: %v", ErrBundleFetch, err)
 }
+ const maxBundleBytes = 10 << 20 // 10 MB — well above any valid Sigstore bundle
+
 body, err := io.ReadAll(io.LimitReader(resp.Body, maxBundleBytes+1))
 if err != nil {
   return nil, fmt.Errorf("%w: read body: %v", ErrBundleFetch, err)
 }
 if len(body) > maxBundleBytes {
   return nil, fmt.Errorf("%w: bundle exceeds %d bytes from %s", ErrBundleFetch, maxBundleBytes, bundleURL)
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("%w: read body: %v", ErrBundleFetch, err)
}
const maxBundleBytes = 10 << 20 // 10 MB — well above any valid Sigstore bundle
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBundleBytes+1))
if err != nil {
return nil, fmt.Errorf("%w: read body: %v", ErrBundleFetch, err)
}
if len(body) > maxBundleBytes {
return nil, fmt.Errorf("%w: bundle exceeds %d bytes from %s", ErrBundleFetch, maxBundleBytes, bundleURL)
}
🤖 Prompt for 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.

In `@cli/dispatcher/internal/attestation/fetcher.go` around lines 61 - 64, Limit
the bundle response size before reading it in FetchBundle: wrap resp.Body with
io.LimitReader using a 10 MB cap (and account for an over-limit response), then
pass the limited reader to io.ReadAll and return an appropriate ErrBundleFetch
error when the limit is exceeded.

Comment on lines +107 to +125
// isValidBeyond returns true when the entry is either unlimited (no end date)
// or its end date is on/after the given horizon. Entries whose start hasn't
// arrived yet are not considered "future-capable" — they cannot verify
// present-day bundles.
func isValidBeyond(t *testing.T, v validFor, horizon time.Time) bool {
t.Helper()
if v.End == "" {
return true
}
end, err := time.Parse(time.RFC3339, v.End)
if err != nil {
// Some sigstore roots use millisecond precision. Try that too.
end, err = time.Parse("2006-01-02T15:04:05.999Z07:00", v.End)
if err != nil {
t.Fatalf("cannot parse validFor.end %q: %v", v.End, err)
}
}
return !end.Before(horizon)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

isValidBeyond doesn't check the start date despite documenting it should.

The comment states "Entries whose start hasn't arrived yet are not considered 'future-capable' — they cannot verify present-day bundles," but the implementation only checks v.End. During a Sigstore root rotation where the old authority has expired and the replacement has a future start with no end, this test would incorrectly pass, allowing a root that can't verify present-day bundles to ship.

🔧 Proposed fix: add start-date check to isValidBeyond
 func isValidBeyond(t *testing.T, v validFor, horizon time.Time) bool {
-  t.Helper()
-  if v.End == "" {
-    return true
-  }
-  end, err := time.Parse(time.RFC3339, v.End)
-  if err != nil {
-    // Some sigstore roots use millisecond precision. Try that too.
-    end, err = time.Parse("2006-01-02T15:04:05.999Z07:00", v.End)
-    if err != nil {
-      t.Fatalf("cannot parse validFor.end %q: %v", v.End, err)
-    }
-  }
-  return !end.Before(horizon)
+  t.Helper()
+  if v.Start != "" {
+    start, err := time.Parse(time.RFC3339, v.Start)
+    if err != nil {
+      start, err = time.Parse("2006-01-02T15:04:05.999Z07:00", v.Start)
+      if err != nil {
+        t.Fatalf("cannot parse validFor.start %q: %v", v.Start, err)
+      }
+    }
+    if start.After(horizon.Add(-trustedRootWarningHorizon)) {
+      return false
+    }
+  }
+  if v.End == "" {
+    return true
+  }
+  end, err := time.Parse(time.RFC3339, v.End)
+  if err != nil {
+    // Some sigstore roots use millisecond precision. Try that too.
+    end, err = time.Parse("2006-01-02T15:04:05.999Z07:00", v.End)
+    if err != nil {
+      t.Fatalf("cannot parse validFor.end %q: %v", v.End, err)
+    }
+  }
+  return !end.Before(horizon)
 }

And update the call site at line 77 to pass now (recoverable as horizon.Add(-trustedRootWarningHorizon) or by adding a now parameter):

-    if isValidBeyond(t, entry, horizon) {
+    if isValidBeyond(t, entry, now, horizon) {

With the updated signature:

-func isValidBeyond(t *testing.T, v validFor, horizon time.Time) bool {
+func isValidBeyond(t *testing.T, v validFor, now, horizon time.Time) bool {
🤖 Prompt for 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.

In `@cli/dispatcher/internal/attestation/trusted_root_test.go` around lines 107 -
125, Update isValidBeyond to parse and require validFor.start to be at or before
the evaluation time, while preserving the existing end-date and unlimited-end
handling. Adjust its call site to provide the current time separately from the
horizon (or derive it as horizon.Add(-trustedRootWarningHorizon)), and ensure
both timestamp precisions are supported consistently.

…rization

Address Fable 5 review on PR #1670:

- verifier: add verify.WithSignedCertificateTimestamps(1) so a Fulcio-CA-issued
  cert that skipped CT logs is rejected instead of accepted. TransparencyLog +
  IntegratedTimestamps alone did not cover this failure mode.
- fetcher: stop attaching Authorization to release download URLs. GitHub's
  release download host is not API-rate-limited so the token buys nothing, and
  the http.Client strips it across the cross-host redirect to
  objects.githubusercontent.com anyway. Restrict the header to github.com/ghapi
  calls (fetchGitRef) for least privilege.
- trusted_root_test: align the isValidBeyond comment with the actual gate. The
  guard only inspects End; the Sigstore public-good root has never shipped an
  entry with a future Start, so document that scope instead of promising a
  Start check the code does not perform.
@hatayama
hatayama merged commit 7088d4a into v3-beta Jul 10, 2026
9 checks passed
@hatayama
hatayama deleted the feat/attestation-verifier branch July 10, 2026 03:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant