Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions cli/dispatcher/cmd/refresh-attestation-trusted-root/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Refreshes the Sigstore trusted_root.json embedded by cli/dispatcher/internal/attestation.
//
// Run this from the dispatcher module root to fetch the current Sigstore
// public-good trusted_root.json via TUF and write it as
// internal/attestation/trusted_root.json. The dispatcher then embeds that
// file so verification stays offline at runtime. Refresh cadence is
// quarterly; the attestation package has a CI guard test that fails when
// any embedded authority's validity window is within 90 days of expiring,
// so a missed refresh surfaces as a red build rather than a fleet outage.
//
// Runs from module root:
//
// go run ./cmd/refresh-attestation-trusted-root
package main

import (
"fmt"
"os"
"path/filepath"

"github.com/sigstore/sigstore-go/pkg/tuf"
)

func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "refresh-attestation-trusted-root:", err)
os.Exit(1)
}
}

func run() error {
client, err := tuf.DefaultClient()
if err != nil {
return fmt.Errorf("create TUF client: %w", err)
}
if err := client.Refresh(); err != nil {
return fmt.Errorf("refresh TUF metadata: %w", err)
}
target, err := client.GetTarget("trusted_root.json")
if err != nil {
return fmt.Errorf("get trusted_root.json target: %w", err)
}
outputPath := filepath.Join("internal", "attestation", "trusted_root.json")
if err := os.WriteFile(outputPath, target, 0o644); err != nil {
return fmt.Errorf("write %s: %w", outputPath, err)
}
fmt.Printf("wrote %d bytes to %s\n", len(target), outputPath)
return nil
}
19 changes: 17 additions & 2 deletions cli/dispatcher/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,26 @@ module github.com/hatayama/unity-cli-loop/dispatcher

go 1.26

require github.com/hatayama/unity-cli-loop/common v0.0.0-00010101000000-000000000000
require (
github.com/hatayama/unity-cli-loop/common v0.0.0-00010101000000-000000000000
github.com/sigstore/sigstore-go v1.2.2
)

require (
github.com/Microsoft/go-winio v0.6.2 // indirect
golang.org/x/sys v0.10.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/google/go-containerregistry v0.21.7 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect
github.com/sigstore/protobuf-specs v0.5.1 // indirect
github.com/sigstore/sigstore v1.10.8 // indirect
github.com/theupdateframework/go-tuf/v2 v2.4.2 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/term v0.44.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

replace github.com/hatayama/unity-cli-loop/common => ../common
44 changes: 42 additions & 2 deletions cli/dispatcher/go.sum
Original file line number Diff line number Diff line change
@@ -1,4 +1,44 @@
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-containerregistry v0.21.7 h1:/vPFuVXDjtFREsVArW+0h1CIl5urnOhzei4X2DMW9IU=
github.com/google/go-containerregistry v0.21.7/go.mod h1:kjSbt7/zMsKLWfnHrIvKvhXHUw91jbe9DNjPPJ32gXE=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs=
github.com/secure-systems-lab/go-securesystemslib v0.11.0/go.mod h1:+PMOTjUGwHj2vcZ+TFKlb1tXRbrdWE1LYDT5i9JC80Q=
github.com/sigstore/protobuf-specs v0.5.1 h1:/5OPaNuolRJmQfeZLayJGFXMpsRJEdgC6ah1/+7Px7U=
github.com/sigstore/protobuf-specs v0.5.1/go.mod h1:DRBzpFuE+LnvQMN10/dU6nBeKwVLGEQ6o2FovN2Rats=
github.com/sigstore/sigstore v1.10.8 h1:1Mgkxvkw4AXMfIP1DOjc6kw0GkUgA8pGVpveN/EfOq4=
github.com/sigstore/sigstore v1.10.8/go.mod h1:f9+B/4iaYimvUkySyb2mvc73n3RLqNn24grHZM/ET8M=
github.com/sigstore/sigstore-go v1.2.2 h1:xAJ8hxaoecC0HKBYVbrwUjkeAI+GJYu6vLqbxDlD2Q0=
github.com/sigstore/sigstore-go v1.2.2/go.mod h1:MIFwBxAHJD+/lKgZzt9n/4Zhq/3T2+EuGX8iGrIsZgU=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/theupdateframework/go-tuf/v2 v2.4.2 h1:w7976/W8uTwlsegP5nRymlpjPgrwSh+AXUf85is6nJk=
github.com/theupdateframework/go-tuf/v2 v2.4.2/go.mod h1:JqBrIUnNLAaNq/8GmBcEMFWfAFBbqp/MkJEJseXKbks=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
25 changes: 25 additions & 0 deletions cli/dispatcher/internal/attestation/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package attestation

import "errors"

// Sentinel errors let callers distinguish network trouble from a real signature
// or identity mismatch so update.go and dispatcher_download.go can render
// distinct messages ("update to a newer CLI" vs "release may be compromised").
var (
// ErrBundleFetch reports any failure retrieving <asset>.sigstore.json
// (network error, 4xx/5xx, empty body). Fail-closed at the call site.
ErrBundleFetch = errors.New("attestation bundle fetch failed")

// ErrTagRefFetch reports failure resolving a release tag to its commit
// SHA via the GitHub git-refs API. Fail-closed at the call site.
ErrTagRefFetch = errors.New("release tag commit SHA lookup failed")

// ErrMalformedBundle reports a bundle that could not be parsed as a
// Sigstore protobuf JSON.
ErrMalformedBundle = errors.New("attestation bundle is malformed")

// ErrVerificationFailed reports Sigstore signature or policy failure —
// digest mismatch, identity mismatch, tlog absent, timestamp missing.
// This is the signal that a release asset may have been tampered with.
ErrVerificationFailed = errors.New("attestation verification failed")
)
156 changes: 156 additions & 0 deletions cli/dispatcher/internal/attestation/fetcher.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package attestation

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"time"
)

