From a7fc362b866d0f34aaa591e311d2afd1d9585d7c Mon Sep 17 00:00:00 2001 From: franciscoazevedo Date: Tue, 7 Apr 2026 17:10:43 +0100 Subject: [PATCH 1/2] generate and upload trivy SBOM into codacy --- cmd/upload_sbom.go | 223 ++++++++++++++++++++++++++++++++++++++++ cmd/upload_sbom_test.go | 205 ++++++++++++++++++++++++++++++++++++ cmd/validation.go | 1 + config/config.go | 2 + 4 files changed, 431 insertions(+) create mode 100644 cmd/upload_sbom.go create mode 100644 cmd/upload_sbom_test.go 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 } + + From 36cd083c4c2925fc891928aa4f9c1484300db9fc Mon Sep 17 00:00:00 2001 From: franciscoazevedo Date: Wed, 8 Apr 2026 10:20:00 +0100 Subject: [PATCH 2/2] fix it:tests and codacy review warning fix --- cmd/upload_sbom.go | 170 +++++++++++++----- cmd/upload_sbom_test.go | 111 ++++++++++-- config/config.go | 2 - .../init-with-token/expected/codacy.yaml | 2 +- plugins/tools/trivy/test/expected.sarif | 82 +++++++-- tools/lizard/lizardConfigCreator.go | 2 +- 6 files changed, 285 insertions(+), 84 deletions(-) diff --git a/cmd/upload_sbom.go b/cmd/upload_sbom.go index f8e07d33..6fd2181a 100644 --- a/cmd/upload_sbom.go +++ b/cmd/upload_sbom.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "time" "codacy/cli-v2/utils/logger" @@ -25,9 +26,17 @@ var ( sbomTag string sbomRepoName string sbomEnv string - sbomFormat string + sbomFormat string + sbomBaseURL string + + sbomHTTPClient httpDoer = &http.Client{Timeout: 5 * time.Minute} ) +// httpDoer abstracts the Do method of http.Client for testing. +type httpDoer interface { + Do(req *http.Request) (*http.Response, error) +} + 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)") @@ -80,33 +89,71 @@ func executeUploadSBOM(imageRef string) int { } imageName, tag := parseImageRef(imageRef) + isDigest := strings.Contains(imageRef, "@") + if sbomTag != "" { + if isDigest { + color.Red("Error: --tag cannot be used with digest references (image@sha256:...)") + return 2 + } tag = sbomTag } sbomImageName = imageName + var effectiveImageRef string + if isDigest { + effectiveImageRef = fmt.Sprintf("%s@%s", imageName, tag) + } else { + effectiveImageRef = fmt.Sprintf("%s:%s", imageName, tag) + } + logger.Info("Starting SBOM upload", logrus.Fields{ - "image": imageRef, + "image": effectiveImageRef, "provider": sbomProvider, "org": sbomOrg, }) - // Generate SBOM with Trivy + sbomPath, err := generateSBOM(effectiveImageRef) + if err != nil { + return 2 + } + defer os.Remove(sbomPath) + + fmt.Printf("Uploading SBOM to Codacy (org: %s/%s)...\n", sbomProvider, sbomOrg) + params := sbomUploadParams{ + provider: sbomProvider, + org: sbomOrg, + apiToken: sbomAPIToken, + repoName: sbomRepoName, + env: sbomEnv, + baseURL: sbomBaseURL, + } + if err := uploadSBOMToCodacy(sbomPath, sbomImageName, tag, params); 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", effectiveImageRef) + return 0 +} + +// generateSBOM runs Trivy to generate an SBOM file and returns the path to it. +func generateSBOM(imageRef string) (string, error) { trivyPath, err := getTrivyPath() if err != nil { handleTrivyNotFound(err) - return 2 + return "", err } 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 + return "", err } 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} @@ -120,20 +167,11 @@ func executeUploadSBOM(imageRef string) int { color.Red("Error: Failed to generate SBOM: %v", err) } logger.Error("Trivy SBOM generation failed", logrus.Fields{"error": err.Error()}) - return 2 + os.Remove(sbomPath) + return "", err } 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 + return sbomPath, nil } // parseImageRef splits an image reference into name and tag. @@ -162,38 +200,31 @@ func parseImageRef(imageRef string) (string, string) { 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) +type sbomUploadParams struct { + provider string + org string + apiToken string + repoName string + env string + baseURL string +} - // Add the SBOM file - sbomFile, err := os.Open(sbomPath) - if err != nil { - return fmt.Errorf("failed to open SBOM file: %w", err) +func (p sbomUploadParams) uploadURL() string { + base := p.baseURL + if base == "" { + base = "https://app.codacy.com" } - defer sbomFile.Close() + return fmt.Sprintf("%s/api/v3/organizations/%s/%s/image-sboms", base, p.provider, p.org) +} - 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) - } +func uploadSBOMToCodacy(sbomPath, imageName, tag string, params sbomUploadParams) error { + url := params.uploadURL() - // Add required fields - writer.WriteField("imageName", imageName) - writer.WriteField("tag", tag) + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) - // Add optional fields - if sbomRepoName != "" { - writer.WriteField("repositoryName", sbomRepoName) - } - if sbomEnv != "" { - writer.WriteField("environment", sbomEnv) + if err := buildSBOMMultipartForm(writer, sbomPath, imageName, tag, params); err != nil { + return err } if err := writer.Close(); err != nil { @@ -206,9 +237,9 @@ func uploadSBOMToCodacy(sbomPath, imageName, tag string) error { } req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Accept", "application/json") - req.Header.Set("api-token", sbomAPIToken) + req.Header.Set("api-token", params.apiToken) - resp, err := http.DefaultClient.Do(req) + resp, err := sbomHTTPClient.Do(req) if err != nil { return fmt.Errorf("request failed: %w", err) } @@ -221,3 +252,48 @@ func uploadSBOMToCodacy(sbomPath, imageName, tag string) error { return nil } + +// buildSBOMMultipartForm populates the multipart form with the SBOM file and metadata fields. +func buildSBOMMultipartForm(writer *multipart.Writer, sbomPath, imageName, tag string, params sbomUploadParams) error { + if err := addSBOMFile(writer, sbomPath); err != nil { + return err + } + + fields := map[string]string{ + "imageName": imageName, + "tag": tag, + } + if params.repoName != "" { + fields["repositoryName"] = params.repoName + } + if params.env != "" { + fields["environment"] = params.env + } + + for name, value := range fields { + if err := writer.WriteField(name, value); err != nil { + return fmt.Errorf("failed to write %s field: %w", name, err) + } + } + + return nil +} + +// addSBOMFile adds the SBOM file to the multipart form. +func addSBOMFile(writer *multipart.Writer, sbomPath string) error { + 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) + } + + return nil +} diff --git a/cmd/upload_sbom_test.go b/cmd/upload_sbom_test.go index 4870b57f..4a4d98df 100644 --- a/cmd/upload_sbom_test.go +++ b/cmd/upload_sbom_test.go @@ -3,6 +3,8 @@ package cmd import ( "errors" "io" + "net/http" + "net/http/httptest" "os" "testing" @@ -10,24 +12,28 @@ import ( ) type sbomTestState struct { - apiToken string - provider string - org string - repoName string - env string - tag string - format string + apiToken string + provider string + org string + repoName string + env string + tag string + format string + baseURL string + httpClient httpDoer } func saveSBOMState() sbomTestState { return sbomTestState{ - apiToken: sbomAPIToken, - provider: sbomProvider, - org: sbomOrg, - repoName: sbomRepoName, - env: sbomEnv, - tag: sbomTag, - format: sbomFormat, + apiToken: sbomAPIToken, + provider: sbomProvider, + org: sbomOrg, + repoName: sbomRepoName, + env: sbomEnv, + tag: sbomTag, + format: sbomFormat, + baseURL: sbomBaseURL, + httpClient: sbomHTTPClient, } } @@ -39,6 +45,8 @@ func (s sbomTestState) restore() { sbomEnv = s.env sbomTag = s.tag sbomFormat = s.format + sbomBaseURL = s.baseURL + sbomHTTPClient = s.httpClient } // setSBOMDefaults sets the minimum required SBOM globals for tests @@ -50,6 +58,7 @@ func setSBOMDefaults() { sbomEnv = "" sbomTag = "" sbomFormat = "cyclonedx" + sbomBaseURL = "" } func TestParseImageRef(t *testing.T) { @@ -142,7 +151,6 @@ func TestExecuteUploadSBOM_TrivyGenerationFails(t *testing.T) { assert.Equal(t, 2, exitCode) } - func TestExecuteUploadSBOM_TrivyCalledWithCorrectFormat(t *testing.T) { formats := []string{"cyclonedx", "spdx-json"} @@ -161,7 +169,7 @@ func TestExecuteUploadSBOM_TrivyCalledWithCorrectFormat(t *testing.T) { 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) + _ = os.WriteFile(args[i+1], []byte(`{}`), 0644) break } } @@ -169,11 +177,19 @@ func TestExecuteUploadSBOM_TrivyCalledWithCorrectFormat(t *testing.T) { }, } commandRunner = mockRunner + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + setSBOMDefaults() sbomFormat = format + sbomBaseURL = server.URL + sbomHTTPClient = server.Client() - // Will fail at upload (no real API), but we can verify Trivy args - _ = executeUploadSBOM("alpine:latest") + exitCode := executeUploadSBOM("alpine:latest") + assert.Equal(t, 0, exitCode) assert.Len(t, mockRunner.Calls, 1) assert.Contains(t, mockRunner.Calls[0].Args, "--format") @@ -182,8 +198,65 @@ func TestExecuteUploadSBOM_TrivyCalledWithCorrectFormat(t *testing.T) { } } +func TestExecuteUploadSBOM_DigestImagePassedCorrectly(t *testing.T) { + state := saveState() + defer state.restore() + ss := saveSBOMState() + defer ss.restore() + + getTrivyPathResolver = func() (string, error) { + return "/usr/local/bin/trivy", nil + } + + var capturedImageRef string + mockRunner := &MockCommandRunner{ + RunWithStderrFunc: func(_ string, args []string, _ io.Writer) error { + capturedImageRef = args[len(args)-1] + for i, arg := range args { + if arg == "-o" && i+1 < len(args) { + os.WriteFile(args[i+1], []byte(`{}`), 0644) + break + } + } + return nil + }, + } + commandRunner = mockRunner + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + setSBOMDefaults() + sbomBaseURL = server.URL + sbomHTTPClient = server.Client() + + exitCode := executeUploadSBOM("nginx@sha256:abc123def456") + assert.Equal(t, 0, exitCode) + assert.Equal(t, "nginx@sha256:abc123def456", capturedImageRef) +} + +func TestExecuteUploadSBOM_DigestWithTagRejected(t *testing.T) { + state := saveState() + defer state.restore() + ss := saveSBOMState() + defer ss.restore() + + setSBOMDefaults() + sbomTag = "latest" + + exitCode := executeUploadSBOM("nginx@sha256:abc123def456") + assert.Equal(t, 2, exitCode) +} + func TestUploadSBOMToCodacy_FileNotFound(t *testing.T) { - err := uploadSBOMToCodacy("/nonexistent/file.json", "myapp", "latest") + params := sbomUploadParams{ + provider: "gh", + org: "test-org", + apiToken: "test-token", + } + err := uploadSBOMToCodacy("/nonexistent/file.json", "myapp", "latest", params) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to open SBOM file") } diff --git a/config/config.go b/config/config.go index 2ca36805..12e69a1f 100644 --- a/config/config.go +++ b/config/config.go @@ -442,5 +442,3 @@ func (c *ConfigType) GetCliMode() (string, error) { return currentCliMode, nil } - - diff --git a/integration-tests/init-with-token/expected/codacy.yaml b/integration-tests/init-with-token/expected/codacy.yaml index c610a1a8..d5754b6a 100644 --- a/integration-tests/init-with-token/expected/codacy.yaml +++ b/integration-tests/init-with-token/expected/codacy.yaml @@ -5,7 +5,7 @@ runtimes: tools: - eslint@8.57.0 - lizard@1.17.31 - - opengrep@1.16.4 + - opengrep@1.17.0 - pmd@6.55.0 - pylint@3.3.9 - trivy@0.69.3 diff --git a/plugins/tools/trivy/test/expected.sarif b/plugins/tools/trivy/test/expected.sarif index 4a542dba..264569c0 100644 --- a/plugins/tools/trivy/test/expected.sarif +++ b/plugins/tools/trivy/test/expected.sarif @@ -34,7 +34,7 @@ "text": "Package: django\nInstalled Version: 1.11.29\nVulnerability CVE-2021-33203\nSeverity: MEDIUM\nFixed Version: 2.2.24, 3.1.12, 3.2.4\nLink: [CVE-2021-33203](https://avd.aquasec.com/nvd/cve-2021-33203)" }, "ruleId": "CVE-2021-33203", - "ruleIndex": 12 + "ruleIndex": 14 }, { "level": "error", @@ -61,7 +61,7 @@ "text": "Package: django\nInstalled Version: 1.11.29\nVulnerability CVE-2022-36359\nSeverity: HIGH\nFixed Version: 3.2.15, 4.0.7\nLink: [CVE-2022-36359](https://avd.aquasec.com/nvd/cve-2022-36359)" }, "ruleId": "CVE-2022-36359", - "ruleIndex": 9 + "ruleIndex": 11 }, { "level": "error", @@ -88,7 +88,7 @@ "text": "Package: cross-spawn\nInstalled Version: 7.0.3\nVulnerability CVE-2024-21538\nSeverity: HIGH\nFixed Version: 7.0.5, 6.0.6\nLink: [CVE-2024-21538](https://avd.aquasec.com/nvd/cve-2024-21538)" }, "ruleId": "CVE-2024-21538", - "ruleIndex": 2 + "ruleIndex": 3 }, { "level": "warning", @@ -115,7 +115,7 @@ "text": "Package: django\nInstalled Version: 1.11.29\nVulnerability CVE-2024-45231\nSeverity: MEDIUM\nFixed Version: 5.1.1, 5.0.9, 4.2.16\nLink: [CVE-2024-45231](https://avd.aquasec.com/nvd/cve-2024-45231)" }, "ruleId": "CVE-2024-45231", - "ruleIndex": 13 + "ruleIndex": 15 }, { "level": "warning", @@ -142,7 +142,7 @@ "text": "Package: django\nInstalled Version: 1.11.29\nVulnerability CVE-2025-48432\nSeverity: MEDIUM\nFixed Version: 5.2.2, 5.1.10, 4.2.22\nLink: [CVE-2025-48432](https://avd.aquasec.com/nvd/cve-2025-48432)" }, "ruleId": "CVE-2025-48432", - "ruleIndex": 14 + "ruleIndex": 16 }, { "level": "error", @@ -169,7 +169,7 @@ "text": "Package: django\nInstalled Version: 1.11.29\nVulnerability CVE-2025-57833\nSeverity: HIGH\nFixed Version: 4.2.24, 5.1.12, 5.2.6\nLink: [CVE-2025-57833](https://avd.aquasec.com/nvd/cve-2025-57833)" }, "ruleId": "CVE-2025-57833", - "ruleIndex": 10 + "ruleIndex": 12 }, { "level": "note", @@ -196,7 +196,7 @@ "text": "Package: brace-expansion\nInstalled Version: 1.1.11\nVulnerability CVE-2025-5889\nSeverity: LOW\nFixed Version: 2.0.2, 1.1.12, 3.0.1, 4.0.1\nLink: [CVE-2025-5889](https://avd.aquasec.com/nvd/cve-2025-5889)" }, "ruleId": "CVE-2025-5889", - "ruleIndex": 1 + "ruleIndex": 2 }, { "level": "error", @@ -223,7 +223,7 @@ "text": "Package: django\nInstalled Version: 1.11.29\nVulnerability CVE-2025-64458\nSeverity: HIGH\nFixed Version: 5.2.8, 5.1.14, 4.2.26\nLink: [CVE-2025-64458](https://avd.aquasec.com/nvd/cve-2025-64458)" }, "ruleId": "CVE-2025-64458", - "ruleIndex": 11 + "ruleIndex": 13 }, { "level": "error", @@ -250,7 +250,7 @@ "text": "Package: django\nInstalled Version: 1.11.29\nVulnerability CVE-2025-64459\nSeverity: CRITICAL\nFixed Version: 5.2.8, 5.1.14, 4.2.26\nLink: [CVE-2025-64459](https://avd.aquasec.com/nvd/cve-2025-64459)" }, "ruleId": "CVE-2025-64459", - "ruleIndex": 8 + "ruleIndex": 10 }, { "level": "warning", @@ -277,7 +277,7 @@ "text": "Package: js-yaml\nInstalled Version: 4.1.0\nVulnerability CVE-2025-64718\nSeverity: MEDIUM\nFixed Version: 4.1.1, 3.14.2\nLink: [CVE-2025-64718](https://avd.aquasec.com/nvd/cve-2025-64718)" }, "ruleId": "CVE-2025-64718", - "ruleIndex": 4 + "ruleIndex": 6 }, { "level": "warning", @@ -331,7 +331,7 @@ "text": "Package: minimatch\nInstalled Version: 3.1.2\nVulnerability CVE-2026-26996\nSeverity: HIGH\nFixed Version: 10.2.1, 9.0.6, 8.0.5, 7.4.7, 6.2.1, 5.1.7, 4.2.4, 3.1.3\nLink: [CVE-2026-26996](https://avd.aquasec.com/nvd/cve-2026-26996)" }, "ruleId": "CVE-2026-26996", - "ruleIndex": 5 + "ruleIndex": 7 }, { "level": "error", @@ -358,7 +358,7 @@ "text": "Package: minimatch\nInstalled Version: 3.1.2\nVulnerability CVE-2026-27903\nSeverity: HIGH\nFixed Version: 10.2.3, 9.0.7, 8.0.6, 7.4.8, 6.2.2, 5.1.8, 4.2.5, 3.1.3\nLink: [CVE-2026-27903](https://avd.aquasec.com/nvd/cve-2026-27903)" }, "ruleId": "CVE-2026-27903", - "ruleIndex": 6 + "ruleIndex": 8 }, { "level": "error", @@ -385,7 +385,7 @@ "text": "Package: minimatch\nInstalled Version: 3.1.2\nVulnerability CVE-2026-27904\nSeverity: HIGH\nFixed Version: 10.2.3, 9.0.7, 8.0.6, 7.4.8, 6.2.2, 5.1.8, 4.2.5, 3.1.4\nLink: [CVE-2026-27904](https://avd.aquasec.com/nvd/cve-2026-27904)" }, "ruleId": "CVE-2026-27904", - "ruleIndex": 7 + "ruleIndex": 9 }, { "level": "error", @@ -412,7 +412,61 @@ "text": "Package: flatted\nInstalled Version: 3.3.1\nVulnerability CVE-2026-32141\nSeverity: HIGH\nFixed Version: 3.4.0\nLink: [CVE-2026-32141](https://avd.aquasec.com/nvd/cve-2026-32141)" }, "ruleId": "CVE-2026-32141", - "ruleIndex": 3 + "ruleIndex": 4 + }, + { + "level": "error", + "locations": [ + { + "message": { + "text": "package-lock.json: flatted@3.3.1" + }, + "physicalLocation": { + "artifactLocation": { + "uri": "package-lock.json", + "uriBaseId": "ROOTPATH" + }, + "region": { + "endColumn": 1, + "endLine": 823, + "startColumn": 1, + "startLine": 819 + } + } + } + ], + "message": { + "text": "Package: flatted\nInstalled Version: 3.3.1\nVulnerability CVE-2026-33228\nSeverity: HIGH\nFixed Version: 3.4.2\nLink: [CVE-2026-33228](https://avd.aquasec.com/nvd/cve-2026-33228)" + }, + "ruleId": "CVE-2026-33228", + "ruleIndex": 5 + }, + { + "level": "warning", + "locations": [ + { + "message": { + "text": "package-lock.json: brace-expansion@1.1.11" + }, + "physicalLocation": { + "artifactLocation": { + "uri": "package-lock.json", + "uriBaseId": "ROOTPATH" + }, + "region": { + "endColumn": 1, + "endLine": 357, + "startColumn": 1, + "startLine": 349 + } + } + } + ], + "message": { + "text": "Package: brace-expansion\nInstalled Version: 1.1.11\nVulnerability CVE-2026-33750\nSeverity: MEDIUM\nFixed Version: 5.0.5, 3.0.2, 2.0.3, 1.1.13\nLink: [CVE-2026-33750](https://avd.aquasec.com/nvd/cve-2026-33750)" + }, + "ruleId": "CVE-2026-33750", + "ruleIndex": 1 } ], "tool": { diff --git a/tools/lizard/lizardConfigCreator.go b/tools/lizard/lizardConfigCreator.go index a55c5b16..c3998be4 100644 --- a/tools/lizard/lizardConfigCreator.go +++ b/tools/lizard/lizardConfigCreator.go @@ -19,7 +19,7 @@ func CreateLizardConfig(toolsConfigDir string, patterns []domain.PatternConfigur for _, pattern := range patterns { patternDefinition := pattern.PatternDefinition metricType := getMetricTypeFromPatternId(patternDefinition.Id) - + if metricType == "" { fmt.Printf("Warning: Invalid pattern ID format: %s\n", patternDefinition.Id) continue