diff --git a/.github/workflows/pre-submit.yml b/.github/workflows/pre-submit.yml new file mode 100644 index 0000000000..91f6e46d66 --- /dev/null +++ b/.github/workflows/pre-submit.yml @@ -0,0 +1,25 @@ +name: Pre submits +on: [pull_request, workflow_dispatch] + +permissions: read-all + +jobs: + pre-submit: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 # v2.3.4 + + - name: setup-go + uses: actions/setup-go@bfdd3570ce990073878bf10f6b2d79082de49492 # v2.2.0 + with: + go-version: "1.18" + + - name: Run tests + run: | + set -euo pipefail + + # Download dependencies. + go mod vendor + # Test. + go test -mod=vendor -v ./... diff --git a/github/oidc.go b/github/oidc.go index 36995e44d5..0d27c1336a 100644 --- a/github/oidc.go +++ b/github/oidc.go @@ -17,6 +17,7 @@ package github import ( "encoding/base64" "encoding/json" + "errors" "fmt" "net/http" "os" @@ -34,24 +35,32 @@ type OIDCToken struct { JobWorkflowRef string `json:"job_workflow_ref"` } +var ( + errURLEnvKeyEmpty = fmt.Errorf("%q env var is empty", requestURLEnvKey) + errResponseJSON = errors.New("invalid response JSON") + errInvalidToken = errors.New("invalid JWT token") + errInvalidTokenB64 = errors.New("invalid JWT token base64") + errInvalidTokenJSON = errors.New("invalid JWT token JSON") +) + // RequestOIDCToken requests an OIDC token from Github's provider and returns // the token. func RequestOIDCToken(audience string) (*OIDCToken, error) { urlKey := os.Getenv(requestURLEnvKey) if urlKey == "" { - return nil, fmt.Errorf("requestURLEnvKey is empty") + return nil, errURLEnvKeyEmpty } url := urlKey + "&audience=" + audience req, err := http.NewRequest("GET", url, nil) if err != nil { - return nil, err + return nil, fmt.Errorf("creating request: %w", err) } req.Header.Add("Authorization", "bearer "+os.Getenv(requestTokenEnvKey)) resp, err := http.DefaultClient.Do(req) if err != nil { - return nil, err + return nil, fmt.Errorf("request: %w", err) } defer resp.Body.Close() @@ -62,13 +71,13 @@ func RequestOIDCToken(audience string) (*OIDCToken, error) { // Extract the value from JSON payload. decoder := json.NewDecoder(resp.Body) if err := decoder.Decode(&payload); err != nil { - return nil, err + return nil, errResponseJSON } // This is a JWT token with 3 parts. parts := strings.Split(payload.Value, ".") if len(parts) != 3 { - return nil, fmt.Errorf("invalid jwt token: found %d parts", len(parts)) + return nil, errInvalidToken } content := parts[1] @@ -76,12 +85,12 @@ func RequestOIDCToken(audience string) (*OIDCToken, error) { // Base64-decode the content. token, err := base64.RawURLEncoding.DecodeString(content) if err != nil { - return nil, fmt.Errorf("base64.RawURLEncoding.DecodeString: %w", err) + return nil, errInvalidTokenB64 } var oidc OIDCToken if err := json.Unmarshal(token, &oidc); err != nil { - return nil, fmt.Errorf("json.Unmarshal: %w", err) + return nil, errInvalidTokenJSON } return &oidc, nil diff --git a/github/oidc_test.go b/github/oidc_test.go new file mode 100644 index 0000000000..c0e689218b --- /dev/null +++ b/github/oidc_test.go @@ -0,0 +1,71 @@ +package github + +import ( + "encoding/base64" + "errors" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" +) + +func TestRequestOIDCToken(t *testing.T) { + testCases := []struct { + name string + audience string + expected *OIDCToken + raw string + err error + }{ + { + name: "basic token", + audience: "hoge", + expected: &OIDCToken{JobWorkflowRef: "hoge"}, + }, + { + name: "invalid response", + audience: "hoge", + raw: `not json`, + err: errResponseJSON, + }, + { + name: "invalid parts", + audience: "hoge", + raw: `{"value": "part1"}`, + err: errInvalidToken, + }, + { + name: "invalid base64", + audience: "hoge", + raw: `{"value": "part1.part2.part3"}`, + err: errInvalidTokenB64, + }, + { + name: "invalid json", + audience: "hoge", + raw: fmt.Sprintf(`{"value": "part1.%s.part3"}`, base64.RawURLEncoding.EncodeToString([]byte("not json"))), + err: errInvalidTokenJSON, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.expected != nil { + _, stop := NewTestOIDCServer(tc.expected) + defer stop() + } else { + _, stop := newRawTestOIDCServer(tc.raw) + defer stop() + } + + token, err := RequestOIDCToken(tc.audience) + if !errors.Is(err, tc.err) { + t.Fatalf("unexpected error: %v", cmp.Diff(err, tc.err, cmpopts.EquateErrors())) + } + if want, got := tc.expected, token; !cmp.Equal(want, got) { + t.Errorf("unexpected workflow ref\nwant: %#v\ngot: %#v\ndiff: %#v", want, got, cmp.Diff(want, got)) + } + }) + } +} diff --git a/github/oidctest.go b/github/oidctest.go new file mode 100644 index 0000000000..b1b5cf09fb --- /dev/null +++ b/github/oidctest.go @@ -0,0 +1,41 @@ +package github + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" +) + +// NewTestOIDCServer returns an httptest.Server that can be used as the OIDC +// server in tests and a cleanup function that can be used to stop and clean up +// the server. The server returns the given token when queried. +func NewTestOIDCServer(t *OIDCToken) (*httptest.Server, func()) { + b, err := json.Marshal(t) + if err != nil { + panic(err) + } + + rawResponse := fmt.Sprintf(`{"value": "part1.%s.part3"}`, base64.RawURLEncoding.EncodeToString(b)) + return newRawTestOIDCServer(rawResponse) +} + +func newRawTestOIDCServer(raw string) (*httptest.Server, func()) { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Respond with a very basic 3-part JWT token. + fmt.Fprintln(w, raw) + })) + oldEnv, ok := os.LookupEnv(requestURLEnvKey) + // NOTE: httptest.Server.URL has no trailing slash. + os.Setenv(requestURLEnvKey, s.URL+"/") + return s, func() { + s.Close() + if ok { + os.Setenv(requestURLEnvKey, oldEnv) + } else { + os.Unsetenv(requestURLEnvKey) + } + } +} diff --git a/slsa/provenance_test.go b/slsa/provenance_test.go new file mode 100644 index 0000000000..2de858f49e --- /dev/null +++ b/slsa/provenance_test.go @@ -0,0 +1,51 @@ +package slsa + +import ( + "reflect" + "testing" + + intoto "github.com/in-toto/in-toto-golang/in_toto" + slsa "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2" + + "github.com/slsa-framework/slsa-github-generator/github" +) + +func TestHostedActionsProvenance(t *testing.T) { + testCases := []struct { + name string + r WorkflowRun + expected *intoto.ProvenanceStatement + }{ + { + name: "empty", + r: WorkflowRun{}, + expected: &intoto.ProvenanceStatement{ + StatementHeader: intoto.StatementHeader{ + Type: intoto.StatementInTotoV01, + PredicateType: slsa.PredicateSLSAProvenance, + }, + Predicate: slsa.ProvenancePredicate{ + Builder: slsa.ProvenanceBuilder{ + ID: GithubHostedActionsBuilderID, + }, + Metadata: &slsa.ProvenanceMetadata{}, + }, + }, + }, + } + + for _, tc := range testCases { + _, stop := github.NewTestOIDCServer(nil) + defer stop() + + t.Run(tc.name, func(t *testing.T) { + if p, err := HostedActionsProvenance(tc.r); err != nil { + t.Fatalf("unexpected error: %v", err) + } else { + if want, got := tc.expected, p; !reflect.DeepEqual(want, got) { + t.Errorf("unexpected result, want: %#v, got: %#v", want, got) + } + } + }) + } +}