// DefaultHTTPClient is the http.Client used for bundle and tag-ref fetches.
// It is a var so tests can substitute a client wired to httptest servers.
var DefaultHTTPClient = &http.Client{Timeout: 30 * time.Second}

// githubAPIBaseURL is mutable so tests can point it at an httptest server;
// production callers never touch it directly.
var githubAPIBaseURL = "https://api.github.com"

func githubAPIBase() string { return githubAPIBaseURL }
func setGithubAPIBase(url string) { githubAPIBaseURL = url }

const (
githubReleaseBaseURL = "https://github.com/%s/releases/download/%s/%s"
envAuthTokenPrimary = "GITHUB_TOKEN"
envAuthTokenSecondary = "GH_TOKEN"
acceptHeaderGitHubJSON = "application/vnd.github+json"
apiVersionHeaderValue = "2022-11-28"
)

// BundleAssetURL builds the release download URL for <asset>.sigstore.json.
// Callers pass the base asset name (without the .sigstore.json suffix).
func BundleAssetURL(repo, tag, assetName string) string {
return fmt.Sprintf(githubReleaseBaseURL, repo, tag, url.PathEscape(assetName+".sigstore.json"))
}

// FetchBundle downloads the <asset>.sigstore.json body from the release. It
// wraps every failure in ErrBundleFetch so the caller fails closed. 403/429
// are treated the same way as any other non-2xx: verification is not attempted
// because we cannot distinguish rate-limit denial from an attacker denying the
// bundle to skip verification.
//
// No Authorization header is set here: release download URLs
// (github.com/.../releases/download) are not subject to the API rate limits
// that GITHUB_TOKEN would relax, and Go's http.Client strips Authorization
// across the cross-host redirect to objects.githubusercontent.com anyway, so
// leaking the token would be pointless. Least-privilege: keep the token on
// api.github.com calls only (see fetchGitRef).
func FetchBundle(ctx context.Context, bundleURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, bundleURL, nil)
if err != nil {
return nil, fmt.Errorf("%w: build request: %v", ErrBundleFetch, err)
}
resp, err := DefaultHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrBundleFetch, err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%w: status %s from %s", ErrBundleFetch, resp.Status, bundleURL)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("%w: read body: %v", ErrBundleFetch, err)
}
Comment on lines +67 to +70

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.

if len(body) == 0 {
return nil, fmt.Errorf("%w: empty body from %s", ErrBundleFetch, bundleURL)
}
return body, nil
}

// TagRefResponse mirrors the git ref API payload shape for the fields we
// consume.
type TagRefResponse struct {
Object struct {
SHA string `json:"sha"`
Type string `json:"type"`
} `json:"object"`
}

// FetchTagCommitSHA resolves a release tag to the git commit SHA it points at
// via the GitHub git-refs REST API. The verifier binds this SHA to the cert's
// Source Repository Digest extension so a stolen OIDC token cannot be reused
// on a tag it did not produce. We follow one level of "tag" indirection so
// annotated tags resolve to the same commit SHA as lightweight ones.
func FetchTagCommitSHA(ctx context.Context, repo, tag string) (string, error) {
initialURL := fmt.Sprintf("%s/repos/%s/git/ref/tags/%s", githubAPIBase(), repo, url.PathEscape(tag))
sha, objectType, err := fetchGitRef(ctx, initialURL)
if err != nil {
return "", err
}
if objectType == "tag" {
tagURL := fmt.Sprintf("%s/repos/%s/git/tags/%s", githubAPIBase(), repo, url.PathEscape(sha))
sha, objectType, err = fetchGitRef(ctx, tagURL)
if err != nil {
return "", err
}
}
if objectType != "commit" {
return "", fmt.Errorf("%w: unexpected object type %q for tag %s", ErrTagRefFetch, objectType, tag)
}
if !isHexCommitSHA(sha) {
return "", fmt.Errorf("%w: bad commit SHA %q for tag %s", ErrTagRefFetch, sha, tag)
}
return sha, nil
}

func fetchGitRef(ctx context.Context, apiURL string) (string, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
if err != nil {
return "", "", fmt.Errorf("%w: build request: %v", ErrTagRefFetch, err)
}
req.Header.Set("Accept", acceptHeaderGitHubJSON)
req.Header.Set("X-GitHub-Api-Version", apiVersionHeaderValue)
setAuthorizationIfAvailable(req)
resp, err := DefaultHTTPClient.Do(req)
if err != nil {
return "", "", fmt.Errorf("%w: %v", ErrTagRefFetch, err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", "", fmt.Errorf("%w: status %s from %s", ErrTagRefFetch, resp.Status, apiURL)
}
var payload TagRefResponse
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return "", "", fmt.Errorf("%w: decode payload: %v", ErrTagRefFetch, err)
}
return payload.Object.SHA, payload.Object.Type, nil
}

func setAuthorizationIfAvailable(req *http.Request) {
token := os.Getenv(envAuthTokenPrimary)
if token == "" {
token = os.Getenv(envAuthTokenSecondary)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
}

// AssetNameFromReleaseAsset strips any query fragments and returns the base
// asset name, so callers can pass a full URL and receive the file name.
func AssetNameFromReleaseAsset(assetURL string) string {
parsed, err := url.Parse(assetURL)
if err != nil {
return path.Base(assetURL)
}
return path.Base(parsed.Path)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
14c15a7c132f5a3b8eb11b7e3115fff35024793368da79cb90b325a343780a28
Loading
Loading