Repository navigation
chore(dispatcher): Add attestation verifier package for Sigstore bundle verification #1670
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
49 changes: 49 additions & 0 deletions
49
cli/dispatcher/cmd/refresh-attestation-trusted-root/main.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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= |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| 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) | ||
| } | ||
1 change: 1 addition & 0 deletions
1
cli/dispatcher/internal/attestation/testdata/happy_asset_digest.txt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| 14c15a7c132f5a3b8eb11b7e3115fff35024793368da79cb90b325a343780a28 |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.ReadAllon bundle response risks OOM.FetchBundlereads 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) }📝 Committable suggestion
🤖 Prompt for AI Agents