diff --git a/README.md b/README.md index fa5a049a3..6038bedb2 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ to add new and/or missing endpoints. Currently, the following services are suppo - [x] Commits - [x] Container Registry - [x] Custom Attributes +- [x] Dependency List Export - [x] Deploy Keys - [x] Deployments - [x] Discussions (threaded comments) diff --git a/dependency_list_export.go b/dependency_list_export.go new file mode 100644 index 000000000..c1e786e8d --- /dev/null +++ b/dependency_list_export.go @@ -0,0 +1,122 @@ +package gitlab + +import ( + "bytes" + "fmt" + "io" + "net/http" +) + +type DependencyListExportService struct { + client *Client +} + +// CreateDependencyListExportOptions represents the available CreateDependencyListExport() +// options. +// +// GitLab API docs: +// https://docs.gitlab.com/ee/api/dependency_list_export.html#create-a-pipeline-level-dependency-list-export +type CreateDependencyListExportOptions struct { + ExportType *string `url:"export_type" json:"export_type"` +} + +// DependencyListExport represents a request for a GitLab project's dependency list. +// +// GitLab API docs: +// https://docs.gitlab.com/ee/api/dependency_list_export.html#create-a-pipeline-level-dependency-list-export +type DependencyListExport struct { + ID int `json:"id"` + HasFinished bool `json:"has_finished"` + Self string `json:"self"` + Download string `json:"download"` +} + +const defaultExportType = "sbom" + +// CreateDependencyListExport creates a new CycloneDX JSON export for all the project dependencies +// detected in a pipeline. +// +// If an authenticated user does not have permission to read_dependency, this request returns a 403 +// Forbidden status code. +// +// SBOM exports can be only accessed by the export’s author. +// +// GitLab docs: +// https://docs.gitlab.com/ee/api/dependency_list_export.html#create-a-pipeline-level-dependency-list-export +func (s *DependencyListExportService) CreateDependencyListExport(pipelineID int, opt *CreateDependencyListExportOptions, options ...RequestOptionFunc) (*DependencyListExport, *Response, error) { + // POST /pipelines/:id/dependency_list_exports + createExportPath := fmt.Sprintf("pipelines/%d/dependency_list_exports", pipelineID) + + if opt == nil { + opt = &CreateDependencyListExportOptions{} + } + if opt.ExportType == nil { + opt.ExportType = Ptr(defaultExportType) + } + + req, err := s.client.NewRequest(http.MethodPost, createExportPath, opt, options) + if err != nil { + return nil, nil, err + } + + export := new(DependencyListExport) + resp, err := s.client.Do(req, &export) + if err != nil { + return nil, resp, err + } + + return export, resp, nil +} + +// GetDependencyListExport gets metadata about a single dependency list export. +// +// GitLab docs: +// https://docs.gitlab.com/ee/api/dependency_list_export.html#get-single-dependency-list-export +func (s *DependencyListExportService) GetDependencyListExport(id int, options ...RequestOptionFunc) (*DependencyListExport, *Response, error) { + // GET /dependency_list_exports/:id + getExportPath := fmt.Sprintf("dependency_list_exports/%d", id) + + req, err := s.client.NewRequest(http.MethodGet, getExportPath, nil, options) + if err != nil { + return nil, nil, err + } + + export := new(DependencyListExport) + resp, err := s.client.Do(req, &export) + if err != nil { + return nil, resp, err + } + + return export, resp, nil +} + +// DownloadDependencyListExport downloads a single dependency list export. +// +// The github.com/CycloneDX/cyclonedx-go package can be used to parse the data from the returned io.Reader. +// +// sbom := new(cdx.BOM) +// decoder := cdx.NewBOMDecoder(reader, cdx.BOMFileFormatJSON) +// +// if err = decoder.Decode(sbom); err != nil { +// panic(err) +// } +// +// GitLab docs: +// https://docs.gitlab.com/ee/api/dependency_list_export.html#download-dependency-list-export +func (s *DependencyListExportService) DownloadDependencyListExport(id int, options ...RequestOptionFunc) (io.Reader, *Response, error) { + // GET /dependency_list_exports/:id/download + downloadExportPath := fmt.Sprintf("dependency_list_exports/%d/download", id) + + req, err := s.client.NewRequest(http.MethodGet, downloadExportPath, nil, options) + if err != nil { + return nil, nil, err + } + + var sbomBuffer bytes.Buffer + resp, err := s.client.Do(req, &sbomBuffer) + if err != nil { + return nil, resp, err + } + + return &sbomBuffer, resp, nil +} diff --git a/dependency_list_export_test.go b/dependency_list_export_test.go new file mode 100644 index 000000000..c892c4cdd --- /dev/null +++ b/dependency_list_export_test.go @@ -0,0 +1,85 @@ +package gitlab + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateDependencyListExport(t *testing.T) { + mux, client := setup(t) + + mux.HandleFunc("/api/v4/pipelines/1234/dependency_list_exports", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodPost) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + + var content CreateDependencyListExportOptions + err = json.Unmarshal(body, &content) + require.NoError(t, err) + + assert.Equal(t, "sbom", *content.ExportType) + mustWriteHTTPResponse(t, w, "testdata/create_dependency_list_export.json") + }) + + d := &CreateDependencyListExportOptions{ + ExportType: Ptr("sbom"), + } + + export, _, err := client.DependencyListExport.CreateDependencyListExport(1234, d) + require.NoError(t, err) + + want := &DependencyListExport{ + ID: 5678, + HasFinished: false, + Self: "http://gitlab.example.com/api/v4/dependency_list_exports/5678", + Download: "http://gitlab.example.com/api/v4/dependency_list_exports/5678/download", + } + require.Equal(t, want, export) +} + +func TestGetDependencyListExport(t *testing.T) { + mux, client := setup(t) + + mux.HandleFunc("/api/v4/dependency_list_exports/5678", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + mustWriteHTTPResponse(t, w, "testdata/get_dependency_list_export.json") + }) + + export, _, err := client.DependencyListExport.GetDependencyListExport(5678) + require.NoError(t, err) + + want := &DependencyListExport{ + ID: 5678, + HasFinished: true, + Self: "http://gitlab.example.com/api/v4/dependency_list_exports/5678", + Download: "http://gitlab.example.com/api/v4/dependency_list_exports/5678/download", + } + require.Equal(t, want, export) +} + +func TestDownloadDependencyListExport(t *testing.T) { + mux, client := setup(t) + + mux.HandleFunc("/api/v4/dependency_list_exports/5678/download", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + mustWriteHTTPResponse(t, w, "testdata/download_dependency_list_export.json") + }) + + sbomReader, _, err := client.DependencyListExport.DownloadDependencyListExport(5678) + require.NoError(t, err) + + expectedSbom, err := os.ReadFile("testdata/download_dependency_list_export.json") + require.NoError(t, err) + + var want bytes.Buffer + want.Write(expectedSbom) + + require.Equal(t, &want, sbomReader) +} diff --git a/gitlab.go b/gitlab.go index 19ed3eadb..31b62bd5f 100644 --- a/gitlab.go +++ b/gitlab.go @@ -122,6 +122,7 @@ type Client struct { Commits *CommitsService ContainerRegistry *ContainerRegistryService CustomAttribute *CustomAttributesService + DependencyListExport *DependencyListExportService DeployKeys *DeployKeysService DeployTokens *DeployTokensService DeploymentMergeRequests *DeploymentMergeRequestsService @@ -360,6 +361,7 @@ func newClient(options ...ClientOptionFunc) (*Client, error) { c.Commits = &CommitsService{client: c} c.ContainerRegistry = &ContainerRegistryService{client: c} c.CustomAttribute = &CustomAttributesService{client: c} + c.DependencyListExport = &DependencyListExportService{client: c} c.DeployKeys = &DeployKeysService{client: c} c.DeployTokens = &DeployTokensService{client: c} c.DeploymentMergeRequests = &DeploymentMergeRequestsService{client: c} diff --git a/testdata/create_dependency_list_export.json b/testdata/create_dependency_list_export.json new file mode 100644 index 000000000..7b55e479e --- /dev/null +++ b/testdata/create_dependency_list_export.json @@ -0,0 +1,6 @@ +{ + "id": 5678, + "has_finished": false, + "self": "http://gitlab.example.com/api/v4/dependency_list_exports/5678", + "download": "http://gitlab.example.com/api/v4/dependency_list_exports/5678/download" +} diff --git a/testdata/download_dependency_list_export.json b/testdata/download_dependency_list_export.json new file mode 100644 index 000000000..49eccc38c --- /dev/null +++ b/testdata/download_dependency_list_export.json @@ -0,0 +1,31 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "serialNumber": "urn:uuid:3fa3b1c2-7e21-4dae-917b-b320f6d25ae1", + "version": 1, + "metadata": { + "timestamp": "2024-11-14T23:39:16.117Z", + "authors": [{ "name": "GitLab", "email": "support@gitlab.com" }], + "properties": [ + { + "name": "gitlab:dependency_scanning:input_file:path", + "value": "my_package_manager.lock" + }, + { + "name": "gitlab:dependency_scanning:package_manager:name", + "value": "my_package_manager" + }, + { "name": "gitlab:meta:schema_version", "value": "1" } + ], + "tools": [{ "vendor": "GitLab", "name": "Gemnasium", "version": "5.8.0" }] + }, + "components": [ + { + "name": "dummy", + "version": "1.0.0", + "purl": "pkg:testing/dummy@1.0.0", + "type": "library", + "licenses": [{ "license": { "name": "unknown" } }] + } + ] +} diff --git a/testdata/get_dependency_list_export.json b/testdata/get_dependency_list_export.json new file mode 100644 index 000000000..58a3b2fda --- /dev/null +++ b/testdata/get_dependency_list_export.json @@ -0,0 +1,6 @@ +{ + "id": 5678, + "has_finished": true, + "self": "http://gitlab.example.com/api/v4/dependency_list_exports/5678", + "download": "http://gitlab.example.com/api/v4/dependency_list_exports/5678/download" +}