Skip to content
Closed
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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ require (
github.com/hashicorp/logutils v1.0.0
github.com/jessevdk/go-flags v1.6.1
github.com/jstemmer/go-junit-report v1.0.0
github.com/klauspost/compress v1.18.6
github.com/mattn/go-colorable v0.1.15
github.com/mitchellh/go-homedir v1.1.0
github.com/owenrumney/go-sarif/v2 v2.3.3
Expand Down Expand Up @@ -125,7 +126,6 @@ require (
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/in-toto/attestation v1.2.0 // indirect
github.com/in-toto/in-toto-golang v0.11.0 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/oklog/run v1.1.0 // indirect
Expand Down
80 changes: 78 additions & 2 deletions plugin/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
Expand All @@ -18,6 +19,7 @@ import (
"strings"

"github.com/google/go-github/v81/github"
"github.com/klauspost/compress/snappy"
"github.com/terraform-linters/tflint/tflint"
"golang.org/x/net/idna"
"golang.org/x/oauth2"
Expand Down Expand Up @@ -296,19 +298,93 @@ func (c *InstallConfig) fetchReleaseAssets(ctx context.Context, client *github.C
return assets, nil
}

// attestationsResponse is like github.AttestationsResponse, but also captures
// the bundle_url field, which go-github does not expose. GitHub may serve the
// sigstore bundle from blob storage instead of embedding it in the response.
type attestationsResponse struct {
Attestations []*attestation `json:"attestations"`
}

type attestation struct {
Bundle json.RawMessage `json:"bundle"`
BundleURL string `json:"bundle_url"`
RepositoryID int64 `json:"repository_id"`
}

// fetchArtifactAttestations fetches GitHub Artifact Attestations based on the given artifact.
// Attestations whose sigstore bundle is not embedded in the API response are
// resolved by downloading the bundle from the URL in the bundle_url field.
func (c *InstallConfig) fetchArtifactAttestations(ctx context.Context, client *github.Client, artifact []byte) ([]*github.Attestation, error) {
hash := sha256.New()
if _, err := hash.Write(artifact); err != nil {
return []*github.Attestation{}, err
}
digest := hex.EncodeToString(hash.Sum(nil))

resp, _, err := client.Repositories.ListAttestations(ctx, c.SourceOwner, c.SourceRepo, "sha256:"+digest, nil)
u := fmt.Sprintf("repos/%s/%s/attestations/sha256:%s", c.SourceOwner, c.SourceRepo, digest)
req, err := client.NewRequest(http.MethodGet, u, nil)
if err != nil {
return []*github.Attestation{}, err
}
return resp.Attestations, nil
var resp attestationsResponse
if _, err := client.Do(ctx, req, &resp); err != nil {
return []*github.Attestation{}, err
}

attestations := make([]*github.Attestation, 0, len(resp.Attestations))
for _, a := range resp.Attestations {
b := a.Bundle
if isNullJSON(b) {
if a.BundleURL == "" {
return []*github.Attestation{}, fmt.Errorf("attestation has no bundle or bundle URL")
}
log.Printf("[DEBUG] Attestation bundle is not embedded, downloading from the bundle URL")
b, err = downloadAttestationBundle(ctx, a.BundleURL)
if err != nil {
return []*github.Attestation{}, err
}
}
attestations = append(attestations, &github.Attestation{Bundle: b, RepositoryID: a.RepositoryID})
}
return attestations, nil
}

// isNullJSON returns whether the raw JSON value is empty or the null literal.
func isNullJSON(raw json.RawMessage) bool {
trimmed := bytes.TrimSpace(raw)
return len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null"))
}

// downloadAttestationBundle downloads a sigstore bundle from the given URL.
// GitHub serves bundles from blob storage as snappy-compressed JSON.
// The URL is pre-signed, so the request is sent without API credentials.
func downloadAttestationBundle(ctx context.Context, url string) (json.RawMessage, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to download attestation bundle: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}

decoded, err := snappy.Decode(nil, body)
if err != nil {
// Fall back to the response as-is for bundles served without compression.
if json.Valid(body) {
return body, nil
}
return nil, fmt.Errorf("failed to decompress attestation bundle: %s", err)
}
return decoded, nil
}

func isIgnorableAttestationError(err error) bool {
Expand Down
117 changes: 117 additions & 0 deletions plugin/install_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
package plugin

import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"testing"

"github.com/google/go-cmp/cmp"
"github.com/google/go-github/v81/github"
"github.com/klauspost/compress/snappy"
"github.com/terraform-linters/tflint/tflint"
)

Expand Down Expand Up @@ -450,3 +459,111 @@
})
}
}

