Skip to content

Commit ffe0913

Browse files
authored
Add SBOM command (#1115)
* Rough initial scaffolding for sbom command * Implement SBOM fetching and validation for all images in a given release * Improve output * Make final status emoji consistent + remove debugging * Add a spinner 😵‍💫 * Newline * Enable transparency log verification and provide an override * Fix alignment * Remove comment * Remove comment * Remove unreachable code * Fix getImageDigestDockerHub * Use out.Write consistently * Add sourcegraph-sboms/ dir to gitignore * Fix typo * Use http.DefaultClient * Tweak tlog warning * Remove hardcoded list
1 parent 099c8c0 commit ffe0913

File tree

5 files changed

+543
-1
lines changed

5 files changed

+543
-1
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ bazel-testlogs
1313
bazel-zoekt
1414
bazel-src-cli
1515
.DS_Store
16-
samples
16+
samples
17+
sourcegraph-sboms/

cmd/src/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ The commands are:
4343
orgs,org manages organizations
4444
teams,team manages teams
4545
repos,repo manages repositories
46+
sbom manages SBOM (Software Bill of Materials) data
4647
search search for results on Sourcegraph
4748
serve-git serves your local git repositories over HTTP for Sourcegraph to pull
4849
users,user manages users

cmd/src/sbom.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package main
2+
3+
import (
4+
"flag"
5+
"fmt"
6+
)
7+
8+
var sbomCommands commander
9+
10+
func init() {
11+
usage := `'src sbom' fetches and verified SBOM (Software Bill of Materials) data for Sourcegraph containers.
12+
13+
Usage:
14+
15+
src sbom command [command options]
16+
17+
The commands are:
18+
19+
fetch fetch SBOMs for a released version of Sourcegraph
20+
`
21+
flagSet := flag.NewFlagSet("sbom", flag.ExitOnError)
22+
handler := func(args []string) error {
23+
sbomCommands.run(flagSet, "src sbom", usage, args)
24+
return nil
25+
}
26+
27+
// Register the command.
28+
commands = append(commands, &command{
29+
flagSet: flagSet,
30+
aliases: []string{"sbom"},
31+
handler: handler,
32+
usageFunc: func() {
33+
fmt.Println(usage)
34+
},
35+
})
36+
}

cmd/src/sbom_fetch.go

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
package main
2+
3+
import (
4+
"bufio"
5+
"encoding/base64"
6+
"encoding/json"
7+
"flag"
8+
"fmt"
9+
"net/http"
10+
"os"
11+
"os/exec"
12+
"path"
13+
"path/filepath"
14+
"strings"
15+
"unicode"
16+
17+
"github.com/sourcegraph/sourcegraph/lib/errors"
18+
"github.com/sourcegraph/sourcegraph/lib/output"
19+
20+
"github.com/sourcegraph/src-cli/internal/cmderrors"
21+
)
22+
23+
type sbomConfig struct {
24+
publicKey string
25+
outputDir string
26+
version string
27+
internalRelease bool
28+
insecureIgnoreTransparencyLog bool
29+
}
30+
31+
const publicKey = "https://storage.googleapis.com/sourcegraph-release-sboms/keys/cosign_keyring-cosign-1.pub"
32+
const imageListBaseURL = "https://storage.googleapis.com/sourcegraph-release-sboms"
33+
const imageListFilename = "release-image-list.txt"
34+
35+
func init() {
36+
usage := `
37+
'src sbom fetch' fetches and verifies SBOMs for the given release version of Sourcegraph.
38+
39+
Usage:
40+
41+
src sbom fetch -v <version>
42+
43+
Examples:
44+
45+
$ src sbom fetch -v 5.8.0 # Fetch all SBOMs for the 5.8.0 release
46+
47+
$ src sbom fetch -v 5.8.123 -internal -d /tmp/sboms # Fetch all SBOMs for the internal 5.8.123 release and store them in /tmp/sboms
48+
`
49+
50+
flagSet := flag.NewFlagSet("fetch", flag.ExitOnError)
51+
versionFlag := flagSet.String("v", "", "The version of Sourcegraph to fetch SBOMs for.")
52+
outputDirFlag := flagSet.String("d", "sourcegraph-sboms", "The directory to store validated SBOMs in.")
53+
internalReleaseFlag := flagSet.Bool("internal", false, "Fetch SBOMs for an internal release. Defaults to false.")
54+
insecureIgnoreTransparencyLogFlag := flagSet.Bool("insecure-ignore-tlog", false, "Disable transparency log verification. Defaults to false.")
55+
56+
handler := func(args []string) error {
57+
c := sbomConfig{
58+
publicKey: publicKey,
59+
}
60+
61+
if err := flagSet.Parse(args); err != nil {
62+
return err
63+
}
64+
65+
if len(flagSet.Args()) != 0 {
66+
return cmderrors.Usage("additional arguments not allowed")
67+
}
68+
69+
if versionFlag == nil || *versionFlag == "" {
70+
return cmderrors.Usage("version is required")
71+
}
72+
c.version = *versionFlag
73+
74+
if outputDirFlag == nil || *outputDirFlag == "" {
75+
return cmderrors.Usage("output directory is required")
76+
}
77+
c.outputDir = getOutputDir(*outputDirFlag, *versionFlag)
78+
79+
if internalReleaseFlag == nil || !*internalReleaseFlag {
80+
c.internalRelease = false
81+
} else {
82+
c.internalRelease = true
83+
}
84+
85+
if insecureIgnoreTransparencyLogFlag != nil && *insecureIgnoreTransparencyLogFlag {
86+
c.insecureIgnoreTransparencyLog = true
87+
}
88+
89+
out := output.NewOutput(flagSet.Output(), output.OutputOpts{Verbose: *verbose})
90+
91+
if err := verifyCosign(); err != nil {
92+
return cmderrors.ExitCode(1, err)
93+
}
94+
95+
images, err := c.getImageList()
96+
if err != nil {
97+
return err
98+
}
99+
100+
out.Writef("Fetching SBOMs and validating signatures for all %d images in the Sourcegraph %s release...\n", len(images), c.version)
101+
102+
if c.insecureIgnoreTransparencyLog {
103+
out.WriteLine(output.Line("⚠️", output.StyleWarning, "WARNING: Transparency log verification is disabled, increasing the risk that SBOMs may have been tampered with."))
104+
out.WriteLine(output.Line("️", output.StyleWarning, " This setting should only be used for testing or under explicit instruction from Sourcegraph.\n"))
105+
}
106+
107+
var successCount, failureCount int
108+
for _, image := range images {
109+
stopSpinner := make(chan bool)
110+
go spinner(image, stopSpinner)
111+
112+
_, err = c.getSBOMForImageVersion(image, c.version)
113+
114+
stopSpinner <- true
115+
116+
if err != nil {
117+
out.WriteLine(output.Line(output.EmojiFailure, output.StyleWarning,
118+
fmt.Sprintf("\r%s: error fetching and validating SBOM:\n %v", image, err)))
119+
failureCount += 1
120+
} else {
121+
out.WriteLine(output.Line("\r\u2705", output.StyleSuccess, image))
122+
successCount += 1
123+
}
124+
}
125+
126+
out.Write("")
127+
if failureCount == 0 && successCount == 0 {
128+
out.WriteLine(output.Line("🔴", output.StyleWarning, "Failed to fetch SBOMs for any images"))
129+
}
130+
if failureCount > 0 {
131+
out.WriteLine(output.Line("🟠", output.StyleOrange, fmt.Sprintf("Fetched verified SBOMs for %d images, but failed to fetch SBOMs for %d images", successCount, failureCount)))
132+
} else if successCount > 0 {
133+
out.WriteLine(output.Line("🟢", output.StyleSuccess, fmt.Sprintf("Fetched verified SBOMs for %d images", successCount)))
134+
}
135+
136+
out.Writef("\nFetched and validated SBOMs have been written to `%s`.\n", c.outputDir)
137+
out.WriteLine(output.Linef("", output.StyleBold, "Your Sourcegraph deployment may not use all of these images. Please check your deployment to confirm which images are used.\n"))
138+
139+
if failureCount > 0 || successCount == 0 {
140+
return cmderrors.ExitCode1
141+
}
142+
143+
return nil
144+
}
145+
146+
sbomCommands = append(sbomCommands, &command{
147+
flagSet: flagSet,
148+
handler: handler,
149+
usageFunc: func() {
150+
fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src sbom %s':\n", flagSet.Name())
151+
flagSet.PrintDefaults()
152+
fmt.Println(usage)
153+
},
154+
})
155+
}
156+
157+
func (c sbomConfig) getSBOMForImageVersion(image string, version string) (string, error) {
158+
hash, err := getImageDigest(image, version)
159+
if err != nil {
160+
return "", err
161+
}
162+
163+
sbom, err := c.getSBOMForImageHash(image, hash)
164+
if err != nil {
165+
return "", err
166+
}
167+
168+
return sbom, nil
169+
}
170+
171+
func verifyCosign() error {
172+
_, err := exec.LookPath("cosign")
173+
if err != nil {
174+
return errors.New("SBOM verification requires 'cosign' to be installed and available in $PATH. See https://docs.sigstore.dev/cosign/system_config/installation/")
175+
}
176+
return nil
177+
}
178+
179+
func (c sbomConfig) getImageList() ([]string, error) {
180+
imageReleaseListURL := c.getImageReleaseListURL()
181+
182+
resp, err := http.Get(imageReleaseListURL)
183+
if err != nil {
184+
return nil, fmt.Errorf("failed to fetch image list: %w", err)
185+
}
186+
defer resp.Body.Close()
187+
188+
if resp.StatusCode != http.StatusOK {
189+
return nil, fmt.Errorf("failed to fetch list of images - check that %s is a valid Sourcegraph release: HTTP status %d", c.version, resp.StatusCode)
190+
}
191+
192+
scanner := bufio.NewScanner(resp.Body)
193+
var images []string
194+
for scanner.Scan() {
195+
image := strings.TrimSpace(scanner.Text())
196+
if image != "" {
197+
// Strip off a version suffix if present
198+
parts := strings.SplitN(image, ":", 2)
199+
images = append(images, parts[0])
200+
}
201+
}
202+
203+
if err := scanner.Err(); err != nil {
204+
return nil, fmt.Errorf("error reading image list: %w", err)
205+
}
206+
207+
return images, nil
208+
}
209+
210+
func (c sbomConfig) getSBOMForImageHash(image string, hash string) (string, error) {
211+
tempDir, err := os.MkdirTemp("", "sbom-")
212+
if err != nil {
213+
return "", fmt.Errorf("failed to create temporary directory: %w", err)
214+
}
215+
defer os.RemoveAll(tempDir)
216+
217+
outputFile := filepath.Join(tempDir, "attestation.json")
218+
219+
cosignArgs := []string{
220+
"verify-attestation",
221+
"--key", publicKey,
222+
"--type", "cyclonedx",
223+
fmt.Sprintf("%s@%s", image, hash),
224+
"--output-file", outputFile,
225+
}
226+
227+
if c.insecureIgnoreTransparencyLog {
228+
cosignArgs = append(cosignArgs, "--insecure-ignore-tlog")
229+
}
230+
231+
cmd := exec.Command("cosign", cosignArgs...)
232+
233+
output, err := cmd.CombinedOutput()
234+
if err != nil {
235+
return "", fmt.Errorf("SBOM fetching or validation failed: %w\nOutput: %s", err, output)
236+
}
237+
238+
attestation, err := os.ReadFile(outputFile)
239+
if err != nil {
240+
return "", fmt.Errorf("failed to read SBOM file: %w", err)
241+
}
242+
243+
sbom, err := extractSBOM(attestation)
244+
if err != nil {
245+
return "", fmt.Errorf("failed to extract SBOM from attestation: %w", err)
246+
}
247+
248+
c.storeSBOM(sbom, image)
249+
250+
return sbom, nil
251+
}
252+
253+
type attestation struct {
254+
PayloadType string `json:"payloadType"`
255+
Base64Payload string `json:"payload"`
256+
}
257+
258+
func extractSBOM(attestationBytes []byte) (string, error) {
259+
var a attestation
260+
if err := json.Unmarshal(attestationBytes, &a); err != nil {
261+
return "", fmt.Errorf("failed to unmarshal attestation: %w", err)
262+
}
263+
264+
if a.PayloadType != "application/vnd.in-toto+json" {
265+
return "", fmt.Errorf("unexpected payload type: %s", a.PayloadType)
266+
}
267+
268+
decodedPayload, err := base64.StdEncoding.DecodeString(a.Base64Payload)
269+
if err != nil {
270+
return "", fmt.Errorf("failed to decode payload: %w", err)
271+
}
272+
273+
return string(decodedPayload), nil
274+
}
275+
276+
func (c sbomConfig) storeSBOM(sbom string, image string) error {
277+
// Make the image name safe for use as a filename
278+
safeImageName := strings.Map(func(r rune) rune {
279+
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' {
280+
return r
281+
}
282+
return '_'
283+
}, image)
284+
285+
// Create the output file path
286+
outputFile := filepath.Join(c.outputDir, safeImageName+".json")
287+
288+
// Ensure the output directory exists
289+
if err := os.MkdirAll(c.outputDir, 0755); err != nil {
290+
return fmt.Errorf("failed to create output directory: %w", err)
291+
}
292+
293+
// Write the SBOM to the file
294+
if err := os.WriteFile(outputFile, []byte(sbom), 0644); err != nil {
295+
return fmt.Errorf("failed to write SBOM file: %w", err)
296+
}
297+
298+
return nil
299+
}
300+
301+
func getOutputDir(parentDir, version string) string {
302+
return path.Join(parentDir, "sourcegraph-"+version)
303+
}
304+
305+
// getImageReleaseListURL returns the URL for the list of images in a release, based on the version and whether it's an internal release.
306+
func (c *sbomConfig) getImageReleaseListURL() string {
307+
if c.internalRelease {
308+
return fmt.Sprintf("%s/release-internal/%s/%s", imageListBaseURL, c.version, imageListFilename)
309+
} else {
310+
return fmt.Sprintf("%s/release/%s/%s", imageListBaseURL, c.version, imageListFilename)
311+
}
312+
}

0 commit comments

Comments
 (0)