diff --git a/cmd/upload_sbom.go b/cmd/upload_sbom.go new file mode 100644 index 00000000..f8e07d33 --- /dev/null +++ b/cmd/upload_sbom.go @@ -0,0 +1,223 @@ +package cmd + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + + "codacy/cli-v2/utils/logger" + + "github.com/fatih/color" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +var ( + sbomAPIToken string + sbomProvider string + sbomOrg string + sbomImageName string + sbomTag string + sbomRepoName string + sbomEnv string + sbomFormat string +) + +func init() { + uploadSBOMCmd.Flags().StringVarP(&sbomAPIToken, "api-token", "a", "", "API token for Codacy API (required)") + uploadSBOMCmd.Flags().StringVarP(&sbomProvider, "provider", "p", "", "Git provider (gh, gl, bb) (required)") + uploadSBOMCmd.Flags().StringVarP(&sbomOrg, "organization", "o", "", "Organization name on the Git provider (required)") + uploadSBOMCmd.Flags().StringVarP(&sbomTag, "tag", "t", "", "Docker image tag (defaults to image tag or 'latest')") + uploadSBOMCmd.Flags().StringVarP(&sbomRepoName, "repository", "r", "", "Repository name (optional)") + uploadSBOMCmd.Flags().StringVarP(&sbomEnv, "environment", "e", "", "Environment where the image is deployed (optional)") + uploadSBOMCmd.Flags().StringVar(&sbomFormat, "format", "cyclonedx", "SBOM format: cyclonedx or spdx-json (default cyclonedx, smaller output)") + + uploadSBOMCmd.MarkFlagRequired("api-token") + uploadSBOMCmd.MarkFlagRequired("provider") + uploadSBOMCmd.MarkFlagRequired("organization") + + rootCmd.AddCommand(uploadSBOMCmd) +} + +var uploadSBOMCmd = &cobra.Command{ + Use: "upload-sbom ", + Short: "Generate and upload an SBOM for a Docker image to Codacy", + Long: `Generate an SBOM (Software Bill of Materials) for a Docker image using Trivy +and upload it to Codacy for vulnerability tracking. + +By default, Trivy generates a CycloneDX SBOM (smaller output). Use --format +to switch to spdx-json if needed. Both formats are accepted by the Codacy API.`, + Example: ` # Generate and upload SBOM + codacy-cli upload-sbom -a -p gh -o my-org -r my-repo myapp:latest + + # Use SPDX format instead + codacy-cli upload-sbom -a -p gh -o my-org -r my-repo --format spdx-json myapp:v1.0.0`, + Args: cobra.ExactArgs(1), + Run: runUploadSBOM, +} + +func runUploadSBOM(_ *cobra.Command, args []string) { + exitCode := executeUploadSBOM(args[0]) + exitFunc(exitCode) +} + +// executeUploadSBOM generates (or reads) an SBOM and uploads it to Codacy. Returns exit code. +func executeUploadSBOM(imageRef string) int { + if err := validateImageName(imageRef); err != nil { + logger.Error("Invalid image name", logrus.Fields{"image": imageRef, "error": err.Error()}) + color.Red("Error: %v", err) + return 2 + } + + if sbomFormat != "cyclonedx" && sbomFormat != "spdx-json" { + color.Red("Error: --format must be 'cyclonedx' or 'spdx-json'") + return 2 + } + + imageName, tag := parseImageRef(imageRef) + if sbomTag != "" { + tag = sbomTag + } + sbomImageName = imageName + + logger.Info("Starting SBOM upload", logrus.Fields{ + "image": imageRef, + "provider": sbomProvider, + "org": sbomOrg, + }) + + // Generate SBOM with Trivy + trivyPath, err := getTrivyPath() + if err != nil { + handleTrivyNotFound(err) + return 2 + } + + tmpFile, err := os.CreateTemp("", "codacy-sbom-*") + if err != nil { + logger.Error("Failed to create temp file", logrus.Fields{"error": err.Error()}) + color.Red("Error: Failed to create temporary file: %v", err) + return 2 + } + tmpFile.Close() + sbomPath := tmpFile.Name() + defer os.Remove(sbomPath) + + fmt.Printf("Generating SBOM for image: %s\n", imageRef) + args := []string{"image", "--format", sbomFormat, "-o", sbomPath, imageRef} + logger.Info("Running Trivy SBOM generation", logrus.Fields{"command": fmt.Sprintf("%s %v", trivyPath, args)}) + + var stderrBuf bytes.Buffer + if err := commandRunner.RunWithStderr(trivyPath, args, &stderrBuf); err != nil { + if isScanFailure(stderrBuf.Bytes()) { + color.Red("Error: Failed to generate SBOM (image not found or no container runtime)") + } else { + color.Red("Error: Failed to generate SBOM: %v", err) + } + logger.Error("Trivy SBOM generation failed", logrus.Fields{"error": err.Error()}) + return 2 + } + fmt.Println("SBOM generated successfully") + + // Upload SBOM to Codacy + fmt.Printf("Uploading SBOM to Codacy (org: %s/%s)...\n", sbomProvider, sbomOrg) + if err := uploadSBOMToCodacy(sbomPath, sbomImageName, tag); err != nil { + logger.Error("Failed to upload SBOM", logrus.Fields{"error": err.Error()}) + color.Red("Error: Failed to upload SBOM: %v", err) + return 1 + } + + color.Green("Successfully uploaded SBOM for %s:%s", sbomImageName, tag) + return 0 +} + +// parseImageRef splits an image reference into name and tag. +// e.g. "myapp:v1.0.0" -> ("myapp", "v1.0.0"), "myapp" -> ("myapp", "latest") +func parseImageRef(imageRef string) (string, string) { + // Handle digest references (image@sha256:...) + if idx := strings.Index(imageRef, "@"); idx != -1 { + return imageRef[:idx], imageRef[idx+1:] + } + + // Find the last colon that is part of the tag (not the registry port) + lastSlash := strings.LastIndex(imageRef, "/") + tagPart := imageRef + if lastSlash != -1 { + tagPart = imageRef[lastSlash:] + } + + if idx := strings.LastIndex(tagPart, ":"); idx != -1 { + absIdx := idx + if lastSlash != -1 { + absIdx = lastSlash + idx + } + return imageRef[:absIdx], imageRef[absIdx+1:] + } + + return imageRef, "latest" +} + +func uploadSBOMToCodacy(sbomPath, imageName, tag string) error { + url := fmt.Sprintf("https://app.codacy.com/api/v3/organizations/%s/%s/image-sboms", + sbomProvider, sbomOrg) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + // Add the SBOM file + sbomFile, err := os.Open(sbomPath) + if err != nil { + return fmt.Errorf("failed to open SBOM file: %w", err) + } + defer sbomFile.Close() + + part, err := writer.CreateFormFile("sbom", filepath.Base(sbomPath)) + if err != nil { + return fmt.Errorf("failed to create form file: %w", err) + } + if _, err := io.Copy(part, sbomFile); err != nil { + return fmt.Errorf("failed to write SBOM to form: %w", err) + } + + // Add required fields + writer.WriteField("imageName", imageName) + writer.WriteField("tag", tag) + + // Add optional fields + if sbomRepoName != "" { + writer.WriteField("repositoryName", sbomRepoName) + } + if sbomEnv != "" { + writer.WriteField("environment", sbomEnv) + } + + if err := writer.Close(); err != nil { + return fmt.Errorf("failed to close multipart writer: %w", err) + } + + req, err := http.NewRequest("POST", url, body) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Accept", "application/json") + req.Header.Set("api-token", sbomAPIToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(respBody)) + } + + return nil +} diff --git a/cmd/upload_sbom_test.go b/cmd/upload_sbom_test.go new file mode 100644 index 00000000..4870b57f --- /dev/null +++ b/cmd/upload_sbom_test.go @@ -0,0 +1,205 @@ +package cmd + +import ( + "errors" + "io" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +type sbomTestState struct { + apiToken string + provider string + org string + repoName string + env string + tag string + format string +} + +func saveSBOMState() sbomTestState { + return sbomTestState{ + apiToken: sbomAPIToken, + provider: sbomProvider, + org: sbomOrg, + repoName: sbomRepoName, + env: sbomEnv, + tag: sbomTag, + format: sbomFormat, + } +} + +func (s sbomTestState) restore() { + sbomAPIToken = s.apiToken + sbomProvider = s.provider + sbomOrg = s.org + sbomRepoName = s.repoName + sbomEnv = s.env + sbomTag = s.tag + sbomFormat = s.format +} + +// setSBOMDefaults sets the minimum required SBOM globals for tests +func setSBOMDefaults() { + sbomProvider = "gh" + sbomOrg = "test-org" + sbomAPIToken = "test-token" + sbomRepoName = "" + sbomEnv = "" + sbomTag = "" + sbomFormat = "cyclonedx" +} + +func TestParseImageRef(t *testing.T) { + tests := []struct { + input string + wantName string + wantTag string + }{ + {"myapp:latest", "myapp", "latest"}, + {"myapp:v1.0.0", "myapp", "v1.0.0"}, + {"myapp", "myapp", "latest"}, + {"ghcr.io/codacy/app:v2", "ghcr.io/codacy/app", "v2"}, + {"registry.example.com:5000/myapp:tag", "registry.example.com:5000/myapp", "tag"}, + {"nginx@sha256:abc123", "nginx", "sha256:abc123"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + name, tag := parseImageRef(tt.input) + assert.Equal(t, tt.wantName, name) + assert.Equal(t, tt.wantTag, tag) + }) + } +} + +func TestExecuteUploadSBOM_InvalidImage(t *testing.T) { + state := saveState() + defer state.restore() + + exitCode := executeUploadSBOM("nginx;rm -rf /") + assert.Equal(t, 2, exitCode) +} + +func TestExecuteUploadSBOM_InvalidFormat(t *testing.T) { + state := saveState() + defer state.restore() + ss := saveSBOMState() + defer ss.restore() + + setSBOMDefaults() + sbomFormat = "invalid-format" + + exitCode := executeUploadSBOM("alpine:latest") + assert.Equal(t, 2, exitCode) +} + +func TestExecuteUploadSBOM_TrivyNotFound(t *testing.T) { + state := saveState() + defer state.restore() + ss := saveSBOMState() + defer ss.restore() + + var capturedExitCode int + exitFunc = func(code int) { + capturedExitCode = code + } + + getTrivyPathResolver = func() (string, error) { + return "", errors.New("trivy not found") + } + setSBOMDefaults() + + exitCode := executeUploadSBOM("alpine:latest") + assert.Equal(t, 2, capturedExitCode) + assert.Equal(t, 2, exitCode) +} + +func TestExecuteUploadSBOM_TrivyGenerationFails(t *testing.T) { + state := saveState() + defer state.restore() + ss := saveSBOMState() + defer ss.restore() + + getTrivyPathResolver = func() (string, error) { + return "/usr/local/bin/trivy", nil + } + + mockRunner := &MockCommandRunner{ + RunWithStderrFunc: func(_ string, _ []string, stderr io.Writer) error { + if stderr != nil { + _, _ = stderr.Write([]byte("FATAL Fatal error")) + } + return &mockExitError{code: 1} + }, + } + commandRunner = mockRunner + setSBOMDefaults() + + exitCode := executeUploadSBOM("alpine:latest") + assert.Equal(t, 2, exitCode) +} + + +func TestExecuteUploadSBOM_TrivyCalledWithCorrectFormat(t *testing.T) { + formats := []string{"cyclonedx", "spdx-json"} + + for _, format := range formats { + t.Run(format, func(t *testing.T) { + state := saveState() + defer state.restore() + ss := saveSBOMState() + defer ss.restore() + + getTrivyPathResolver = func() (string, error) { + return "/usr/local/bin/trivy", nil + } + + mockRunner := &MockCommandRunner{ + RunWithStderrFunc: func(_ string, args []string, _ io.Writer) error { + for i, arg := range args { + if arg == "-o" && i+1 < len(args) { + os.WriteFile(args[i+1], []byte(`{}`), 0644) + break + } + } + return nil + }, + } + commandRunner = mockRunner + setSBOMDefaults() + sbomFormat = format + + // Will fail at upload (no real API), but we can verify Trivy args + _ = executeUploadSBOM("alpine:latest") + + assert.Len(t, mockRunner.Calls, 1) + assert.Contains(t, mockRunner.Calls[0].Args, "--format") + assert.Contains(t, mockRunner.Calls[0].Args, format) + }) + } +} + +func TestUploadSBOMToCodacy_FileNotFound(t *testing.T) { + err := uploadSBOMToCodacy("/nonexistent/file.json", "myapp", "latest") + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to open SBOM file") +} + +func TestUploadSBOMSkipsValidation(t *testing.T) { + result := shouldSkipValidation("upload-sbom") + assert.True(t, result, "upload-sbom should skip validation") +} + +func TestUploadSBOMCommandRequiresArg(t *testing.T) { + err := uploadSBOMCmd.Args(uploadSBOMCmd, []string{}) + assert.Error(t, err, "Should error when no args provided") + + err = uploadSBOMCmd.Args(uploadSBOMCmd, []string{"myapp:latest"}) + assert.NoError(t, err, "Should accept single image") + + err = uploadSBOMCmd.Args(uploadSBOMCmd, []string{"img1", "img2"}) + assert.Error(t, err, "Should error when multiple args provided") +} diff --git a/cmd/validation.go b/cmd/validation.go index ea3cea74..8936222e 100644 --- a/cmd/validation.go +++ b/cmd/validation.go @@ -84,6 +84,7 @@ func shouldSkipValidation(cmdName string) bool { "codacy-cli", // root command when called without subcommands "update", "container-scan", // container scanning doesn't need codacy.yaml + "upload-sbom", // SBOM upload doesn't need codacy.yaml } for _, skipCmd := range skipCommands { diff --git a/config/config.go b/config/config.go index 12e69a1f..2ca36805 100644 --- a/config/config.go +++ b/config/config.go @@ -442,3 +442,5 @@ func (c *ConfigType) GetCliMode() (string, error) { return currentCliMode, nil } + +