Skip to content

Commit b38549f

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

File tree

4 files changed

+508
-1
lines changed

4 files changed

+508
-1
lines changed

cmd/upload_sbom.go

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

0 commit comments

Comments
 (0)