Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<artifact>-sbom.spdx.json`). Omitting the flag preserves the existing CycloneDX behavior.

### Changed

### Deprecated
Expand Down
6 changes: 5 additions & 1 deletion internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
55 changes: 55 additions & 0 deletions internal/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
Expand Down Expand Up @@ -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)
Expand Down
30 changes: 29 additions & 1 deletion internal/cmd/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -18,6 +19,7 @@ var (
groupBy string
includeFiles []string
generateSBOM bool
sbomFormat string
generateVEX bool
sbomOutput string
vexOutput string
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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/<artifact>-sbom.json)")
scanCmd.PersistentFlags().StringVar(&vexOutput, "vex-output", "", "Output file path for VEX (default: .armis/<artifact>-vex.json)")
Expand Down
4 changes: 3 additions & 1 deletion internal/cmd/scan_image.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -117,6 +118,7 @@ var scanImageCmd = &cobra.Command{
if generateSBOM || generateVEX {
sbomVEXOpts := &scan.SBOMVEXOptions{
GenerateSBOM: generateSBOM,
SBOMFormat: sbomFormat,
GenerateVEX: generateVEX,
SBOMOutput: sbomOutput,
VEXOutput: vexOutput,
Expand Down
2 changes: 2 additions & 0 deletions internal/cmd/scan_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -97,6 +98,7 @@ var scanRepoCmd = &cobra.Command{
if generateSBOM || generateVEX {
sbomVEXOpts := &scan.SBOMVEXOptions{
GenerateSBOM: generateSBOM,
SBOMFormat: sbomFormat,
GenerateVEX: generateVEX,
SBOMOutput: sbomOutput,
VEXOutput: vexOutput,
Expand Down
51 changes: 51 additions & 0 deletions internal/cmd/scan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,13 +309,15 @@ func TestScanPersistentPreRunE(t *testing.T) {
// Save original values
originalFormat := format
originalGroupBy := groupBy
originalSBOMFormat := sbomFormat
originalColorFlag := colorFlag
originalThemeFlag := themeFlag
originalNoUpdateCheck := noUpdateCheck

t.Cleanup(func() {
format = originalFormat
groupBy = originalGroupBy
sbomFormat = originalSBOMFormat
colorFlag = originalColorFlag
themeFlag = originalThemeFlag
noUpdateCheck = originalNoUpdateCheck
Expand Down Expand Up @@ -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
Expand Down
15 changes: 9 additions & 6 deletions internal/model/finding.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions internal/scan/image/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
1 change: 1 addition & 0 deletions internal/scan/repo/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
28 changes: 23 additions & 5 deletions internal/scan/sbom_vex.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading