The github.com/golang/snappy repository was archived and is no longer maintained. klauspost/compress provides a drop-in replacement, which is actively maintained, and the klauspost/compress module is already an existing (indirect) dependency. Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
306 lines
8 KiB
Go
306 lines
8 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/cenkalti/backoff/v4"
|
|
"github.com/cli/cli/v2/api"
|
|
ioconfig "github.com/cli/cli/v2/pkg/cmd/attestation/io"
|
|
"github.com/klauspost/compress/snappy"
|
|
v1 "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1"
|
|
"github.com/sigstore/sigstore-go/pkg/bundle"
|
|
"golang.org/x/sync/errgroup"
|
|
"google.golang.org/protobuf/encoding/protojson"
|
|
)
|
|
|
|
const (
|
|
DefaultLimit = 30
|
|
maxLimitForFlag = 1000
|
|
maxLimitForFetch = 100
|
|
)
|
|
|
|
// Allow injecting backoff interval in tests.
|
|
var getAttestationRetryInterval = time.Millisecond * 200
|
|
|
|
// FetchParams are the parameters for fetching attestations from the GitHub API
|
|
type FetchParams struct {
|
|
Digest string
|
|
Limit int
|
|
Owner string
|
|
PredicateType string
|
|
Repo string
|
|
Initiator string
|
|
}
|
|
|
|
func (p *FetchParams) Validate() error {
|
|
if p.Digest == "" {
|
|
return fmt.Errorf("digest must be provided")
|
|
}
|
|
if p.Limit <= 0 || p.Limit > maxLimitForFlag {
|
|
return fmt.Errorf("limit must be greater than 0 and less than or equal to %d", maxLimitForFlag)
|
|
}
|
|
if p.Repo == "" && p.Owner == "" {
|
|
return fmt.Errorf("owner or repo must be provided")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// githubApiClient makes REST calls to the GitHub API
|
|
type githubApiClient interface {
|
|
REST(hostname, method, p string, body io.Reader, data interface{}) error
|
|
RESTWithNext(hostname, method, p string, body io.Reader, data interface{}) (string, error)
|
|
}
|
|
|
|
// httpClient makes HTTP calls to all non-GitHub API endpoints
|
|
type httpClient interface {
|
|
Get(url string) (*http.Response, error)
|
|
}
|
|
|
|
type Client interface {
|
|
GetByDigest(params FetchParams) ([]*Attestation, error)
|
|
GetTrustDomain() (string, error)
|
|
}
|
|
|
|
type LiveClient struct {
|
|
githubAPI githubApiClient
|
|
httpClient httpClient
|
|
host string
|
|
logger *ioconfig.Handler
|
|
}
|
|
|
|
func NewLiveClient(hc *http.Client, host string, l *ioconfig.Handler) *LiveClient {
|
|
return &LiveClient{
|
|
githubAPI: api.NewClientFromHTTP(hc),
|
|
host: strings.TrimSuffix(host, "/"),
|
|
httpClient: hc,
|
|
logger: l,
|
|
}
|
|
}
|
|
|
|
// GetByDigest fetches the attestation by digest and either owner or repo
|
|
// depending on which is provided
|
|
func (c *LiveClient) GetByDigest(params FetchParams) ([]*Attestation, error) {
|
|
c.logger.VerbosePrintf("Fetching attestations for artifact digest %s\n\n", params.Digest)
|
|
attestations, err := c.getAttestations(params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
bundles, err := c.fetchBundleFromAttestations(attestations)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch bundle with URL: %w", err)
|
|
}
|
|
|
|
return bundles, nil
|
|
}
|
|
|
|
func (c *LiveClient) buildRequestURL(params FetchParams) (string, error) {
|
|
if err := params.Validate(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var url string
|
|
if params.Repo != "" {
|
|
// check if Repo is set first because if Repo has been set, Owner will be set using the value of Repo.
|
|
// If Repo is not set, the field will remain empty. It will not be populated using the value of Owner.
|
|
url = fmt.Sprintf(GetAttestationByRepoAndSubjectDigestPath, params.Repo, params.Digest)
|
|
} else {
|
|
url = fmt.Sprintf(GetAttestationByOwnerAndSubjectDigestPath, params.Owner, params.Digest)
|
|
}
|
|
|
|
perPage := params.Limit
|
|
if perPage > maxLimitForFetch {
|
|
perPage = maxLimitForFetch
|
|
}
|
|
|
|
// ref: https://github.com/cli/go-gh/blob/d32c104a9a25c9de3d7c7b07a43ae0091441c858/example_gh_test.go#L96
|
|
url = fmt.Sprintf("%s?per_page=%d", url, perPage)
|
|
if params.PredicateType != "" {
|
|
url = fmt.Sprintf("%s&predicate_type=%s", url, params.PredicateType)
|
|
}
|
|
return url, nil
|
|
}
|
|
|
|
func (c *LiveClient) getAttestations(params FetchParams) ([]*Attestation, error) {
|
|
url, err := c.buildRequestURL(params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var attestations []*Attestation
|
|
var resp AttestationsResponse
|
|
bo := backoff.NewConstantBackOff(getAttestationRetryInterval)
|
|
|
|
// if no attestation or less than limit, then keep fetching
|
|
for url != "" && len(attestations) < params.Limit {
|
|
err := backoff.Retry(func() error {
|
|
newURL, restErr := c.githubAPI.RESTWithNext(c.host, http.MethodGet, url, nil, &resp)
|
|
if restErr != nil {
|
|
if shouldRetry(restErr) {
|
|
return restErr
|
|
}
|
|
return backoff.Permanent(restErr)
|
|
}
|
|
|
|
url = newURL
|
|
|
|
// filter by the initiator type
|
|
if params.Initiator != "" {
|
|
filtered := make([]*Attestation, 0, len(resp.Attestations))
|
|
for _, att := range resp.Attestations {
|
|
if att.Initiator == params.Initiator {
|
|
filtered = append(filtered, att)
|
|
}
|
|
}
|
|
resp.Attestations = filtered
|
|
}
|
|
attestations = append(attestations, resp.Attestations...)
|
|
|
|
return nil
|
|
}, backoff.WithMaxRetries(bo, 3))
|
|
|
|
// bail if RESTWithNext errored out
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if len(attestations) == 0 {
|
|
return nil, ErrNoAttestationsFound
|
|
}
|
|
|
|
if len(attestations) > params.Limit {
|
|
return attestations[:params.Limit], nil
|
|
}
|
|
|
|
return attestations, nil
|
|
}
|
|
|
|
func (c *LiveClient) fetchBundleFromAttestations(attestations []*Attestation) ([]*Attestation, error) {
|
|
fetched := make([]*Attestation, len(attestations))
|
|
g := errgroup.Group{}
|
|
for i, a := range attestations {
|
|
g.Go(func() error {
|
|
if a.Bundle == nil && a.BundleURL == "" {
|
|
return fmt.Errorf("attestation has no bundle or bundle URL")
|
|
}
|
|
|
|
// for now, we fall back to the bundle field if the bundle URL is empty
|
|
if a.BundleURL == "" {
|
|
c.logger.VerbosePrintf("Bundle URL is empty. Falling back to bundle field\n\n")
|
|
fetched[i] = &Attestation{
|
|
Bundle: a.Bundle,
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// otherwise fetch the bundle with the provided URL
|
|
b, err := c.getBundle(a.BundleURL)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch bundle with URL: %w", err)
|
|
}
|
|
fetched[i] = &Attestation{
|
|
Bundle: b,
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
if err := g.Wait(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return fetched, nil
|
|
}
|
|
|
|
func (c *LiveClient) getBundle(url string) (*bundle.Bundle, error) {
|
|
c.logger.VerbosePrintf("Fetching attestation bundle with bundle URL\n\n")
|
|
|
|
var sgBundle *bundle.Bundle
|
|
bo := backoff.NewConstantBackOff(getAttestationRetryInterval)
|
|
err := backoff.Retry(func() error {
|
|
resp, err := c.httpClient.Get(url)
|
|
if err != nil {
|
|
return fmt.Errorf("request to fetch bundle from URL failed: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode >= 500 && resp.StatusCode <= 599 {
|
|
return fmt.Errorf("attestation bundle with URL %s returned status code %d", url, resp.StatusCode)
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read blob storage response body: %w", err)
|
|
}
|
|
|
|
var out []byte
|
|
decompressed, err := snappy.Decode(out, body)
|
|
if err != nil {
|
|
return backoff.Permanent(fmt.Errorf("failed to decompress with snappy: %w", err))
|
|
}
|
|
|
|
var pbBundle v1.Bundle
|
|
if err = protojson.Unmarshal(decompressed, &pbBundle); err != nil {
|
|
return backoff.Permanent(fmt.Errorf("failed to unmarshal to bundle: %w", err))
|
|
}
|
|
|
|
c.logger.VerbosePrintf("Successfully fetched bundle\n\n")
|
|
|
|
sgBundle, err = bundle.NewBundle(&pbBundle)
|
|
if err != nil {
|
|
return backoff.Permanent(fmt.Errorf("failed to create new bundle: %w", err))
|
|
}
|
|
|
|
return nil
|
|
}, backoff.WithMaxRetries(bo, 3))
|
|
|
|
return sgBundle, err
|
|
}
|
|
|
|
func shouldRetry(err error) bool {
|
|
var httpError api.HTTPError
|
|
if errors.As(err, &httpError) {
|
|
if httpError.StatusCode >= 500 && httpError.StatusCode <= 599 {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// GetTrustDomain returns the current trust domain. If the default is used
|
|
// the empty string is returned
|
|
func (c *LiveClient) GetTrustDomain() (string, error) {
|
|
return c.getTrustDomain(MetaPath)
|
|
}
|
|
|
|
func (c *LiveClient) getTrustDomain(url string) (string, error) {
|
|
var resp MetaResponse
|
|
|
|
bo := backoff.NewConstantBackOff(getAttestationRetryInterval)
|
|
err := backoff.Retry(func() error {
|
|
restErr := c.githubAPI.REST(c.host, http.MethodGet, url, nil, &resp)
|
|
if restErr != nil {
|
|
if shouldRetry(restErr) {
|
|
return restErr
|
|
} else {
|
|
return backoff.Permanent(restErr)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}, backoff.WithMaxRetries(bo, 3))
|
|
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return resp.Domains.ArtifactAttestations.TrustDomain, nil
|
|
}
|