Skip to content

Commit a7fc362

Browse files
generate and upload trivy SBOM into codacy
1 parent 9b6cc83 commit a7fc362

File tree

4 files changed

+431
-0
lines changed

4 files changed

+431
-0
lines changed

cmd/upload_sbom.go

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"io"
7+
"mime/multipart"
8+
"net/http"
9+
"os"
10+
"path/filepath"
11+
"strings"
12+
13+
"codacy/cli-v2/utils/logger"
14+
15+
"github.com/fatih/color"
16+
"github.com/sirupsen/logrus"
17+
"github.com/spf13/cobra"
18+
)
19+
20+
var (
21+
sbomAPIToken string
22+
sbomProvider string
23+
sbomOrg string
24+
sbomImageName string
25+
sbomTag string
26+
sbomRepoName string
27+
sbomEnv string
28+
sbomFormat string
29+
)
30+
31+
func init() {
32+
uploadSBOMCmd.Flags().StringVarP(&sbomAPIToken, "api-token", "a", "", "API token for Codacy API (required)")
33+
uploadSBOMCmd.Flags().StringVarP(&sbomProvider, "provider", "p", "", "Git provider (gh, gl, bb) (required)")
34+
uploadSBOMCmd.Flags().StringVarP(&sbomOrg, "organization", "o", "", "Organization name on the Git provider (required)")
35+
uploadSBOMCmd.Flags().StringVarP(&sbomTag, "tag", "t", "", "Docker image tag (defaults to image tag or 'latest')")
36+
uploadSBOMCmd.Flags().StringVarP(&sbomRepoName, "repository", "r", "", "Repository name (optional)")
37+
uploadSBOMCmd.Flags().StringVarP(&sbomEnv, "environment", "e", "", "Environment where the image is deployed (optional)")
38+
uploadSBOMCmd.Flags().StringVar(&sbomFormat, "format", "cyclonedx", "SBOM format: cyclonedx or spdx-json (default cyclonedx, smaller output)")
39+
40+
uploadSBOMCmd.MarkFlagRequired("api-token")
41+
uploadSBOMCmd.MarkFlagRequired("provider")
42+
uploadSBOMCmd.MarkFlagRequired("organization")
43+
44+
rootCmd.AddCommand(uploadSBOMCmd)
45+
}
46+
47+
var uploadSBOMCmd = &cobra.Command{
48+
Use: "upload-sbom <IMAGE_NAME>",
49+
Short: "Generate and upload an SBOM for a Docker image to Codacy",
50+
Long: `Generate an SBOM (Software Bill of Materials) for a Docker image using Trivy
51+
and upload it to Codacy for vulnerability tracking.
52+
53+
By default, Trivy generates a CycloneDX SBOM (smaller output). Use --format
54+
to switch to spdx-json if needed. Both formats are accepted by the Codacy API.`,
55+
Example: ` # Generate and upload SBOM
56+
codacy-cli upload-sbom -a <api-token> -p gh -o my-org -r my-repo myapp:latest
57+
58+
# Use SPDX format instead
59+
codacy-cli upload-sbom -a <api-token> -p gh -o my-org -r my-repo --format spdx-json myapp:v1.0.0`,
60+
Args: cobra.ExactArgs(1),
61+
Run: runUploadSBOM,
62+
}
63+
64+
func runUploadSBOM(_ *cobra.Command, args []string) {
65+
exitCode := executeUploadSBOM(args[0])
66+
exitFunc(exitCode)
67+
}
68+
69+
// executeUploadSBOM generates (or reads) an SBOM and uploads it to Codacy. Returns exit code.
70+
func executeUploadSBOM(imageRef string) int {
71+
if err := validateImageName(imageRef); err != nil {
72+
logger.Error("Invalid image name", logrus.Fields{"image": imageRef, "error": err.Error()})
73+
color.Red("Error: %v", err)
74+
return 2
75+
}
76+
77+
if sbomFormat != "cyclonedx" && sbomFormat != "spdx-json" {
78+
color.Red("Error: --format must be 'cyclonedx' or 'spdx-json'")
79+
return 2
80+
}
81+
82+
imageName, tag := parseImageRef(imageRef)
83+
if sbomTag != "" {
84+
tag = sbomTag
85+
}
86+
sbomImageName = imageName
87+
88+
logger.Info("Starting SBOM upload", logrus.Fields{
89+
"image": imageRef,
90+
"provider": sbomProvider,
91+
"org": sbomOrg,
92+
})
93+
94+
// Generate SBOM with Trivy
95+
trivyPath, err := getTrivyPath()
96+
if err != nil {
97+
handleTrivyNotFound(err)
98+
return 2
99+
}
100+
101+
tmpFile, err := os.CreateTemp("", "codacy-sbom-*")
102+
if err != nil {
103+
logger.Error("Failed to create temp file", logrus.Fields{"error": err.Error()})
104+
color.Red("Error: Failed to create temporary file: %v", err)
105+
return 2
106+
}
107+
tmpFile.Close()
108+
sbomPath := tmpFile.Name()
109+
defer os.Remove(sbomPath)
110+
111+
fmt.Printf("Generating SBOM for image: %s\n", imageRef)
112+
args := []string{"image", "--format", sbomFormat, "-o", sbomPath, imageRef}
113+
logger.Info("Running Trivy SBOM generation", logrus.Fields{"command": fmt.Sprintf("%s %v", trivyPath, args)})
114+
115+
var stderrBuf bytes.Buffer
116+
if err := commandRunner.RunWithStderr(trivyPath, args, &stderrBuf); err != nil {
117+
if isScanFailure(stderrBuf.Bytes()) {
118+
color.Red("Error: Failed to generate SBOM (image not found or no container runtime)")
119+
} else {
120+
color.Red("Error: Failed to generate SBOM: %v", err)
121+
}
122+
logger.Error("Trivy SBOM generation failed", logrus.Fields{"error": err.Error()})
123+
return 2
124+
}
125+
fmt.Println("SBOM generated successfully")
126+
127+
// Upload SBOM to Codacy
128+
fmt.Printf("Uploading SBOM to Codacy (org: %s/%s)...\n", sbomProvider, sbomOrg)
129+
if err := uploadSBOMToCodacy(sbomPath, sbomImageName, tag); err != nil {
130+
logger.Error("Failed to upload SBOM", logrus.Fields{"error": err.Error()})
131+
color.Red("Error: Failed to upload SBOM: %v", err)
132+
return 1
133+
}
134+
135+
color.Green("Successfully uploaded SBOM for %s:%s", sbomImageName, tag)
136+
return 0
137+
}
138+
139+
// parseImageRef splits an image reference into name and tag.
140+
// e.g. "myapp:v1.0.0" -> ("myapp", "v1.0.0"), "myapp" -> ("myapp", "latest")
141+
func parseImageRef(imageRef string) (string, string) {
142+
// Handle digest references (image@sha256:...)
143+
if idx := strings.Index(imageRef, "@"); idx != -1 {
144+
return imageRef[:idx], imageRef[idx+1:]
145+
}
146+
147+
// Find the last colon that is part of the tag (not the registry port)
148+
lastSlash := strings.LastIndex(imageRef, "/")
149+
tagPart := imageRef
150+
if lastSlash != -1 {
151+
tagPart = imageRef[lastSlash:]
152+
}
153+
154+
if idx := strings.LastIndex(tagPart, ":"); idx != -1 {
155+
absIdx := idx
156+
if lastSlash != -1 {
157+
absIdx = lastSlash + idx
158+
}
159+
return imageRef[:absIdx], imageRef[absIdx+1:]
160+
}
161+
162+
return imageRef, "latest"
163+
}
164+
165+
func uploadSBOMToCodacy(sbomPath, imageName, tag string) error {
166+
url := fmt.Sprintf("https://app.codacy.com/api/v3/organizations/%s/%s/image-sboms",
167+
sbomProvider, sbomOrg)
168+
169+
body := &bytes.Buffer{}
170+
writer := multipart.NewWriter(body)
171+
172+
// Add the SBOM file
173+
sbomFile, err := os.Open(sbomPath)
174+
if err != nil {
175+
return fmt.Errorf("failed to open SBOM file: %w", err)
176+
}
177+
defer sbomFile.Close()
178+
179+
part, err := writer.CreateFormFile("sbom", filepath.Base(sbomPath))
180+
if err != nil {
181+
return fmt.Errorf("failed to create form file: %w", err)
182+
}
183+
if _, err := io.Copy(part, sbomFile); err != nil {
184+
return fmt.Errorf("failed to write SBOM to form: %w", err)
185+
}
186+
187+
// Add required fields
188+
writer.WriteField("imageName", imageName)
189+
writer.WriteField("tag", tag)
190+
191+
// Add optional fields
192+
if sbomRepoName != "" {
193+
writer.WriteField("repositoryName", sbomRepoName)
194+
}
195+
if sbomEnv != "" {
196+
writer.WriteField("environment", sbomEnv)
197+
}
198+
199+
if err := writer.Close(); err != nil {
200+
return fmt.Errorf("failed to close multipart writer: %w", err)
201+
}
202+
203+
req, err := http.NewRequest("POST", url, body)
204+
if err != nil {
205+
return fmt.Errorf("failed to create request: %w", err)
206+
}
207+
req.Header.Set("Content-Type", writer.FormDataContentType())
208+
req.Header.Set("Accept", "application/json")
209+
req.Header.Set("api-token", sbomAPIToken)
210+
211+
resp, err := http.DefaultClient.Do(req)
212+
if err != nil {
213+
return fmt.Errorf("request failed: %w", err)
214+
}
215+
defer resp.Body.Close()
216+
217+
if resp.StatusCode != http.StatusNoContent {
218+
respBody, _ := io.ReadAll(resp.Body)
219+
return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(respBody))
220+
}
221+
222+
return nil
223+
}

0 commit comments

Comments
 (0)