From 25ccb633633455a72b81f52f9f186c19c9a83a4a Mon Sep 17 00:00:00 2001 From: filip Date: Thu, 26 Sep 2024 17:04:52 +0200 Subject: [PATCH] Initial sbom command --- internal/cmd/report/report.go | 4 + internal/cmd/report/report_test.go | 8 +- internal/cmd/report/sbom/sbom.go | 55 ++++++++++ internal/cmd/report/sbom/sbom_test.go | 63 ++++++++++++ internal/cmd/root/root.go | 2 +- internal/report/sbom/report.go | 143 ++++++++++++++++++++++++++ internal/report/sbom/report_test.go | 122 ++++++++++++++++++++++ internal/wire/cli_container.go | 7 ++ 8 files changed, 400 insertions(+), 4 deletions(-) create mode 100644 internal/cmd/report/sbom/sbom.go create mode 100644 internal/cmd/report/sbom/sbom_test.go create mode 100644 internal/report/sbom/report.go create mode 100644 internal/report/sbom/report_test.go diff --git a/internal/cmd/report/report.go b/internal/cmd/report/report.go index 60bcb458..f4ba3b06 100644 --- a/internal/cmd/report/report.go +++ b/internal/cmd/report/report.go @@ -2,8 +2,10 @@ package report import ( "github.com/debricked/cli/internal/cmd/report/license" + "github.com/debricked/cli/internal/cmd/report/sbom" "github.com/debricked/cli/internal/cmd/report/vulnerability" licenseReport "github.com/debricked/cli/internal/report/license" + sbomReport "github.com/debricked/cli/internal/report/sbom" vulnerabilityReport "github.com/debricked/cli/internal/report/vulnerability" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -12,6 +14,7 @@ import ( func NewReportCmd( licenseReporter licenseReport.Reporter, vulnerabilityReporter vulnerabilityReport.Reporter, + sbomReporter sbomReport.Reporter, ) *cobra.Command { cmd := &cobra.Command{ Use: "report", @@ -25,6 +28,7 @@ This is a premium feature. Please visit https://debricked.com/pricing/ for more cmd.AddCommand(license.NewLicenseCmd(licenseReporter)) cmd.AddCommand(vulnerability.NewVulnerabilityCmd(vulnerabilityReporter)) + cmd.AddCommand(sbom.NewSBOMCmd(sbomReporter)) return cmd } diff --git a/internal/cmd/report/report_test.go b/internal/cmd/report/report_test.go index 19de982c..084826e3 100644 --- a/internal/cmd/report/report_test.go +++ b/internal/cmd/report/report_test.go @@ -4,20 +4,22 @@ import ( "testing" "github.com/debricked/cli/internal/report/license" + "github.com/debricked/cli/internal/report/sbom" "github.com/debricked/cli/internal/report/vulnerability" "github.com/stretchr/testify/assert" ) func TestNewReportCmd(t *testing.T) { - cmd := NewReportCmd(license.Reporter{}, vulnerability.Reporter{}) + cmd := NewReportCmd(license.Reporter{}, vulnerability.Reporter{}, sbom.Reporter{}) commands := cmd.Commands() - nbrOfCommands := 2 + nbrOfCommands := 3 assert.Lenf(t, commands, nbrOfCommands, "failed to assert that there were %d sub commands connected", nbrOfCommands) } func TestPreRun(t *testing.T) { var licenseReporter license.Reporter var vulnReporter vulnerability.Reporter - cmd := NewReportCmd(licenseReporter, vulnReporter) + var sbomReporter sbom.Reporter + cmd := NewReportCmd(licenseReporter, vulnReporter, sbomReporter) cmd.PreRun(cmd, nil) } diff --git a/internal/cmd/report/sbom/sbom.go b/internal/cmd/report/sbom/sbom.go new file mode 100644 index 00000000..8d223ac2 --- /dev/null +++ b/internal/cmd/report/sbom/sbom.go @@ -0,0 +1,55 @@ +package sbom + +import ( + "fmt" + + "github.com/debricked/cli/internal/report" + "github.com/debricked/cli/internal/report/sbom" + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var commitId string +var repositoryId string + +const CommitFlag = "commit" +const RepositorylFlag = "repository" +const TokenFlag = "token" + +func NewSBOMCmd(reporter report.IReporter) *cobra.Command { + cmd := &cobra.Command{ + Use: "sbom", + Short: "Generate SBOM report", + Long: `Generate SBOM report for chosen commit and repository. +This is an enterprise feature. Please visit https://debricked.com/pricing/ for more info.`, + PreRun: func(cmd *cobra.Command, _ []string) { + _ = viper.BindPFlags(cmd.Flags()) + }, + RunE: RunE(reporter), + } + + cmd.Flags().StringVarP(&commitId, CommitFlag, "c", "", "The commit that you want an SBOM report for") + _ = cmd.MarkFlagRequired(CommitFlag) + viper.MustBindEnv(CommitFlag) + cmd.Flags().StringVarP(&repositoryId, RepositorylFlag, "r", "", "The repository that you want an SBOM report for") + _ = cmd.MarkFlagRequired(RepositorylFlag) + viper.MustBindEnv(RepositorylFlag) + + return cmd +} + +func RunE(r report.IReporter) func(_ *cobra.Command, args []string) error { + return func(_ *cobra.Command, _ []string) error { + orderArgs := sbom.OrderArgs{ + RepositoryID: viper.GetString(RepositorylFlag), + CommitID: viper.GetString(CommitFlag), + } + + if err := r.Order(orderArgs); err != nil { + return fmt.Errorf("%s %s", color.RedString("⨯"), err.Error()) + } + + return nil + } +} diff --git a/internal/cmd/report/sbom/sbom_test.go b/internal/cmd/report/sbom/sbom_test.go new file mode 100644 index 00000000..971275ca --- /dev/null +++ b/internal/cmd/report/sbom/sbom_test.go @@ -0,0 +1,63 @@ +package sbom + +import ( + "errors" + "testing" + + "github.com/debricked/cli/internal/cmd/report/testdata" + "github.com/debricked/cli/internal/report" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" +) + +func TestNewSBOMCmd(t *testing.T) { + var r report.IReporter + cmd := NewSBOMCmd(r) + commands := cmd.Commands() + nbrOfCommands := 0 + assert.Len(t, commands, nbrOfCommands) + + viperKeys := viper.AllKeys() + flags := cmd.Flags() + flagAssertions := map[string]string{ + CommitFlag: "c", + RepositorylFlag: "r", + } + for name, shorthand := range flagAssertions { + flag := flags.Lookup(name) + assert.NotNil(t, flag) + assert.Equalf(t, shorthand, flag.Shorthand, "failed to assert that %s flag shorthand %s was set correctly", name, shorthand) + + match := false + for _, key := range viperKeys { + if key == name { + match = true + } + } + assert.Truef(t, match, "failed to assert that %s was present", name) + } +} + +func TestRunEError(t *testing.T) { + reporterMock := testdata.NewReporterMock() + reporterMock.SetError(errors.New("")) + runeE := RunE(reporterMock) + + err := runeE(nil, nil) + + assert.ErrorContains(t, err, "⨯") +} + +func TestRunE(t *testing.T) { + reporterMock := testdata.NewReporterMock() + runeE := RunE(reporterMock) + + err := runeE(nil, nil) + + assert.NoError(t, err) +} + +func TestPreRun(t *testing.T) { + cmd := NewSBOMCmd(nil) + cmd.PreRun(cmd, nil) +} diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index a98177f3..51a9c481 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -42,7 +42,7 @@ Read more: https://docs.debricked.com/product/administration/generate-access-tok var debClient = container.DebClient() debClient.SetAccessToken(&accessToken) - rootCmd.AddCommand(report.NewReportCmd(container.LicenseReporter(), container.VulnerabilityReporter())) + rootCmd.AddCommand(report.NewReportCmd(container.LicenseReporter(), container.VulnerabilityReporter(), container.SBOMReporter())) rootCmd.AddCommand(files.NewFilesCmd(container.Finder())) rootCmd.AddCommand(scan.NewScanCmd(container.Scanner())) rootCmd.AddCommand(fingerprint.NewFingerprintCmd(container.Fingerprinter())) diff --git a/internal/report/sbom/report.go b/internal/report/sbom/report.go new file mode 100644 index 00000000..4553c6be --- /dev/null +++ b/internal/report/sbom/report.go @@ -0,0 +1,143 @@ +package sbom + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/debricked/cli/internal/client" + "github.com/debricked/cli/internal/report" +) + +var ( + ErrHandleArgs = errors.New("failed to handle args") + ErrSubscription = errors.New("premium feature. Please visit https://debricked.com/pricing/ for more info") +) + +type generateSbom struct { + Format string `json:"format"` + RepositoryID string `json:"repositoryId"` + IntegrationName string `json:"integrationName"` + CommitID string `json:"commitId"` + Email string `json:"email"` + Branch string `json:"branch"` + Locale string `json:"locale"` + Licenses bool `json:licenses` + Vulnerabilities bool `json:"vulnerabilities"` + SendEmail bool `json:sendEmail` + VulnerabilityStatuses []string `json:vulnerabilityStatuses` +} + +type generateSbomResponse struct { + Message string `json:message` + ReportUUID string `json:reportUuid` + Notes []string `json:notes` +} + +type OrderArgs struct { + RepositoryID string + CommitID string + Token string +} + +type Reporter struct { + DebClient client.IDebClient +} + +func (r Reporter) Order(args report.IOrderArgs) error { + orderArgs, ok := args.(OrderArgs) + if !ok { + return ErrHandleArgs + } + uuid, err := r.generate(orderArgs.CommitID, orderArgs.RepositoryID) + if err != nil { + return err + } + sbomJSON, err := r.download(uuid) + if err != nil { + return err + } + + fmt.Print(sbomJSON) + + return nil + +} + +func (r Reporter) generate(commitID, repositoryID string) (string, error) { + // Tries to start generating an SBOM and returns the UUID for the report + body, err := json.Marshal(generateSbom{ + Format: "CycloneDX", + RepositoryID: repositoryID, + CommitID: commitID, + Email: "", + Branch: "master", // Probably current branch or specified + Locale: "en", + Vulnerabilities: false, + Licenses: false, + SendEmail: false, + VulnerabilityStatuses: []string{"vulnerable", "unexamined", "paused", "snoozed"}, + }) + + if err != nil { + return "", err + } + + response, err := (r.DebClient).Post( + "/api/1.0/open/sbom/generate", + "application/json", + bytes.NewBuffer(body), + 0, + ) + if err != nil { + return "", err + } + defer response.Body.Close() + if response.StatusCode == http.StatusPaymentRequired { + return "", ErrSubscription + } else if response.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to initialize SBOM generation due to status code %d", response.StatusCode) + } else { + fmt.Println("Successfully initialized SBOM generation") + } + generateSbomResponseJSON, err := io.ReadAll(response.Body) + if err != nil { + return "", err + } + + var generateSbomResponse generateSbomResponse + err = json.Unmarshal(generateSbomResponseJSON, &generateSbomResponse) + if err != nil { + return "", err + } + + return generateSbomResponse.ReportUUID, nil +} + +func (r Reporter) download(uuid string) (string, error) { + uri := fmt.Sprintf("/api/1.0/open/sbom/download?reportUuid=%s", uuid) + fmt.Println("Trying to download SBOM, will wait if not yet ready (could take up to 1 minute)") + for { // poll download status until completion + res, err := (r.DebClient).Get(uri, "application/json") + if err != nil { + return "", err + } + switch statusCode := res.StatusCode; statusCode { + case http.StatusOK: + data, _ := io.ReadAll(res.Body) + defer res.Body.Close() + + return string(data), nil + case http.StatusCreated: + return "", errors.New("polling failed due to too long queue times") + case http.StatusAccepted: + time.Sleep(3000 * time.Millisecond) + default: + return "", fmt.Errorf("download failed with status code %d", res.StatusCode) + } + } +} diff --git a/internal/report/sbom/report_test.go b/internal/report/sbom/report_test.go new file mode 100644 index 00000000..bc5dc967 --- /dev/null +++ b/internal/report/sbom/report_test.go @@ -0,0 +1,122 @@ +package sbom + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/debricked/cli/internal/client/testdata" + "github.com/stretchr/testify/assert" +) + +func TestOrderError(t *testing.T) { + debClientMock := testdata.NewDebClientMock() + debClientMock.AddMockResponse(testdata.MockResponse{StatusCode: http.StatusOK}) + reporter := Reporter{DebClient: debClientMock} + args := OrderArgs{CommitID: "", RepositoryID: ""} + err := reporter.Order(args) + assert.Error(t, err) +} + +func TestOrder(t *testing.T) { + debClientMock := testdata.NewDebClientMock() + debClientMock.AddMockResponse(testdata.MockResponse{ + StatusCode: http.StatusOK, + ResponseBody: io.NopCloser(strings.NewReader("{}")), + }) + debClientMock.AddMockResponse(testdata.MockResponse{ + StatusCode: http.StatusOK, + ResponseBody: io.NopCloser(strings.NewReader("{}")), + }) + reporter := Reporter{DebClient: debClientMock} + args := OrderArgs{CommitID: "", RepositoryID: ""} + err := reporter.Order(args) + assert.NoError(t, err) +} + +func TestOrderDownloadErr(t *testing.T) { + debClientMock := testdata.NewDebClientMock() + debClientMock.AddMockResponse(testdata.MockResponse{ + StatusCode: http.StatusOK, + ResponseBody: io.NopCloser(strings.NewReader("{}")), + }) + debClientMock.AddMockResponse(testdata.MockResponse{ + StatusCode: http.StatusForbidden, + }) + reporter := Reporter{DebClient: debClientMock} + args := OrderArgs{CommitID: "", RepositoryID: ""} + err := reporter.Order(args) + assert.Error(t, err) +} + +func TestOrderArgsError(t *testing.T) { + debClientMock := testdata.NewDebClientMock() + debClientMock.AddMockResponse(testdata.MockResponse{StatusCode: http.StatusOK}) + reporter := Reporter{DebClient: debClientMock} + err := reporter.Order("") + assert.Error(t, err) +} + +func TestGenerateOK(t *testing.T) { + debClientMock := testdata.NewDebClientMock() + debClientMock.AddMockResponse(testdata.MockResponse{ + StatusCode: http.StatusOK, + ResponseBody: io.NopCloser(strings.NewReader("{}")), + }) + reporter := Reporter{DebClient: debClientMock} + uuid, err := reporter.generate("", "") + assert.NoError(t, err) + assert.NotNil(t, uuid) +} + +func TestGenerateSubscriptionError(t *testing.T) { + debClientMock := testdata.NewDebClientMock() + debClientMock.AddMockResponse(testdata.MockResponse{ + StatusCode: http.StatusPaymentRequired, + ResponseBody: io.NopCloser(strings.NewReader("{}")), + }) + reporter := Reporter{DebClient: debClientMock} + uuid, err := reporter.generate("", "") + assert.Error(t, err) + assert.NotNil(t, uuid) +} + +func TestGenerateError(t *testing.T) { + debClientMock := testdata.NewDebClientMock() + debClientMock.AddMockResponse(testdata.MockResponse{ + StatusCode: http.StatusForbidden, + ResponseBody: io.NopCloser(strings.NewReader("{}")), + }) + reporter := Reporter{DebClient: debClientMock} + uuid, err := reporter.generate("", "") + assert.Error(t, err) + assert.NotNil(t, uuid) +} + +func TestDownloadOK(t *testing.T) { + debClientMock := testdata.NewDebClientMock() + debClientMock.AddMockResponse(testdata.MockResponse{StatusCode: http.StatusOK}) + reporter := Reporter{DebClient: debClientMock} + res, err := reporter.download("") + assert.NoError(t, err) + assert.NotNil(t, res) +} + +func TestDownloadTooLongQueue(t *testing.T) { + debClientMock := testdata.NewDebClientMock() + debClientMock.AddMockResponse(testdata.MockResponse{StatusCode: http.StatusCreated}) + reporter := Reporter{DebClient: debClientMock} + res, err := reporter.download("") + assert.Error(t, err) + assert.NotNil(t, res) +} + +func TestDownloadDefaultError(t *testing.T) { + debClientMock := testdata.NewDebClientMock() + debClientMock.AddMockResponse(testdata.MockResponse{StatusCode: http.StatusForbidden}) + reporter := Reporter{DebClient: debClientMock} + res, err := reporter.download("") + assert.Error(t, err) + assert.NotNil(t, res) +} diff --git a/internal/wire/cli_container.go b/internal/wire/cli_container.go index f232f16a..d0859c68 100644 --- a/internal/wire/cli_container.go +++ b/internal/wire/cli_container.go @@ -12,6 +12,7 @@ import ( "github.com/debricked/cli/internal/fingerprint" "github.com/debricked/cli/internal/io" licenseReport "github.com/debricked/cli/internal/report/license" + sbomReport "github.com/debricked/cli/internal/report/sbom" vulnerabilityReport "github.com/debricked/cli/internal/report/vulnerability" "github.com/debricked/cli/internal/resolution" resolutionFile "github.com/debricked/cli/internal/resolution/file" @@ -92,6 +93,7 @@ func (cc *CliContainer) wire() error { cc.licenseReporter = licenseReport.Reporter{DebClient: cc.debClient} cc.vulnerabilityReporter = vulnerabilityReport.Reporter{DebClient: cc.debClient} + cc.sbomReporter = sbomReport.Reporter{DebClient: cc.debClient} cc.authenticator = auth.NewDebrickedAuthenticator(cc.debClient) return nil @@ -111,6 +113,7 @@ type CliContainer struct { batchFactory resolutionFile.IBatchFactory licenseReporter licenseReport.Reporter vulnerabilityReporter vulnerabilityReport.Reporter + sbomReporter sbomReport.Reporter callgraph callgraph.IGenerator cgScheduler callgraph.IScheduler cgStrategyFactory callgraphStrategy.IFactory @@ -145,6 +148,10 @@ func (cc *CliContainer) VulnerabilityReporter() vulnerabilityReport.Reporter { return cc.vulnerabilityReporter } +func (cc *CliContainer) SBOMReporter() sbomReport.Reporter { + return cc.sbomReporter +} + func (cc *CliContainer) Fingerprinter() fingerprint.IFingerprint { return cc.fingerprinter }