func Test_fetchArtifactAttestations(t *testing.T) {
artifact := []byte("test artifact")
digest := sha256.Sum256(artifact)
attestationsPath := "/repos/terraform-linters/tflint-ruleset-aws/attestations/sha256:" + hex.EncodeToString(digest[:])

bundleJSON := `{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json"}`

cases := []struct {
name string
response func(serverURL string) string
blobHandler http.HandlerFunc
want []*github.Attestation
expectedErr error
}{
{
name: "embedded bundle",
response: func(string) string {
return `{"attestations":[{"repository_id":1,"bundle":` + bundleJSON + `}]}`
},
want: []*github.Attestation{{Bundle: json.RawMessage(bundleJSON), RepositoryID: 1}},
},
{
name: "bundle from bundle_url",
response: func(serverURL string) string {
return `{"attestations":[{"repository_id":1,"bundle":null,"bundle_url":"` + serverURL + `/blob"}]}`
},
blobHandler: func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/x-snappy")
w.Write(snappy.Encode(nil, []byte(bundleJSON)))

Check failure on line 491 in plugin/install_test.go

View workflow job for this annotation

GitHub Actions / checks

Error return value of `w.Write` is not checked (errcheck)
},
want: []*github.Attestation{{Bundle: json.RawMessage(bundleJSON), RepositoryID: 1}},
},
{
name: "uncompressed bundle from bundle_url",
response: func(serverURL string) string {
return `{"attestations":[{"repository_id":1,"bundle":null,"bundle_url":"` + serverURL + `/blob"}]}`
},
blobHandler: func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(bundleJSON))

Check failure on line 501 in plugin/install_test.go

View workflow job for this annotation

GitHub Actions / checks

Error return value of `w.Write` is not checked (errcheck)
},
want: []*github.Attestation{{Bundle: json.RawMessage(bundleJSON), RepositoryID: 1}},
},
{
name: "no bundle and no bundle_url",
response: func(string) string {
return `{"attestations":[{"repository_id":1,"bundle":null}]}`
},
expectedErr: fmt.Errorf("attestation has no bundle or bundle URL"),
},
{
name: "bundle_url download failure",
response: func(serverURL string) string {
return `{"attestations":[{"repository_id":1,"bundle":null,"bundle_url":"` + serverURL + `/blob"}]}`
},
blobHandler: func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "gone", http.StatusNotFound)
},
expectedErr: fmt.Errorf("failed to download attestation bundle: 404 Not Found"),
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mux := http.NewServeMux()
server := httptest.NewServer(mux)
defer server.Close()

mux.HandleFunc(attestationsPath, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, tc.response(server.URL))
})
if tc.blobHandler != nil {
mux.HandleFunc("/blob", tc.blobHandler)
}

client := github.NewClient(nil)
baseURL, err := url.Parse(server.URL + "/")
if err != nil {
t.Fatal(err)
}
client.BaseURL = baseURL

config := NewInstallConfig(tflint.EmptyConfig(), &tflint.PluginConfig{
SourceHost: "github.com",
SourceOwner: "terraform-linters",
SourceRepo: "tflint-ruleset-aws",
})
got, err := config.fetchArtifactAttestations(context.Background(), client, artifact)

if tc.expectedErr != nil {
if err == nil {
t.Fatalf("expected=%s, actual=no errors", tc.expectedErr)
}
if err.Error() != tc.expectedErr.Error() {
t.Fatalf("expected=%s, actual=%s", tc.expectedErr, err)
}
return
}
if err != nil {
t.Fatalf("failed to fetch attestations: %s", err)
}
if diff := cmp.Diff(tc.want, got); diff != "" {
t.Fatal(diff)
}
})
}
}
20 changes: 14 additions & 6 deletions plugin/signature.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,19 @@ func (c *SignatureChecker) VerifyAttestations(target io.Reader, attestations []*
}
artifactDigest := sha256.Sum256(artifact)

// Parse attestation bundles upfront. Note that a null bundle is
// unmarshaled to nil without errors, which would crash the verifier
// if passed through.
bundles := make([]*bundle.Bundle, len(attestations))
for i, attestation := range attestations {
if err := json.Unmarshal(attestation.Bundle, &bundles[i]); err != nil {
return fmt.Errorf("failed to unmarshal sigstore bundle: %s", err)
}
if bundles[i] == nil {
return fmt.Errorf("attestation contains an empty sigstore bundle")
}
}

// Initialize Sigstore trust root
// This saves the caches under the "~/.sigstore"
client, err := tuf.New(tuf.DefaultOptions())
Expand Down Expand Up @@ -126,13 +139,8 @@ func (c *SignatureChecker) VerifyAttestations(target io.Reader, attestations []*
)

// Verify attestations
var b *bundle.Bundle
var verifyErr error
for _, attestation := range attestations {
if err := json.Unmarshal(attestation.Bundle, &b); err != nil {
return fmt.Errorf("failed to unmarshal sigstore bundle: %s", err)
}

for _, b := range bundles {
ret, err := verifier.Verify(b, policy)
if err != nil {
verifyErr = err
Expand Down
11 changes: 11 additions & 0 deletions plugin/signature_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,17 @@ b97e20eae04a45d650886611f17020fd0aa29114b86268b71e3841195fbc55ca tflint-ruleset
Attestations: []*github.Attestation{},
Expected: fmt.Errorf("no attestations found"),
},
{
Name: "null bundle",
Config: NewInstallConfig(tflint.EmptyConfig(), &tflint.PluginConfig{SourceHost: "github.com", SourceOwner: "terraform-linters", SourceRepo: "tflint-ruleset-aws"}),
Attestations: []*github.Attestation{
{
RepositoryID: 245765716,
Bundle: []byte(`null`),
},
},
Expected: fmt.Errorf("attestation contains an empty sigstore bundle"),
},
{
Name: "mismatched attestations",
Config: NewInstallConfig(tflint.EmptyConfig(), &tflint.PluginConfig{SourceHost: "github.com", SourceOwner: "terraform-linters", SourceRepo: "tflint-ruleset-aws"}),
Expand Down
Loading