diff --git a/github/dependency_graph.go b/github/dependency_graph.go index f145799a1c7..80061a51c42 100644 --- a/github/dependency_graph.go +++ b/github/dependency_graph.go @@ -7,7 +7,10 @@ package github import ( "context" + "encoding/json" "fmt" + "net/http" + "strings" ) // DependencyGraphService handles communication with the dependency graph @@ -127,3 +130,124 @@ func (s *DependencyGraphService) GetSBOM(ctx context.Context, owner, repo string return sbom, resp, nil } + +// SBOMGeneration represents the response to a request to generate +// a software bill of materials for a repository. +type SBOMGeneration struct { + // SBOMURL is the URL the generated SBOM can be fetched from once it's + // ready. UUID extracts the identifier FetchSBOM takes. + SBOMURL *string `json:"sbom_url,omitempty"` +} + +// UUID returns the sbomUUID accepted by FetchSBOM. It is the final path segment +// of SBOMURL. +func (s *SBOMGeneration) UUID() string { + url := s.GetSBOMURL() + if i := strings.LastIndex(url, "/"); i >= 0 { + return url[i+1:] + } + return "" +} + +// GenerateSBOM requests the generation of a software bill of materials for a repository. +// +// Generation is asynchronous. Pass the returned SBOMGeneration's UUID to +// FetchSBOM to retrieve the SBOM once GitHub has built it. +// +// GitHub API docs: https://docs.github.com/rest/dependency-graph/sboms?apiVersion=2022-11-28#request-generation-of-a-software-bill-of-materials-sbom-for-a-repository +// +//meta:operation GET /repos/{owner}/{repo}/dependency-graph/sbom/generate-report +func (s *DependencyGraphService) GenerateSBOM(ctx context.Context, owner, repo string) (*SBOMGeneration, *Response, error) { + u := fmt.Sprintf("repos/%v/%v/dependency-graph/sbom/generate-report", owner, repo) + + req, err := s.client.NewRequest(ctx, "GET", u, nil) + if err != nil { + return nil, nil, err + } + + var generation *SBOMGeneration + resp, err := s.client.Do(req, &generation) + if err != nil { + return nil, resp, err + } + + return generation, resp, nil +} + +// FetchSBOM downloads a software bill of materials or returns a redirect URL. +// +// If followRedirectsClient is nil, FetchSBOM returns the download URL in +// redirectURL and a nil sbom. Otherwise, it downloads the report and returns +// sbom with an empty redirectURL. +// +// Use http.DefaultClient or another client that does not add authentication +// headers when fetching the pre-signed download URL. +// +// While GitHub is generating the SBOM, FetchSBOM returns an *AcceptedError +// and status code 202. The request can be repeated later. +// +// GitHub API docs: https://docs.github.com/rest/dependency-graph/sboms?apiVersion=2022-11-28#fetch-a-software-bill-of-materials-sbom-for-a-repository +// +//meta:operation GET /repos/{owner}/{repo}/dependency-graph/sbom/fetch-report/{sbom_uuid} +func (s *DependencyGraphService) FetchSBOM(ctx context.Context, owner, repo, sbomUUID string, followRedirectsClient *http.Client) (sbom *SBOM, redirectURL string, resp *Response, err error) { + u := fmt.Sprintf("repos/%v/%v/dependency-graph/sbom/fetch-report/%v", owner, repo, sbomUUID) + + req, err := s.client.NewRequest(ctx, "GET", u, nil) + if err != nil { + return nil, "", nil, err + } + + loc, resp, err := s.client.bareDoUntilFound(req, 10) + if err != nil { + return nil, "", resp, err + } + defer resp.Body.Close() + + if loc == nil { + return nil, "", resp, fmt.Errorf("expected redirect, got status %v", resp.Status) + } + + if followRedirectsClient == nil { + return nil, loc.String(), resp, nil + } + + sbom, err = s.fetchSBOMFromURL(ctx, followRedirectsClient, loc.String()) + if err != nil { + return nil, "", resp, err + } + + return sbom, "", resp, nil +} + +// fetchSBOMFromURL downloads and decodes an SPDX report from a temporary download +// URL. +// +// The request must not carry s.client's credentials: the URL is pre-signed and +// its host rejects authenticated requests. +func (s *DependencyGraphService) fetchSBOMFromURL(ctx context.Context, followRedirectsClient *http.Client, url string) (*SBOM, error) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + + resp, err := followRedirectsClient.Do(req) + if err != nil { + return nil, err + } + + // CheckResponse substitutes resp.Body with a re-readable copy on error responses, + // so capture the network body first: it is the one that must be closed. + origBody := resp.Body + defer origBody.Close() + + if err := CheckResponse(resp); err != nil { + return nil, err + } + + var info *SBOMInfo + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return nil, err + } + + return &SBOM{SBOM: info}, nil +} diff --git a/github/dependency_graph_test.go b/github/dependency_graph_test.go index 5281c570672..c6d85379e40 100644 --- a/github/dependency_graph_test.go +++ b/github/dependency_graph_test.go @@ -6,8 +6,11 @@ package github import ( + "errors" "fmt" + "io" "net/http" + "net/http/httptest" "testing" "github.com/google/go-cmp/cmp" @@ -74,3 +77,295 @@ func TestDependencyGraphService_GetSBOM(t *testing.T) { return resp, err }) } + +func TestSBOMGeneration_UUID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + gen *SBOMGeneration + want string + }{ + { + name: "reads the UUID off a generation response", + gen: &SBOMGeneration{ + SBOMURL: new("https://api.github.com/repos/owner/repo/dependency-graph/sbom/fetch-report/c0ccba21-ccba-4292-9afd-a64781f7e98a"), + }, + want: "c0ccba21-ccba-4292-9afd-a64781f7e98a", + }, + { + name: "nil SBOMGeneration", + gen: nil, + }, + { + name: "unset SBOMURL", + gen: &SBOMGeneration{}, + }, + { + name: "SBOMURL has no path segments", + gen: &SBOMGeneration{SBOMURL: new("c0ccba21")}, + }, + { + name: "SBOMURL ends in a separator", + gen: &SBOMGeneration{ + SBOMURL: new("https://api.github.com/repos/owner/repo/dependency-graph/sbom/fetch-report/"), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := tt.gen.UUID(); got != tt.want { + t.Errorf("SBOMGeneration.UUID() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestDependencyGraphService_GenerateSBOM(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + mux.HandleFunc("/repos/owner/repo/dependency-graph/sbom/generate-report", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"sbom_url":"https://api.github.com/repos/owner/repo/dependency-graph/sbom/fetch-report/1234"}`) + }) + + ctx := t.Context() + generation, resp, err := client.DependencyGraph.GenerateSBOM(ctx, "owner", "repo") + if err != nil { + t.Errorf("DependencyGraph.GenerateSBOM returned error: %v", err) + } + + if resp.StatusCode != http.StatusCreated { + t.Errorf("DependencyGraph.GenerateSBOM returned status = %v, want %v", resp.StatusCode, http.StatusCreated) + } + + want := &SBOMGeneration{ + SBOMURL: new("https://api.github.com/repos/owner/repo/dependency-graph/sbom/fetch-report/1234"), + } + if !cmp.Equal(generation, want) { + t.Errorf("DependencyGraph.GenerateSBOM returned %+v, want %+v", generation, want) + } + + const methodName = "GenerateSBOM" + testBadOptions(t, methodName, func() (err error) { + _, _, err = client.DependencyGraph.GenerateSBOM(ctx, "\n", "\n") + return err + }) + + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.DependencyGraph.GenerateSBOM(ctx, "owner", "repo") + if got != nil { + t.Errorf("testNewRequestAndDoFailure %v = %#v, want nil", methodName, got) + } + return resp, err + }) +} + +func TestDependencyGraphService_FetchSBOM_Download(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + downloadStatus int + downloadBody string + wantSBOM *SBOM + wantErr error + }{ + { + name: "success", + downloadStatus: http.StatusOK, + downloadBody: `{"name":"owner/repo"}`, + wantSBOM: &SBOM{ + SBOM: &SBOMInfo{Name: new("owner/repo")}, + }, + }, + { + name: "forbidden", + downloadStatus: http.StatusForbidden, + wantErr: &ErrorResponse{ + Response: &http.Response{StatusCode: http.StatusForbidden}, + }, + }, + { + name: "truncated JSON", + downloadStatus: http.StatusOK, + downloadBody: `{"name":"owner/`, + wantErr: io.ErrUnexpectedEOF, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + client, err := client.Clone(WithAuthToken("secret-token")) + if err != nil { + t.Fatalf("failed to clone client: %v", err) + } + + downloadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + testHeader(t, r, "Authorization", "") + w.WriteHeader(tt.downloadStatus) + fmt.Fprint(w, tt.downloadBody) + })) + t.Cleanup(downloadServer.Close) + + mux.HandleFunc("/repos/owner/repo/dependency-graph/sbom/fetch-report/1234", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + testHeader(t, r, "Authorization", "Bearer secret-token") + http.Redirect(w, r, downloadServer.URL, http.StatusFound) + }) + + sbom, redirectURL, resp, err := client.DependencyGraph.FetchSBOM(t.Context(), "owner", "repo", "1234", http.DefaultClient) + if !errors.Is(err, tt.wantErr) { + t.Errorf("DependencyGraph.FetchSBOM error = %#v, want %#v", err, tt.wantErr) + } + + if resp == nil || resp.StatusCode != http.StatusFound { + t.Errorf("DependencyGraph.FetchSBOM response = %v, want status 302", resp) + } + if diff := cmp.Diff(tt.wantSBOM, sbom); diff != "" { + t.Errorf("DependencyGraph.FetchSBOM SBOM mismatch (-want +got):\n%v", diff) + } + if redirectURL != "" { + t.Errorf("DependencyGraph.FetchSBOM redirectURL = %q, want empty", redirectURL) + } + }) + } +} + +func TestDependencyGraphService_FetchSBOM_Redirect(t *testing.T) { + t.Parallel() + client, mux, serverURL := setup(t) + downloadURL := serverURL + baseURLPath + "/download" + + mux.HandleFunc("/repos/owner/repo/dependency-graph/sbom/fetch-report/1234", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + http.Redirect(w, r, downloadURL, http.StatusFound) + }) + mux.HandleFunc("/download", func(http.ResponseWriter, *http.Request) { + t.Error("download URL was fetched with a nil followRedirectsClient") + }) + + sbom, redirectURL, resp, err := client.DependencyGraph.FetchSBOM(t.Context(), "owner", "repo", "1234", nil) + if err != nil { + t.Fatalf("DependencyGraph.FetchSBOM returned error: %v", err) + } + + if sbom != nil { + t.Errorf("DependencyGraph.FetchSBOM SBOM = %v, want nil", sbom) + } + if redirectURL != downloadURL { + t.Errorf("DependencyGraph.FetchSBOM redirectURL = %q, want %q", redirectURL, downloadURL) + } + if resp == nil || resp.StatusCode != http.StatusFound { + t.Errorf("DependencyGraph.FetchSBOM response = %v, want status 302", resp) + } +} + +func TestDependencyGraphService_FetchSBOM_DownloadTransportError(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + const downloadURL = "https://example.com/sbom" + wantErr := errors.New("download failed") + downloadClient := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + testMethod(t, r, "GET") + if got := r.URL.String(); got != downloadURL { + t.Errorf("download URL = %q, want %q", got, downloadURL) + } + return nil, wantErr + }), + } + + mux.HandleFunc("/repos/owner/repo/dependency-graph/sbom/fetch-report/1234", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + http.Redirect(w, r, downloadURL, http.StatusFound) + }) + + sbom, redirectURL, resp, err := client.DependencyGraph.FetchSBOM(t.Context(), "owner", "repo", "1234", downloadClient) + if !errors.Is(err, wantErr) { + t.Errorf("DependencyGraph.FetchSBOM error = %v, want %v", err, wantErr) + } + + if sbom != nil || redirectURL != "" { + t.Errorf("DependencyGraph.FetchSBOM returned (%v, %q), want (nil, empty)", sbom, redirectURL) + } + if resp == nil || resp.StatusCode != http.StatusFound { + t.Errorf("DependencyGraph.FetchSBOM response = %v, want status 302", resp) + } +} + +func TestDependencyGraphService_FetchSBOM_AcceptedError(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + mux.HandleFunc("/repos/owner/repo/dependency-graph/sbom/fetch-report/1234", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + w.WriteHeader(http.StatusAccepted) + }) + + sbom, redirectURL, resp, err := client.DependencyGraph.FetchSBOM(t.Context(), "owner", "repo", "1234", http.DefaultClient) + + if _, ok := errors.AsType[*AcceptedError](err); !ok { + t.Errorf("DependencyGraph.FetchSBOM error = %v, want *AcceptedError", err) + } + + if sbom != nil || redirectURL != "" { + t.Errorf("DependencyGraph.FetchSBOM returned (%v, %q), want (nil, empty)", sbom, redirectURL) + } + if resp == nil || resp.StatusCode != http.StatusAccepted { + t.Errorf("DependencyGraph.FetchSBOM response = %v, want status 202", resp) + } +} + +func TestDependencyGraphService_FetchSBOM_NoRedirect(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + mux.HandleFunc("/repos/owner/repo/dependency-graph/sbom/fetch-report/1234", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + w.WriteHeader(http.StatusOK) + }) + + sbom, redirectURL, resp, err := client.DependencyGraph.FetchSBOM(t.Context(), "owner", "repo", "1234", http.DefaultClient) + if err == nil { + t.Error("DependencyGraph.FetchSBOM returned no error for a non-redirect response") + } + + if sbom != nil || redirectURL != "" { + t.Errorf("DependencyGraph.FetchSBOM returned (%v, %q), want (nil, empty)", sbom, redirectURL) + } + if resp == nil || resp.StatusCode != http.StatusOK { + t.Errorf("DependencyGraph.FetchSBOM response = %v, want status 200", resp) + } +} + +func TestDependencyGraphService_FetchSBOM_requestErrors(t *testing.T) { + t.Parallel() + client, _, _ := setup(t) + + ctx := t.Context() + + const methodName = "FetchSBOM" + testBadOptions(t, methodName, func() (err error) { + _, _, _, err = client.DependencyGraph.FetchSBOM(ctx, "\n", "\n", "\n", http.DefaultClient) + return err + }) + + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + sbom, redirectURL, resp, err := client.DependencyGraph.FetchSBOM(ctx, "owner", "repo", "1234", http.DefaultClient) + if sbom != nil || redirectURL != "" { + t.Errorf("testNewRequestAndDoFailure %v returned (%v, %q), want (nil, empty)", methodName, sbom, redirectURL) + } + return resp, err + }) +} diff --git a/github/github-accessors.go b/github/github-accessors.go index 50ac38eb513..8fd0bc12110 100644 --- a/github/github-accessors.go +++ b/github/github-accessors.go @@ -39942,6 +39942,14 @@ func (s *SBOM) GetSBOM() *SBOMInfo { return s.SBOM } +// GetSBOMURL returns the SBOMURL field if it's non-nil, zero value otherwise. +func (s *SBOMGeneration) GetSBOMURL() string { + if s == nil || s.SBOMURL == nil { + return "" + } + return *s.SBOMURL +} + // GetCreationInfo returns the CreationInfo field. func (s *SBOMInfo) GetCreationInfo() *CreationInfo { if s == nil { diff --git a/github/github-accessors_test.go b/github/github-accessors_test.go index 921473bf3bd..e8e2b5bae66 100644 --- a/github/github-accessors_test.go +++ b/github/github-accessors_test.go @@ -49852,6 +49852,17 @@ func TestSBOM_GetSBOM(tt *testing.T) { s.GetSBOM() } +func TestSBOMGeneration_GetSBOMURL(tt *testing.T) { + tt.Parallel() + var zeroValue string + s := &SBOMGeneration{SBOMURL: &zeroValue} + s.GetSBOMURL() + s = &SBOMGeneration{} + s.GetSBOMURL() + s = nil + s.GetSBOMURL() +} + func TestSBOMInfo_GetCreationInfo(tt *testing.T) { tt.Parallel() s := &SBOMInfo{} diff --git a/github/github.go b/github/github.go index 91569340f16..df3f59115af 100644 --- a/github/github.go +++ b/github/github.go @@ -1992,9 +1992,10 @@ func GetRateLimitCategory(method, path string) RateLimitCategory { case strings.HasSuffix(path, "/audit-log"): return AuditLogCategory - // https://docs.github.com/rest/dependency-graph/sboms?apiVersion=2022-11-28#export-a-software-bill-of-materials-sbom-for-a-repository + // https://docs.github.com/rest/dependency-graph/sboms?apiVersion=2022-11-28 case strings.HasPrefix(path, "/repos/") && - strings.HasSuffix(path, "/dependency-graph/sbom"): + (strings.HasSuffix(path, "/dependency-graph/sbom") || + strings.HasSuffix(path, "/dependency-graph/sbom/generate-report")): return DependencySBOMCategory } } diff --git a/github/github_test.go b/github/github_test.go index a6c946e846b..69c05ae12e4 100644 --- a/github/github_test.go +++ b/github/github_test.go @@ -2566,6 +2566,16 @@ func TestDo_rateLimitCategory(t *testing.T) { url: "/repos/google/go-github/dependency-graph/sbom", category: DependencySBOMCategory, }, + { + method: "GET", + url: "/repos/google/go-github/dependency-graph/sbom/generate-report", + category: DependencySBOMCategory, + }, + { + method: "GET", + url: "/repos/google/go-github/dependency-graph/sbom/fetch-report/1234", + category: CoreCategory, + }, // missing a check for actionsRunnerRegistrationCategory: API not found }