-
Notifications
You must be signed in to change notification settings - Fork 0
fix(cli): verify SLSA provenance via GitHub attestation API #548
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
Show all changes
3 commits
Select commit
Hold shift + click to select a range
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
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 |
|---|---|---|
|
|
@@ -2,15 +2,14 @@ package verify | |
|
|
||
| import ( | ||
| "context" | ||
| "encoding/base64" | ||
| "encoding/json" | ||
| "errors" | ||
| "fmt" | ||
| "io" | ||
| "net/http" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/google/go-containerregistry/pkg/name" | ||
| v1 "github.com/google/go-containerregistry/pkg/v1" | ||
| "github.com/google/go-containerregistry/pkg/v1/remote" | ||
| "github.com/sigstore/sigstore-go/pkg/verify" | ||
| ) | ||
|
|
||
|
|
@@ -23,184 +22,138 @@ const ( | |
| // Exported so selfupdate can reuse the same constant. | ||
| DSSEPayloadType = "application/vnd.in-toto+json" | ||
|
|
||
| // slsaReferrerArtifactType is the OCI artifactType for SLSA provenance referrers. | ||
| // actions/attest-build-provenance sets this to the same value as the DSSE payload | ||
| // type; the match is intentional but these are semantically distinct OCI fields | ||
| // that could diverge in the future. | ||
| slsaReferrerArtifactType = "application/vnd.in-toto+json" | ||
| // defaultGitHubAPIBase is the base URL for the GitHub REST API. | ||
| defaultGitHubAPIBase = "https://api.github.com" | ||
|
|
||
| // githubAttestationOwnerRepo is the GitHub owner/repo for attestation | ||
| // lookups. This is the canonical GitHub repository path (case-sensitive | ||
| // for the API), not derived from the image registry prefix. | ||
| githubAttestationOwnerRepo = "Aureliolo/synthorg" | ||
|
|
||
| // attestationHTTPTimeout bounds individual HTTP requests to the GitHub API. | ||
| attestationHTTPTimeout = 30 * time.Second | ||
|
|
||
| // maxAttestationResponseBytes caps the GitHub attestation API response | ||
| // to prevent memory exhaustion from malicious or oversized responses. | ||
| maxAttestationResponseBytes = 5 << 20 // 5MB | ||
| ) | ||
|
|
||
| // ErrNoProvenanceAttestations indicates that no SLSA provenance attestations | ||
| // were found for an image. This is distinct from a cryptographic verification | ||
| // failure — it means the image was published before provenance was configured. | ||
| // were found for an image via the GitHub attestation API. This is distinct | ||
| // from a cryptographic verification failure. | ||
| var ErrNoProvenanceAttestations = errors.New("no SLSA provenance attestations found") | ||
|
|
||
| // VerifyProvenance fetches SLSA provenance attestations from the OCI registry | ||
| // (pushed as referrers by actions/attest-build-provenance) and verifies the | ||
| // DSSE envelope against the Sigstore transparency log and expected identity. | ||
| // githubAPIBase is the effective base URL for the GitHub REST API. | ||
| // Defaults to the production URL; tests override via setGitHubAPIBase. | ||
| var githubAPIBase = defaultGitHubAPIBase //nolint:gochecknoglobals // test override | ||
|
|
||
| // attestationHTTPClient is a dedicated client for GitHub attestation API | ||
| // requests, isolated from http.DefaultClient to avoid side effects from | ||
| // other packages modifying global state. | ||
| var attestationHTTPClient = &http.Client{} //nolint:gochecknoglobals // package-scoped client | ||
|
|
||
| // setGitHubAPIBase overrides the GitHub API base URL (for tests only). | ||
| func setGitHubAPIBase(base string) { githubAPIBase = base } | ||
|
|
||
| // VerifyProvenance fetches SLSA provenance attestations from the GitHub | ||
| // attestation API and verifies the Sigstore bundle against the public | ||
| // transparency log and expected identity. | ||
| // | ||
| // The image ref must have a resolved Digest. | ||
| func VerifyProvenance(ctx context.Context, ref ImageRef, sev *verify.Verifier, certID verify.CertificateIdentity) error { | ||
| if ref.Digest == "" { | ||
| return fmt.Errorf("image digest not resolved") | ||
| } | ||
|
|
||
| attestationDescs, err := findAttestations(ctx, ref) | ||
| bundles, err := fetchGitHubAttestations(ctx, ref.Digest) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| // Try each attestation — verify the first one that passes. | ||
| // Try each attestation bundle -- first successful verification wins. | ||
| var errs []error | ||
| for i, desc := range attestationDescs { | ||
| if err := verifyAttestation(ctx, ref, desc, sev, certID); err != nil { | ||
| for i, bundleJSON := range bundles { | ||
| if err := verifyProvenanceBundle(bundleJSON, ref.Digest, sev, certID); err != nil { | ||
| errs = append(errs, fmt.Errorf("attestation[%d]: %w", i, err)) | ||
| continue | ||
| } | ||
| return nil // verified successfully | ||
| return nil | ||
| } | ||
| return fmt.Errorf("no valid SLSA provenance attestation for %s: %w", ref, errors.Join(errs...)) | ||
| } | ||
|
|
||
| // findAttestations queries OCI referrers and returns descriptors for in-toto | ||
| // attestation artifacts associated with the given image. | ||
| func findAttestations(ctx context.Context, ref ImageRef) ([]v1.Descriptor, error) { | ||
| digestRef := fmt.Sprintf("%s/%s@%s", ref.Registry, ref.Repository, ref.Digest) | ||
| parsed, err := name.NewDigest(digestRef) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("parsing digest reference %q: %w", digestRef, err) | ||
| } | ||
|
|
||
| referrerIdx, err := remote.Referrers(parsed, remote.WithContext(ctx)) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("querying referrers for %s: %w", ref, err) | ||
| } | ||
|
|
||
| manifest, err := referrerIdx.IndexManifest() | ||
| if err != nil { | ||
| return nil, fmt.Errorf("reading referrer index manifest: %w", err) | ||
| } | ||
|
|
||
| var descs []v1.Descriptor | ||
| for _, desc := range manifest.Manifests { | ||
| if desc.ArtifactType == slsaReferrerArtifactType { | ||
| descs = append(descs, desc) | ||
| } | ||
| } | ||
| if len(descs) == 0 { | ||
| return nil, fmt.Errorf("%w for %s", ErrNoProvenanceAttestations, ref) | ||
| } | ||
| return descs, nil | ||
| // githubAttestation represents a single attestation entry in the GitHub API response. | ||
| type githubAttestation struct { | ||
| Bundle json.RawMessage `json:"bundle"` | ||
| } | ||
|
|
||
| // verifyAttestation fetches and verifies a single attestation manifest. | ||
| func verifyAttestation(ctx context.Context, ref ImageRef, desc v1.Descriptor, sev *verify.Verifier, certID verify.CertificateIdentity) error { | ||
| img, err := fetchAttestationImage(ctx, ref, desc) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| envelope, err := extractDSSEEnvelope(img) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| if err := validateSLSAPredicate(envelope); err != nil { | ||
| return err | ||
| } | ||
|
|
||
| return verifyAttestationBundle(img, ref.Digest, sev, certID) | ||
| // githubAttestationResponse is the structure returned by the GitHub | ||
| // attestation API (GET /repos/OWNER/REPO/attestations/SUBJECT_DIGEST). | ||
| type githubAttestationResponse struct { | ||
| Attestations []githubAttestation `json:"attestations"` | ||
| } | ||
|
Comment on lines
+92
to
94
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For better readability and maintainability, consider defining a named struct for the items in the For example: type githubAttestation struct {
Bundle json.RawMessage `json:"bundle"`
}
type githubAttestationResponse struct {
Attestations []githubAttestation `json:"attestations"`
} |
||
|
|
||
| // fetchAttestationImage fetches the attestation image by its descriptor digest. | ||
| func fetchAttestationImage(ctx context.Context, ref ImageRef, desc v1.Descriptor) (v1.Image, error) { | ||
| attestRef := fmt.Sprintf("%s/%s@%s", ref.Registry, ref.Repository, desc.Digest.String()) | ||
| parsed, err := name.NewDigest(attestRef) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("parsing attestation reference: %w", err) | ||
| } | ||
| // fetchGitHubAttestations queries the GitHub attestation API for Sigstore | ||
| // bundles associated with the given image digest. | ||
| func fetchGitHubAttestations(ctx context.Context, digest string) ([]json.RawMessage, error) { | ||
| url := fmt.Sprintf("%s/repos/%s/attestations/%s", githubAPIBase, githubAttestationOwnerRepo, digest) | ||
|
|
||
| img, err := remote.Image(parsed, remote.WithContext(ctx)) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("fetching attestation image: %w", err) | ||
| } | ||
| return img, nil | ||
| } | ||
| reqCtx, cancel := context.WithTimeout(ctx, attestationHTTPTimeout) | ||
| defer cancel() | ||
|
|
||
| // extractDSSEEnvelope reads and parses the DSSE envelope from the first layer | ||
| // of an attestation image. | ||
| func extractDSSEEnvelope(img v1.Image) (dsseEnvelope, error) { | ||
| layers, err := img.Layers() | ||
| req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil) | ||
| if err != nil { | ||
| return dsseEnvelope{}, fmt.Errorf("reading attestation layers: %w", err) | ||
| } | ||
| if len(layers) == 0 { | ||
| return dsseEnvelope{}, fmt.Errorf("attestation has no layers") | ||
| return nil, fmt.Errorf("creating attestation request: %w", err) | ||
| } | ||
| req.Header.Set("Accept", "application/json") | ||
|
|
||
| layerReader, err := layers[0].Uncompressed() | ||
| resp, err := attestationHTTPClient.Do(req) | ||
| if err != nil { | ||
| return dsseEnvelope{}, fmt.Errorf("reading attestation layer: %w", err) | ||
| return nil, fmt.Errorf("fetching attestations from GitHub API: %w", err) | ||
| } | ||
| defer func() { _ = layerReader.Close() }() | ||
| defer func() { _ = resp.Body.Close() }() | ||
|
|
||
| var envelope dsseEnvelope | ||
| if err := json.NewDecoder(layerReader).Decode(&envelope); err != nil { | ||
| return dsseEnvelope{}, fmt.Errorf("decoding DSSE envelope: %w", err) | ||
| if resp.StatusCode == http.StatusNotFound { | ||
| return nil, fmt.Errorf("%w via GitHub API for digest %s", ErrNoProvenanceAttestations, digest) | ||
| } | ||
| return envelope, nil | ||
| } | ||
|
|
||
| // validateSLSAPredicate checks that a DSSE envelope contains a SLSA provenance | ||
| // predicate type. | ||
| func validateSLSAPredicate(envelope dsseEnvelope) error { | ||
| if envelope.PayloadType != DSSEPayloadType { | ||
| return fmt.Errorf("unexpected DSSE payload type %q, want %q", envelope.PayloadType, DSSEPayloadType) | ||
| if resp.StatusCode != http.StatusOK { | ||
| return nil, fmt.Errorf("GitHub attestation API returned HTTP %d for digest %s", resp.StatusCode, digest) | ||
| } | ||
|
|
||
| payloadBytes, err := base64.StdEncoding.DecodeString(envelope.Payload) | ||
| body, err := io.ReadAll(io.LimitReader(resp.Body, maxAttestationResponseBytes+1)) | ||
| if err != nil { | ||
| return fmt.Errorf("decoding DSSE payload: %w", err) | ||
| } | ||
|
|
||
| var statement inTotoStatement | ||
| if err := json.Unmarshal(payloadBytes, &statement); err != nil { | ||
| return fmt.Errorf("parsing in-toto statement: %w", err) | ||
| return nil, fmt.Errorf("reading attestation response: %w", err) | ||
| } | ||
| if !strings.HasPrefix(statement.PredicateType, SLSAProvenancePredicatePrefix) { | ||
| return fmt.Errorf("unexpected predicate type %q, want prefix %q", statement.PredicateType, SLSAProvenancePredicatePrefix) | ||
| if int64(len(body)) > maxAttestationResponseBytes { | ||
| return nil, fmt.Errorf("attestation response too large (>%d bytes)", maxAttestationResponseBytes) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // verifyAttestationBundle looks for a Sigstore bundle in the attestation | ||
| // manifest or layer annotations and verifies it. Returns an error if no | ||
| // bundle is found — structural validation alone is insufficient. | ||
| func verifyAttestationBundle(img v1.Image, digest string, sev *verify.Verifier, certID verify.CertificateIdentity) error { | ||
| attestManifest, err := img.Manifest() | ||
| if err != nil { | ||
| return fmt.Errorf("reading attestation manifest: %w", err) | ||
| var apiResp githubAttestationResponse | ||
| if err := json.Unmarshal(body, &apiResp); err != nil { | ||
| return nil, fmt.Errorf("parsing attestation response: %w", err) | ||
| } | ||
|
|
||
| // Look for Sigstore bundle in manifest annotations. | ||
| if bundleJSON, ok := attestManifest.Annotations[cosignBundleAnnotation]; ok { | ||
| return verifyProvenanceBundleWith([]byte(bundleJSON), digest, sev, certID) | ||
| if len(apiResp.Attestations) == 0 { | ||
| return nil, fmt.Errorf("%w via GitHub API for digest %s", ErrNoProvenanceAttestations, digest) | ||
| } | ||
|
|
||
| // Also check layer annotations — iterate all layers, not just the first, | ||
| // since cosign may store the bundle in any layer's annotations. | ||
| for i := range attestManifest.Layers { | ||
| if bundleJSON, ok := attestManifest.Layers[i].Annotations[cosignBundleAnnotation]; ok { | ||
| return verifyProvenanceBundleWith([]byte(bundleJSON), digest, sev, certID) | ||
| bundles := make([]json.RawMessage, 0, len(apiResp.Attestations)) | ||
| for _, a := range apiResp.Attestations { | ||
| if len(a.Bundle) > 0 { | ||
| bundles = append(bundles, a.Bundle) | ||
| } | ||
| } | ||
|
|
||
| return fmt.Errorf("no sigstore bundle found in attestation — cannot cryptographically verify provenance") | ||
| if len(bundles) == 0 { | ||
| return nil, fmt.Errorf("%w (no bundles in response) for digest %s", ErrNoProvenanceAttestations, digest) | ||
| } | ||
| return bundles, nil | ||
| } | ||
|
|
||
| // verifyProvenanceBundleWith verifies a Sigstore bundle from an attestation | ||
| // using the provided verifier and identity policy. | ||
| func verifyProvenanceBundleWith(bundleJSON []byte, digest string, sev *verify.Verifier, certID verify.CertificateIdentity) error { | ||
| // verifyProvenanceBundle parses and verifies a single Sigstore bundle from | ||
| // the GitHub attestation API against the expected identity and image digest. | ||
| // After cryptographic verification, it also checks that the in-toto statement | ||
| // has a SLSA provenance predicate type (not an SBOM or other attestation). | ||
| func verifyProvenanceBundle(bundleJSON json.RawMessage, digest string, sev *verify.Verifier, certID verify.CertificateIdentity) error { | ||
| b, err := loadBundle(bundleJSON) | ||
| if err != nil { | ||
| return fmt.Errorf("parsing provenance bundle: %w", err) | ||
|
|
@@ -211,24 +164,26 @@ func verifyProvenanceBundleWith(bundleJSON []byte, digest string, sev *verify.Ve | |
| return err | ||
| } | ||
|
|
||
| _, err = sev.Verify(b, verify.NewPolicy( | ||
| // Verify the bundle cryptographically: check the signature, certificate | ||
| // identity (must be our docker.yml workflow), and artifact digest. | ||
| result, err := sev.Verify(b, verify.NewPolicy( | ||
| verify.WithArtifactDigest(digestAlgo, digestHex), | ||
| verify.WithCertificateIdentity(certID), | ||
| )) | ||
| if err != nil { | ||
| return fmt.Errorf("provenance bundle verification failed: %w", err) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| // dsseEnvelope is a minimal DSSE (Dead Simple Signing Envelope) structure. | ||
| type dsseEnvelope struct { | ||
| PayloadType string `json:"payloadType"` | ||
| Payload string `json:"payload"` | ||
| } | ||
| // sigstore-go does not validate the in-toto predicate type -- we must | ||
| // check it ourselves. Without this, an SBOM or other attestation signed | ||
| // by the same workflow identity would incorrectly pass as SLSA provenance. | ||
| if result == nil || result.Statement == nil { | ||
| return fmt.Errorf("verification succeeded but returned no statement for predicate check") | ||
| } | ||
| pt := result.Statement.PredicateType | ||
| if !strings.HasPrefix(pt, SLSAProvenancePredicatePrefix) { | ||
| return fmt.Errorf("unexpected predicate type %q, want prefix %q", pt, SLSAProvenancePredicatePrefix) | ||
| } | ||
|
|
||
| // inTotoStatement is a minimal in-toto statement for predicate type extraction. | ||
| type inTotoStatement struct { | ||
| PredicateType string `json:"predicateType"` | ||
| return nil | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
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.
Uh oh!
There was an error while loading. Please reload this page.