Skip to content

Commit 301c53e

Browse files
feat: generate and upload trivy SBOM into codacy (#200)
* generate and upload trivy SBOM into codacy * fix it:tests and codacy review warning fix
1 parent 9b6cc83 commit 301c53e

File tree

6 files changed

+648
-16
lines changed

6 files changed

+648
-16
lines changed

cmd/upload_sbom.go

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
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+
"time"
13+
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+
sbomFormat string
30+
sbomBaseURL string
31+
32+
sbomHTTPClient httpDoer = &http.Client{Timeout: 5 * time.Minute}
33+
)
34+
35+
// httpDoer abstracts the Do method of http.Client for testing.
36+
type httpDoer interface {
37+
Do(req *http.Request) (*http.Response, error)
38+
}
39+
40+
func init() {
41+
uploadSBOMCmd.Flags().StringVarP(&sbomAPIToken, "api-token", "a", "", "API token for Codacy API (required)")
42+
uploadSBOMCmd.Flags().StringVarP(&sbomProvider, "provider", "p", "", "Git provider (gh, gl, bb) (required)")
43+
uploadSBOMCmd.Flags().StringVarP(&sbomOrg, "organization", "o", "", "Organization name on the Git provider (required)")
44+
uploadSBOMCmd.Flags().StringVarP(&sbomTag, "tag", "t", "", "Docker image tag (defaults to image tag or 'latest')")
45+
uploadSBOMCmd.Flags().StringVarP(&sbomRepoName, "repository", "r", "", "Repository name (optional)")
46+
uploadSBOMCmd.Flags().StringVarP(&sbomEnv, "environment", "e", "", "Environment where the image is deployed (optional)")
47+
uploadSBOMCmd.Flags().StringVar(&sbomFormat, "format", "cyclonedx", "SBOM format: cyclonedx or spdx-json (default cyclonedx, smaller output)")
48+
49+
uploadSBOMCmd.MarkFlagRequired("api-token")
50+
uploadSBOMCmd.MarkFlagRequired("provider")
51+
uploadSBOMCmd.MarkFlagRequired("organization")
52+
53+
rootCmd.AddCommand(uploadSBOMCmd)
54+
}
55+
56+
var uploadSBOMCmd = &cobra.Command{
57+
Use: "upload-sbom <IMAGE_NAME>",
58+
Short: "Generate and upload an SBOM for a Docker image to Codacy",
59+
Long: `Generate an SBOM (Software Bill of Materials) for a Docker image using Trivy
60+
and upload it to Codacy for vulnerability tracking.
61+
62+
By default, Trivy generates a CycloneDX SBOM (smaller output). Use --format
63+
to switch to spdx-json if needed. Both formats are accepted by the Codacy API.`,
64+
Example: ` # Generate and upload SBOM
65+
codacy-cli upload-sbom -a <api-token> -p gh -o my-org -r my-repo myapp:latest
66+
67+
# Use SPDX format instead
68+
codacy-cli upload-sbom -a <api-token> -p gh -o my-org -r my-repo --format spdx-json myapp:v1.0.0`,
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+
if sbomFormat != "cyclonedx" && sbomFormat != "spdx-json" {
87+
color.Red("Error: --format must be 'cyclonedx' or 'spdx-json'")
88+
return 2
89+
}
90+
91+
imageName, tag := parseImageRef(imageRef)
92+
isDigest := strings.Contains(imageRef, "@")
93+
94+
if sbomTag != "" {
95+
if isDigest {
96+
color.Red("Error: --tag cannot be used with digest references (image@sha256:...)")
97+
return 2
98+
}
99+
tag = sbomTag
100+
}
101+
sbomImageName = imageName
102+
103+
var effectiveImageRef string
104+
if isDigest {
105+
effectiveImageRef = fmt.Sprintf("%s@%s", imageName, tag)
106+
} else {
107+
effectiveImageRef = fmt.Sprintf("%s:%s", imageName, tag)
108+
}
109+
110+
logger.Info("Starting SBOM upload", logrus.Fields{
111+
"image": effectiveImageRef,
112+
"provider": sbomProvider,
113+
"org": sbomOrg,
114+
})
115+
116+
sbomPath, err := generateSBOM(effectiveImageRef)
117+
if err != nil {
118+
return 2
119+
}
120+
defer os.Remove(sbomPath)
121+
122+
fmt.Printf("Uploading SBOM to Codacy (org: %s/%s)...\n", sbomProvider, sbomOrg)
123+
params := sbomUploadParams{
124+
provider: sbomProvider,
125+
org: sbomOrg,
126+
apiToken: sbomAPIToken,
127+
repoName: sbomRepoName,
128+
env: sbomEnv,
129+
baseURL: sbomBaseURL,
130+
}
131+
if err := uploadSBOMToCodacy(sbomPath, sbomImageName, tag, params); err != nil {
132+
logger.Error("Failed to upload SBOM", logrus.Fields{"error": err.Error()})
133+
color.Red("Error: Failed to upload SBOM: %v", err)
134+
return 1
135+
}
136+
137+
color.Green("Successfully uploaded SBOM for %s", effectiveImageRef)
138+
return 0
139+
}
140+
141+
// generateSBOM runs Trivy to generate an SBOM file and returns the path to it.
142+
func generateSBOM(imageRef string) (string, error) {
143+
trivyPath, err := getTrivyPath()
144+
if err != nil {
145+
handleTrivyNotFound(err)
146+
return "", err
147+
}
148+
149+
tmpFile, err := os.CreateTemp("", "codacy-sbom-*")
150+
if err != nil {
151+
logger.Error("Failed to create temp file", logrus.Fields{"error": err.Error()})
152+
color.Red("Error: Failed to create temporary file: %v", err)
153+
return "", err
154+
}
155+
tmpFile.Close()
156+
sbomPath := tmpFile.Name()
157+
158+
fmt.Printf("Generating SBOM for image: %s\n", imageRef)
159+
args := []string{"image", "--format", sbomFormat, "-o", sbomPath, imageRef}
160+
logger.Info("Running Trivy SBOM generation", logrus.Fields{"command": fmt.Sprintf("%s %v", trivyPath, args)})
161+
162+
var stderrBuf bytes.Buffer
163+
if err := commandRunner.RunWithStderr(trivyPath, args, &stderrBuf); err != nil {
164+
if isScanFailure(stderrBuf.Bytes()) {
165+
color.Red("Error: Failed to generate SBOM (image not found or no container runtime)")
166+
} else {
167+
color.Red("Error: Failed to generate SBOM: %v", err)
168+
}
169+
logger.Error("Trivy SBOM generation failed", logrus.Fields{"error": err.Error()})
170+
os.Remove(sbomPath)
171+
return "", err
172+
}
173+
fmt.Println("SBOM generated successfully")
174+
return sbomPath, nil
175+
}
176+
177+
// parseImageRef splits an image reference into name and tag.
178+
// e.g. "myapp:v1.0.0" -> ("myapp", "v1.0.0"), "myapp" -> ("myapp", "latest")
179+
func parseImageRef(imageRef string) (string, string) {
180+
// Handle digest references (image@sha256:...)
181+
if idx := strings.Index(imageRef, "@"); idx != -1 {
182+
return imageRef[:idx], imageRef[idx+1:]
183+
}
184+
185+
// Find the last colon that is part of the tag (not the registry port)
186+
lastSlash := strings.LastIndex(imageRef, "/")
187+
tagPart := imageRef
188+
if lastSlash != -1 {
189+
tagPart = imageRef[lastSlash:]
190+
}
191+
192+
if idx := strings.LastIndex(tagPart, ":"); idx != -1 {
193+
absIdx := idx
194+
if lastSlash != -1 {
195+
absIdx = lastSlash + idx
196+
}
197+
return imageRef[:absIdx], imageRef[absIdx+1:]
198+
}
199+
200+
return imageRef, "latest"
201+
}
202+
203+
type sbomUploadParams struct {
204+
provider string
205+
org string
206+
apiToken string
207+
repoName string
208+
env string
209+
baseURL string
210+
}
211+
212+
func (p sbomUploadParams) uploadURL() string {
213+
base := p.baseURL
214+
if base == "" {
215+
base = "https://app.codacy.com"
216+
}
217+
return fmt.Sprintf("%s/api/v3/organizations/%s/%s/image-sboms", base, p.provider, p.org)
218+
}
219+
220+
func uploadSBOMToCodacy(sbomPath, imageName, tag string, params sbomUploadParams) error {
221+
url := params.uploadURL()
222+
223+
body := &bytes.Buffer{}
224+
writer := multipart.NewWriter(body)
225+
226+
if err := buildSBOMMultipartForm(writer, sbomPath, imageName, tag, params); err != nil {
227+
return err
228+
}
229+
230+
if err := writer.Close(); err != nil {
231+
return fmt.Errorf("failed to close multipart writer: %w", err)
232+
}
233+
234+
req, err := http.NewRequest("POST", url, body)
235+
if err != nil {
236+
return fmt.Errorf("failed to create request: %w", err)
237+
}
238+
req.Header.Set("Content-Type", writer.FormDataContentType())
239+
req.Header.Set("Accept", "application/json")
240+
req.Header.Set("api-token", params.apiToken)
241+
242+
resp, err := sbomHTTPClient.Do(req)
243+
if err != nil {
244+
return fmt.Errorf("request failed: %w", err)
245+
}
246+
defer resp.Body.Close()
247+
248+
if resp.StatusCode != http.StatusNoContent {
249+
respBody, _ := io.ReadAll(resp.Body)
250+
return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(respBody))
251+
}
252+
253+
return nil
254+
}
255+
256+
// buildSBOMMultipartForm populates the multipart form with the SBOM file and metadata fields.
257+
func buildSBOMMultipartForm(writer *multipart.Writer, sbomPath, imageName, tag string, params sbomUploadParams) error {
258+
if err := addSBOMFile(writer, sbomPath); err != nil {
259+
return err
260+
}
261+
262+
fields := map[string]string{
263+
"imageName": imageName,
264+
"tag": tag,
265+
}
266+
if params.repoName != "" {
267+
fields["repositoryName"] = params.repoName
268+
}
269+
if params.env != "" {
270+
fields["environment"] = params.env
271+
}
272+
273+
for name, value := range fields {
274+
if err := writer.WriteField(name, value); err != nil {
275+
return fmt.Errorf("failed to write %s field: %w", name, err)
276+
}
277+
}
278+
279+
return nil
280+
}
281+
282+
// addSBOMFile adds the SBOM file to the multipart form.
283+
func addSBOMFile(writer *multipart.Writer, sbomPath string) error {
284+
sbomFile, err := os.Open(sbomPath)
285+
if err != nil {
286+
return fmt.Errorf("failed to open SBOM file: %w", err)
287+
}
288+
defer sbomFile.Close()
289+
290+
part, err := writer.CreateFormFile("sbom", filepath.Base(sbomPath))
291+
if err != nil {
292+
return fmt.Errorf("failed to create form file: %w", err)
293+
}
294+
if _, err := io.Copy(part, sbomFile); err != nil {
295+
return fmt.Errorf("failed to write SBOM to form: %w", err)
296+
}
297+
298+
return nil
299+
}

0 commit comments

Comments
 (0)