diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0132a63..16a9cde 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `scan repo`/`scan image`: new `--sbom-format` flag selects the generated SBOM serialization — `cyclonedx` (default) or `spdx` (SPDX 2.3 JSON). Requires `--sbom`; the value is sent to the ingest API's `sbom_format` field and the SPDX document is downloaded from the backend's `sbom_spdx_results` artifact (default path `.armis/-sbom.spdx.json`). Omitting the flag preserves the existing CycloneDX behavior. + ### Changed ### Deprecated diff --git a/internal/api/client.go b/internal/api/client.go index cfbee88..002e9de 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -306,7 +306,10 @@ type IngestOptions struct { Data io.Reader Size int64 GenerateSBOM bool - GenerateVEX bool + // SBOMFormat selects the SBOM serialization ("cyclonedx" | "spdx"). Empty + // defaults to CycloneDX server-side. + SBOMFormat string + GenerateVEX bool } // StatusCallback is called on each poll with the current scan status. @@ -526,6 +529,7 @@ func (c *Client) startArtifactScan(ctx context.Context, scanID string, opts Inge TenantID: opts.TenantID, ScanType: "full", // matches the prior /tar behavior; flag-driven scan_type is a future change SBOMGenerate: opts.GenerateSBOM, + SBOMFormat: opts.SBOMFormat, VEXGenerate: opts.GenerateVEX, } bodyBytes, err := json.Marshal(body) diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 70aecc6..357d5a1 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -3,6 +3,7 @@ package api import ( "bytes" "context" + "encoding/json" "errors" "io" "net/http" @@ -287,6 +288,60 @@ func TestClient_StartIngest(t *testing.T) { } }) + t.Run("forwards sbom_format to /scan when SPDX requested", func(t *testing.T) { + t.Parallel() + srv, state := newIngestFlowServer(t) + client := newIngestFlowClient(t, srv.URL) + + _, err := client.StartIngest(context.Background(), IngestOptions{ + TenantID: "tenant-456", ArtifactType: "repo", Filename: "test.tar.gz", + Data: bytes.NewReader([]byte("test")), Size: 4, + GenerateSBOM: true, SBOMFormat: "spdx", + }) + if err != nil { + t.Fatalf("StartIngest failed: %v", err) + } + + state.mu.Lock() + body := state.lastScanRequestBody + state.mu.Unlock() + + var req model.IngestScanStartRequest + if err := json.Unmarshal(body, &req); err != nil { + t.Fatalf("failed to unmarshal /scan body: %v", err) + } + if !req.SBOMGenerate { + t.Error("expected sbom_generate=true") + } + if req.SBOMFormat != "spdx" { + t.Errorf("sbom_format = %q, want %q", req.SBOMFormat, "spdx") + } + }) + + t.Run("omits sbom_format when unset", func(t *testing.T) { + t.Parallel() + srv, state := newIngestFlowServer(t) + client := newIngestFlowClient(t, srv.URL) + + _, err := client.StartIngest(context.Background(), IngestOptions{ + TenantID: "tenant-456", ArtifactType: "repo", Filename: "test.tar.gz", + Data: bytes.NewReader([]byte("test")), Size: 4, + }) + if err != nil { + t.Fatalf("StartIngest failed: %v", err) + } + + state.mu.Lock() + body := state.lastScanRequestBody + state.mu.Unlock() + + // omitempty means the key must be absent from the wire payload so that + // older backends see exactly the pre-SPDX request shape. + if strings.Contains(string(body), "sbom_format") { + t.Errorf("expected sbom_format to be omitted, got body: %s", body) + } + }) + t.Run("upload error: /presigned-url 4xx", func(t *testing.T) { t.Parallel() srv, state := newIngestFlowServer(t) diff --git a/internal/cmd/scan.go b/internal/cmd/scan.go index dc1d978..af614e4 100644 --- a/internal/cmd/scan.go +++ b/internal/cmd/scan.go @@ -7,6 +7,7 @@ import ( "github.com/ArmisSecurity/armis-cli/internal/cli" "github.com/ArmisSecurity/armis-cli/internal/cmd/cmdutil" "github.com/ArmisSecurity/armis-cli/internal/output" + "github.com/ArmisSecurity/armis-cli/internal/scan" "github.com/spf13/cobra" ) @@ -18,6 +19,7 @@ var ( groupBy string includeFiles []string generateSBOM bool + sbomFormat string generateVEX bool sbomOutput string vexOutput string @@ -31,6 +33,10 @@ var validFormats = []string{"human", "json", "sarif", "junit"} // validGroupBy contains the valid group-by options. var validGroupBy = []string{"none", "cwe", "severity", "file"} +// validSBOMFormats contains the valid --sbom-format values. These mirror the +// backend SbomFormat enum and are sent verbatim in the ingest request. +var validSBOMFormats = []string{scan.SBOMFormatCycloneDX, scan.SBOMFormatSPDX} + // defaultFailOn returns the default --fail-on severity set. It returns a fresh // slice on each call so the two flag registrations (scanCmd and scCheckCmd) // never share backing storage — pflag parsing mutates a StringSlice's default @@ -72,6 +78,17 @@ var scanCmd = &cobra.Command{ return fmt.Errorf("invalid --group-by value %q: must be one of %v", groupBy, validGroupBy) } + // Validate --sbom-format early (and normalize case) so a typo surfaces + // as a flag error before any network calls. + sbomFormat = strings.ToLower(sbomFormat) + validSBOMFormatSet := make(map[string]bool) + for _, f := range validSBOMFormats { + validSBOMFormatSet[f] = true + } + if !validSBOMFormatSet[sbomFormat] { + return fmt.Errorf("invalid --sbom-format value %q: must be one of %v", sbomFormat, validSBOMFormats) + } + // Validate exit-code: must be between 1 and 255 (0 defeats --fail-on, >255 is invalid POSIX) if exitCode < 1 || exitCode > 255 { return fmt.Errorf("invalid --exit-code value %d: must be between 1 and 255", exitCode) @@ -113,6 +130,12 @@ func warnOnUnusedSBOMVEXFlags() { if sbomOutput != "" && !generateSBOM { cli.PrintWarning("--sbom-output is ignored without --sbom flag") } + // --sbom-format only takes effect when an SBOM is generated. sbomFormat is + // normalized to lowercase in PersistentPreRunE, so compare against the + // canonical default. + if sbomFormat != scan.SBOMFormatCycloneDX && !generateSBOM { + cli.PrintWarning("--sbom-format is ignored without --sbom flag") + } if vexOutput != "" && !generateVEX { cli.PrintWarning("--vex-output is ignored without --vex flag") } @@ -146,7 +169,12 @@ func init() { "severity": "Group findings by severity level", "file": "Group findings by file path", })) - scanCmd.PersistentFlags().BoolVar(&generateSBOM, "sbom", false, "Generate Software Bill of Materials (SBOM) in CycloneDX format") + scanCmd.PersistentFlags().BoolVar(&generateSBOM, "sbom", false, "Generate Software Bill of Materials (SBOM); serialization set by --sbom-format") + scanCmd.PersistentFlags().StringVar(&sbomFormat, "sbom-format", scan.SBOMFormatCycloneDX, "SBOM serialization format: cyclonedx, spdx (requires --sbom)") + _ = scanCmd.RegisterFlagCompletionFunc("sbom-format", fixedCompletions(validSBOMFormats, map[string]string{ + scan.SBOMFormatCycloneDX: "CycloneDX JSON (default)", + scan.SBOMFormatSPDX: "SPDX 2.3 JSON", + })) scanCmd.PersistentFlags().BoolVar(&generateVEX, "vex", false, "Generate Vulnerability Exploitability eXchange (VEX) document") scanCmd.PersistentFlags().StringVar(&sbomOutput, "sbom-output", "", "Output file path for SBOM (default: .armis/-sbom.json)") scanCmd.PersistentFlags().StringVar(&vexOutput, "vex-output", "", "Output file path for VEX (default: .armis/-vex.json)") diff --git a/internal/cmd/scan_image.go b/internal/cmd/scan_image.go index fb71db1..20daa24 100644 --- a/internal/cmd/scan_image.go +++ b/internal/cmd/scan_image.go @@ -26,7 +26,8 @@ var scanImageCmd = &cobra.Command{ Example: ` $ armis-cli scan image nginx:latest $ armis-cli scan image myapp:v1.0 --format json $ armis-cli scan image --tarball ./image.tar - $ armis-cli scan image alpine:3.18 --sbom --vex --fail-on HIGH,CRITICAL`, + $ armis-cli scan image alpine:3.18 --sbom --vex --fail-on HIGH,CRITICAL + $ armis-cli scan image alpine:3.18 --sbom --sbom-format spdx`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // Validate --pull before auth so a bad value (e.g. "badvalue") surfaces as a @@ -117,6 +118,7 @@ var scanImageCmd = &cobra.Command{ if generateSBOM || generateVEX { sbomVEXOpts := &scan.SBOMVEXOptions{ GenerateSBOM: generateSBOM, + SBOMFormat: sbomFormat, GenerateVEX: generateVEX, SBOMOutput: sbomOutput, VEXOutput: vexOutput, diff --git a/internal/cmd/scan_repo.go b/internal/cmd/scan_repo.go index 459d788..2f5ac92 100644 --- a/internal/cmd/scan_repo.go +++ b/internal/cmd/scan_repo.go @@ -29,6 +29,7 @@ var scanRepoCmd = &cobra.Command{ $ armis-cli scan repo . --format json $ armis-cli scan repo . --format sarif --fail-on HIGH,CRITICAL $ armis-cli scan repo . --sbom --sbom-output sbom.json + $ armis-cli scan repo . --sbom --sbom-format spdx $ armis-cli scan repo . --changed $ armis-cli scan repo . --changed=staged $ armis-cli scan repo . --changed=main`, @@ -97,6 +98,7 @@ var scanRepoCmd = &cobra.Command{ if generateSBOM || generateVEX { sbomVEXOpts := &scan.SBOMVEXOptions{ GenerateSBOM: generateSBOM, + SBOMFormat: sbomFormat, GenerateVEX: generateVEX, SBOMOutput: sbomOutput, VEXOutput: vexOutput, diff --git a/internal/cmd/scan_test.go b/internal/cmd/scan_test.go index a3d889f..50f7b75 100644 --- a/internal/cmd/scan_test.go +++ b/internal/cmd/scan_test.go @@ -309,6 +309,7 @@ func TestScanPersistentPreRunE(t *testing.T) { // Save original values originalFormat := format originalGroupBy := groupBy + originalSBOMFormat := sbomFormat originalColorFlag := colorFlag originalThemeFlag := themeFlag originalNoUpdateCheck := noUpdateCheck @@ -316,6 +317,7 @@ func TestScanPersistentPreRunE(t *testing.T) { t.Cleanup(func() { format = originalFormat groupBy = originalGroupBy + sbomFormat = originalSBOMFormat colorFlag = originalColorFlag themeFlag = originalThemeFlag noUpdateCheck = originalNoUpdateCheck @@ -432,6 +434,55 @@ func TestScanPersistentPreRunE(t *testing.T) { } }) + t.Run("valid sbom-format cyclonedx", func(t *testing.T) { + format = testFormatHuman + groupBy = testGroupByNone + sbomFormat = "cyclonedx" + + if err := scanCmd.PersistentPreRunE(scanCmd, []string{}); err != nil { + t.Errorf("expected no error for valid sbom-format 'cyclonedx', got: %v", err) + } + }) + + t.Run("valid sbom-format spdx", func(t *testing.T) { + format = testFormatHuman + groupBy = testGroupByNone + sbomFormat = "spdx" + + if err := scanCmd.PersistentPreRunE(scanCmd, []string{}); err != nil { + t.Errorf("expected no error for valid sbom-format 'spdx', got: %v", err) + } + }) + + t.Run("sbom-format is normalized to lowercase", func(t *testing.T) { + format = testFormatHuman + groupBy = testGroupByNone + sbomFormat = "SPDX" + + if err := scanCmd.PersistentPreRunE(scanCmd, []string{}); err != nil { + t.Errorf("expected no error for 'SPDX', got: %v", err) + } + if sbomFormat != "spdx" { + t.Errorf("expected sbomFormat normalized to 'spdx', got: %q", sbomFormat) + } + }) + + t.Run("invalid sbom-format returns error", func(t *testing.T) { + format = testFormatHuman + groupBy = testGroupByNone + sbomFormat = testInvalidValue + + err := scanCmd.PersistentPreRunE(scanCmd, []string{}) + if err == nil { + t.Error("expected error for invalid sbom-format 'invalid'") + } + if err != nil && !testutil.ContainsSubstring(err.Error(), "invalid --sbom-format value") { + t.Errorf("error message should contain 'invalid --sbom-format value', got: %v", err) + } + // Reset to a valid value so later subtests aren't affected. + sbomFormat = "cyclonedx" + }) + t.Run("chains to root PreRunE", func(t *testing.T) { // Test that root's color validation is called format = testFormatHuman diff --git a/internal/model/finding.go b/internal/model/finding.go index 313be5e..2092af6 100644 --- a/internal/model/finding.go +++ b/internal/model/finding.go @@ -125,12 +125,15 @@ type PresignedUploadResponse struct { // IngestScanStartRequest is the JSON body for POST /api/v1/ingest/scan. type IngestScanStartRequest struct { - ScanID string `json:"scan_id"` - TenantID string `json:"tenant_id"` - ScanType string `json:"scan_type"` - SBOMGenerate bool `json:"sbom_generate"` - VEXGenerate bool `json:"vex_generate"` - CustomScans []string `json:"custom_scans,omitempty"` + ScanID string `json:"scan_id"` + TenantID string `json:"tenant_id"` + ScanType string `json:"scan_type"` + SBOMGenerate bool `json:"sbom_generate"` + // SBOMFormat selects the generated SBOM serialization ("cyclonedx" | + // "spdx"). Omitted when empty so older backends default to CycloneDX. + SBOMFormat string `json:"sbom_format,omitempty"` + VEXGenerate bool `json:"vex_generate"` + CustomScans []string `json:"custom_scans,omitempty"` } // IngestStatusData represents the status information for a scan ingestion. diff --git a/internal/scan/image/image.go b/internal/scan/image/image.go index 9528972..c1d95d2 100644 --- a/internal/scan/image/image.go +++ b/internal/scan/image/image.go @@ -172,6 +172,7 @@ func (s *Scanner) ScanTarball(ctx context.Context, tarballPath string) (*model.S } if s.sbomVEXOpts != nil { ingestOpts.GenerateSBOM = s.sbomVEXOpts.GenerateSBOM + ingestOpts.SBOMFormat = s.sbomVEXOpts.SBOMFormat ingestOpts.GenerateVEX = s.sbomVEXOpts.GenerateVEX } diff --git a/internal/scan/repo/repo.go b/internal/scan/repo/repo.go index cf664c1..34b50d5 100644 --- a/internal/scan/repo/repo.go +++ b/internal/scan/repo/repo.go @@ -213,6 +213,7 @@ func (s *Scanner) Scan(ctx context.Context, path string) (*model.ScanResult, err } if s.sbomVEXOpts != nil { ingestOpts.GenerateSBOM = s.sbomVEXOpts.GenerateSBOM + ingestOpts.SBOMFormat = s.sbomVEXOpts.SBOMFormat ingestOpts.GenerateVEX = s.sbomVEXOpts.GenerateVEX } diff --git a/internal/scan/sbom_vex.go b/internal/scan/sbom_vex.go index dbfe9e1..77e5121 100644 --- a/internal/scan/sbom_vex.go +++ b/internal/scan/sbom_vex.go @@ -15,13 +15,23 @@ import ( // Result key constants for SBOM/VEX API responses. const ( - ResultKeySBOM = "sbom_results" - ResultKeyVEX = "vex_results" + ResultKeySBOM = "sbom_results" // CycloneDX SBOM (default) + ResultKeySBOMSPDX = "sbom_spdx_results" // SPDX 2.3 SBOM (--sbom-format spdx) + ResultKeyVEX = "vex_results" +) + +// SBOM format identifiers accepted by --sbom-format. These mirror the +// backend SbomFormat enum (Project-Moose); the values are sent verbatim in +// the ingest request's sbom_format field. +const ( + SBOMFormatCycloneDX = "cyclonedx" + SBOMFormatSPDX = "spdx" ) // SBOMVEXOptions configures SBOM and VEX generation during scans. type SBOMVEXOptions struct { GenerateSBOM bool // Request SBOM generation + SBOMFormat string // SBOM serialization: "cyclonedx" (default) | "spdx" GenerateVEX bool // Request VEX generation SBOMOutput string // Output path for SBOM file (empty = default) VEXOutput string // Output path for VEX file (empty = default) @@ -63,13 +73,21 @@ func (d *SBOMVEXDownloader) Download(ctx context.Context, scanID, artifactName s return fmt.Errorf("artifact results not available") } - // Handle SBOM download + // Handle SBOM download. SPDX and CycloneDX land under distinct result + // keys (the backend can persist both), so pick the key and the default + // filename by the requested format. if d.opts.GenerateSBOM { - sbomURL, ok := results.Results[ResultKeySBOM] + resultKey := ResultKeySBOM + defaultName := sanitizedName + "-sbom.json" + if d.opts.SBOMFormat == SBOMFormatSPDX { + resultKey = ResultKeySBOMSPDX + defaultName = sanitizedName + "-sbom.spdx.json" + } + sbomURL, ok := results.Results[resultKey] if ok && sbomURL != "" { outputPath := d.opts.SBOMOutput if outputPath == "" { - outputPath = filepath.Join(".armis", sanitizedName+"-sbom.json") + outputPath = filepath.Join(".armis", defaultName) } if err := d.downloadAndSave(ctx, sbomURL, outputPath, "SBOM"); err != nil { cli.PrintWarningf("%v", err) diff --git a/internal/scan/sbom_vex_test.go b/internal/scan/sbom_vex_test.go index a708bec..c604a5b 100644 --- a/internal/scan/sbom_vex_test.go +++ b/internal/scan/sbom_vex_test.go @@ -17,9 +17,20 @@ func TestResultKeyConstants(t *testing.T) { if ResultKeySBOM != "sbom_results" { t.Errorf("ResultKeySBOM = %q, want %q", ResultKeySBOM, "sbom_results") } + // Locked to the backend Project-Moose contract (PPSC-266): SPDX SBOMs are + // persisted under a distinct ref so they can coexist with the CycloneDX one. + if ResultKeySBOMSPDX != "sbom_spdx_results" { + t.Errorf("ResultKeySBOMSPDX = %q, want %q", ResultKeySBOMSPDX, "sbom_spdx_results") + } if ResultKeyVEX != "vex_results" { t.Errorf("ResultKeyVEX = %q, want %q", ResultKeyVEX, "vex_results") } + if SBOMFormatCycloneDX != "cyclonedx" { + t.Errorf("SBOMFormatCycloneDX = %q, want %q", SBOMFormatCycloneDX, "cyclonedx") + } + if SBOMFormatSPDX != "spdx" { + t.Errorf("SBOMFormatSPDX = %q, want %q", SBOMFormatSPDX, "spdx") + } } func TestSBOMVEXOptions(t *testing.T) { @@ -233,6 +244,111 @@ func TestSBOMVEXDownloader_Download(t *testing.T) { } }) + t.Run("downloads SPDX SBOM from sbom_spdx_results key", func(t *testing.T) { + spdxContent := []byte(`{"spdxVersion": "SPDX-2.3"}`) + + tmpDir := t.TempDir() + spdxPath := filepath.Join(tmpDir, "test-sbom.spdx.json") + + var serverURL string + callCount := 0 + server := testutil.NewTestServer(t, func(w http.ResponseWriter, _ *http.Request) { + callCount++ + if callCount == 1 { + // The backend advertises the SPDX document under its own ref and + // leaves sbom_results unset. The downloader must read the SPDX key. + response := api.ArtifactScanResultsResponse{ + ScanStatus: "COMPLETED", + Results: map[string]string{ + "sbom_spdx_results": serverURL + "/download/spdx", + }, + } + testutil.JSONResponse(t, w, http.StatusOK, response) + } else { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(spdxContent) + } + }) + serverURL = server.URL + + httpClient := httpclient.NewClient(httpclient.Config{Timeout: 5 * time.Second}) + client, err := api.NewClient(server.URL, testutil.NewTestAuthProvider("token123"), false, 0, api.WithHTTPClient(httpClient), api.WithAllowLocalURLs(true)) + if err != nil { + t.Fatalf("NewClient failed: %v", err) + } + + opts := &SBOMVEXOptions{ + GenerateSBOM: true, + SBOMFormat: SBOMFormatSPDX, + SBOMOutput: spdxPath, + } + + downloader := NewSBOMVEXDownloader(client, "tenant-123", opts) + if err := downloader.Download(context.Background(), "scan-456", "test-artifact"); err != nil { + t.Fatalf("Download failed: %v", err) + } + + data, err := os.ReadFile(spdxPath) //nolint:gosec // test file path from t.TempDir() + if err != nil { + t.Fatalf("Failed to read SPDX file: %v", err) + } + if string(data) != string(spdxContent) { + t.Errorf("SPDX content mismatch: got %s, want %s", data, spdxContent) + } + }) + + t.Run("SPDX uses .spdx.json default filename", func(t *testing.T) { + spdxContent := []byte(`{"spdxVersion": "SPDX-2.3"}`) + + tmpDir := t.TempDir() + oldDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + defer func() { _ = os.Chdir(oldDir) }() + + var serverURL string + callCount := 0 + server := testutil.NewTestServer(t, func(w http.ResponseWriter, _ *http.Request) { + callCount++ + if callCount == 1 { + response := api.ArtifactScanResultsResponse{ + ScanStatus: "COMPLETED", + Results: map[string]string{ + "sbom_spdx_results": serverURL + "/download/spdx", + }, + } + testutil.JSONResponse(t, w, http.StatusOK, response) + } else { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(spdxContent) + } + }) + serverURL = server.URL + + httpClient := httpclient.NewClient(httpclient.Config{Timeout: 5 * time.Second}) + client, err := api.NewClient(server.URL, testutil.NewTestAuthProvider("token123"), false, 0, api.WithHTTPClient(httpClient), api.WithAllowLocalURLs(true)) + if err != nil { + t.Fatalf("NewClient failed: %v", err) + } + + opts := &SBOMVEXOptions{ + GenerateSBOM: true, + SBOMFormat: SBOMFormatSPDX, + SBOMOutput: "", // Empty = use default + } + + downloader := NewSBOMVEXDownloader(client, "tenant-123", opts) + if err := downloader.Download(context.Background(), "scan-456", "my-artifact"); err != nil { + t.Fatalf("Download failed: %v", err) + } + + expectedPath := filepath.Join(".armis", "my-artifact-sbom.spdx.json") + if _, err := os.Stat(expectedPath); os.IsNotExist(err) { + t.Errorf("Expected SPDX file at default path %s", expectedPath) + } + }) + t.Run("returns error when artifact results not available", func(t *testing.T) { server := testutil.NewTestServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) diff --git a/internal/testutil/mockapi.go b/internal/testutil/mockapi.go index 410c8d4..842596f 100644 --- a/internal/testutil/mockapi.go +++ b/internal/testutil/mockapi.go @@ -28,6 +28,13 @@ type MockAPIConfig struct { // VEXContent, when non-empty, makes GET /api/v1/ingest/results return a // vex_results presigned URL that serves this content (used by `scan sbom`). VEXContent string + // SBOMContent, when non-empty, makes GET /api/v1/ingest/results return a + // sbom_results presigned URL serving this CycloneDX document. + SBOMContent string + // SBOMSPDXContent, when non-empty, makes GET /api/v1/ingest/results return + // a sbom_spdx_results presigned URL serving this SPDX document. Mirrors the + // backend contract where SPDX lands under its own ref (PPSC-266). + SBOMSPDXContent string } // NewMockScanServer creates a mock Armis API server for integration testing. @@ -178,6 +185,12 @@ func createMockHandler(t *testing.T, config MockAPIConfig) http.HandlerFunc { if config.VEXContent != "" { results["vex_results"] = SchemeFromRequest(r) + "://" + r.Host + "/_vex/" + config.ScanID } + if config.SBOMContent != "" { + results["sbom_results"] = SchemeFromRequest(r) + "://" + r.Host + "/_sbom/" + config.ScanID + } + if config.SBOMSPDXContent != "" { + results["sbom_spdx_results"] = SchemeFromRequest(r) + "://" + r.Host + "/_sbom_spdx/" + config.ScanID + } JSONResponse(t, w, http.StatusOK, map[string]interface{}{ "scan_status": "COMPLETED", "results": results, @@ -192,6 +205,18 @@ func createMockHandler(t *testing.T, config MockAPIConfig) http.HandlerFunc { return } + // Fake presigned-URL download endpoints for the SBOM documents. + if strings.HasPrefix(r.URL.Path, "/_sbom_spdx/") && r.Method == http.MethodGet { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(config.SBOMSPDXContent)) + return + } + if strings.HasPrefix(r.URL.Path, "/_sbom/") && r.Method == http.MethodGet { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(config.SBOMContent)) + return + } + // GET /api/v1/ingest/normalized-results - Fetch results endpoint if strings.Contains(r.URL.Path, "/api/v1/ingest/normalized-results") && r.Method == http.MethodGet { JSONResponse(t, w, http.StatusOK, model.NormalizedResultsResponse{