Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 223 additions & 0 deletions cmd/upload_sbom.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
package cmd

Check notice on line 1 in cmd/upload_sbom.go

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

cmd/upload_sbom.go#L1

should have a package comment

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 <IMAGE_NAME>",
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 <api-token> -p gh -o my-org -r my-repo myapp:latest

# Use SPDX format instead
codacy-cli upload-sbom -a <api-token> -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 {

Check warning on line 70 in cmd/upload_sbom.go

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

cmd/upload_sbom.go#L70

Method executeUploadSBOM has 57 lines of code (limit is 50)

Check warning on line 70 in cmd/upload_sbom.go

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

cmd/upload_sbom.go#L70

Method executeUploadSBOM has a cyclomatic complexity of 10 (limit is 7)
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 {

Check warning on line 165 in cmd/upload_sbom.go

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

cmd/upload_sbom.go#L165

Method uploadSBOMToCodacy has a cyclomatic complexity of 10 (limit is 7)
url := fmt.Sprintf("https://app.codacy.com/api/v3/organizations/%s/%s/image-sboms",
sbomProvider, sbomOrg)

Comment on lines +165 to +168
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uploadSBOMToCodacy relies on package-level globals (sbomProvider, sbomOrg, sbomAPIToken, etc.) instead of its parameters, which makes the function harder to test, non-reentrant, and more error-prone (state leaks between invocations). Consider passing provider/org/token/repo/env (and optionally an *http.Client/base URL) as explicit parameters or encapsulating them in a struct.

Copilot uses AI. Check for mistakes.
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)
Comment on lines +188 to +196
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Errors returned by writer.WriteField(...) are ignored. If these writes fail (e.g., due to an earlier multipart error), the request will be sent missing required/optional fields with no indication why. Handle and return these errors (especially for required fields like imageName and tag).

Suggested change
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.WriteField("imageName", imageName); err != nil {
return fmt.Errorf("failed to write imageName field: %w", err)
}
if err := writer.WriteField("tag", tag); err != nil {
return fmt.Errorf("failed to write tag field: %w", err)
}
// Add optional fields
if sbomRepoName != "" {
if err := writer.WriteField("repositoryName", sbomRepoName); err != nil {
return fmt.Errorf("failed to write repositoryName field: %w", err)
}
}
if sbomEnv != "" {
if err := writer.WriteField("environment", sbomEnv); err != nil {
return fmt.Errorf("failed to write environment field: %w", err)
}

Copilot uses AI. Check for mistakes.
}

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)
}
Comment on lines +211 to +214
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using http.DefaultClient without a timeout can cause the CLI to hang indefinitely on network issues. Prefer a dedicated http.Client{Timeout: ...} (there’s already a 10s timeout used in codacy-client/client.go) and/or allow injecting the client for tests.

Copilot uses AI. Check for mistakes.
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
}
Loading
Loading