diff --git a/go.mod b/go.mod index 17fc554b6..b24877be7 100644 --- a/go.mod +++ b/go.mod @@ -63,6 +63,7 @@ require ( github.com/oklog/ulid v1.3.1 github.com/pierrec/lz4/v4 v4.1.18 github.com/shirou/gopsutil/v3 v3.23.4 + github.com/spdx/tools-golang v0.5.5 ) require ( @@ -79,6 +80,7 @@ require ( github.com/Microsoft/go-winio v0.6.1 // indirect github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect github.com/agext/levenshtein v1.2.3 // indirect + github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092 // indirect github.com/apparentlymart/go-cidr v1.0.1 // indirect github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect diff --git a/go.sum b/go.sum index 3a6673ea0..38d4e0a5b 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092 h1:aM1rlcoLz8y5B2r4tTLMiVTrMtpfY0O8EScKJxaSaEc= +github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092/go.mod h1:rYqSE9HbjzpHTI74vwPvae4ZVYZd1lue2ta6xHPdblA= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/antchfx/xmlquery v1.3.5 h1:I7TuBRqsnfFuL11ruavGm911Awx9IqSdiU6W/ztSmVw= @@ -501,6 +503,9 @@ github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2 github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= +github.com/spdx/gordf v0.0.0-20201111095634-7098f93598fb/go.mod h1:uKWaldnbMnjsSAXRurWqqrdyZen1R7kxl8TkmWk2OyM= +github.com/spdx/tools-golang v0.5.5 h1:61c0KLfAcNqAjlg6UNMdkwpMernhw3zVRwDZ2x9XOmk= +github.com/spdx/tools-golang v0.5.5/go.mod h1:MVIsXx8ZZzaRWNQpUDhC4Dud34edUYJYecciXgrw5vE= github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -520,6 +525,7 @@ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1F github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/terminalstatic/go-xsd-validate v0.1.5 h1:RqpJnf6HGE2CB/lZB1A8BYguk8uRtcvYAPLCF15qguo= @@ -769,3 +775,4 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/packer/build.go b/packer/build.go index 560bcd5b5..313fb0d38 100644 --- a/packer/build.go +++ b/packer/build.go @@ -51,13 +51,18 @@ type CoreBuild struct { l sync.Mutex prepareCalled bool - SBOMFilesCompressed [][]byte + SBOMs []SBOM +} + +type SBOM struct { + Format string + CompressedData []byte } type BuildMetadata struct { PackerVersion string Plugins map[string]PluginDetails - SBOMs [][]byte + SBOMs []SBOM } func (b *CoreBuild) getPluginsMetadata() map[string]PluginDetails { @@ -91,7 +96,7 @@ func (b *CoreBuild) GetMetadata() BuildMetadata { metadata := BuildMetadata{ PackerVersion: version.FormattedVersion(), Plugins: b.getPluginsMetadata(), - SBOMs: b.SBOMFilesCompressed, + SBOMs: b.SBOMs, } return metadata } @@ -307,7 +312,11 @@ func (b *CoreBuild) Run(ctx context.Context, originalUi packersdk.Ui) ([]packers for _, p := range b.Provisioners { sbomInternalProvisioner, ok := p.Provisioner.(*SBOMInternalProvisioner) if ok { - b.SBOMFilesCompressed = append(b.SBOMFilesCompressed, sbomInternalProvisioner.CompressedData) + sbom := SBOM{ + Format: string(sbomInternalProvisioner.SBOMFormat), + CompressedData: sbomInternalProvisioner.CompressedData, + } + b.SBOMs = append(b.SBOMs, sbom) } } diff --git a/packer/provisioner.go b/packer/provisioner.go index e44b48d8b..7e15ee80e 100644 --- a/packer/provisioner.go +++ b/packer/provisioner.go @@ -6,8 +6,11 @@ package packer import ( "context" "fmt" + "github.com/CycloneDX/cyclonedx-go" + spdxjson "github.com/spdx/tools-golang/json" "log" "os" + "strings" "github.com/klauspost/compress/zstd" @@ -244,6 +247,7 @@ func (p *DebuggedProvisioner) Provision(ctx context.Context, ui packersdk.Ui, co type SBOMInternalProvisioner struct { Provisioner packersdk.Provisioner CompressedData []byte + SBOMFormat SBOMFormat } func (p *SBOMInternalProvisioner) ConfigSpec() hcldec.ObjectSpec { return p.ConfigSpec() } @@ -288,11 +292,17 @@ func (p *SBOMInternalProvisioner) Provision( return err } + format, err := p.validateSBOM(tmpFile.Name()) + if err != nil { + return err + } + compressedData, err := p.compressFile(tmpFile.Name()) if err != nil { return err } p.CompressedData = compressedData + p.SBOMFormat = format return nil } @@ -312,3 +322,120 @@ func (p *SBOMInternalProvisioner) compressFile(filePath string) ([]byte, error) log.Printf("SBOM file compressed successfully. Size: %d bytes\n", len(compressedData)) return compressedData, nil } + +type SBOMFormat string + +const ( + CycloneDX SBOMFormat = "CycloneDX" + SPDX SBOMFormat = "SPDX" +) + +// SBOMValidator defines the interface for SBOM validation. +type SBOMValidator interface { + Validate(file *os.File) error +} + +// CycloneDxValidator validates CycloneDx SBOM files. +type CycloneDxValidator struct{} + +// Validate performs validation for CycloneDX files. +func (v *CycloneDxValidator) Validate(file *os.File) error { + decoder := cyclonedx.NewBOMDecoder(file, cyclonedx.BOMFileFormatJSON) + bom := new(cyclonedx.BOM) + if err := decoder.Decode(bom); err != nil { + return fmt.Errorf("failed to decode CycloneDX SBOM: %w", err) + } + + if bom.BOMFormat != "CycloneDX" { + return fmt.Errorf("invalid bomFormat: %s, expected CycloneDX", bom.BOMFormat) + } + if bom.SpecVersion.String() == "" { + return fmt.Errorf("specVersion is required") + } + + return nil +} + +// SPDXValidator validates SPDX SBOM files. +type SPDXValidator struct{} + +// Validate performs validation for SPDX files in JSON format. +func (v *SPDXValidator) Validate(file *os.File) error { + doc, err := spdxjson.Read(file) + if err != nil { + return fmt.Errorf("error parsing SPDX JSON file: %w", err) + } + + if doc.SPDXVersion == "" { + return fmt.Errorf("SPDX validation error: missing SPDXVersion") + } + + return nil +} + +// detectSBOMFormat reads the file and detects whether it is a CycloneDX or SPDX file. +func detectSBOMFormat(file *os.File) (SBOMFormat, error) { + // Read a few bytes of the file to determine its type + buffer := make([]byte, 512) + if _, err := file.Read(buffer); err != nil { + return "", fmt.Errorf("failed to read SBOM file: %w", err) + } + + if strings.Contains(string(buffer), "CycloneDX") { + return CycloneDX, nil + } + + if strings.Contains(string(buffer), "SPDX-") { + return SPDX, nil + } + + return "", fmt.Errorf("unsupported or unknown SBOM format") +} + +// NewSBOMValidator is a factory function that returns the appropriate validator based on the file format. +func NewSBOMValidator(format SBOMFormat) (SBOMValidator, error) { + switch format { + case CycloneDX: + return &CycloneDxValidator{}, nil + case SPDX: + return &SPDXValidator{}, nil + default: + return nil, fmt.Errorf("unsupported SBOM format: %s", format) + } +} + +// validateSBOM validates the SBOM file against supported formats (CycloneDx, SPDX). +func (p *SBOMInternalProvisioner) validateSBOM(filePath string) (SBOMFormat, error) { + // Open the SBOM file for reading + file, err := os.Open(filePath) + if err != nil { + return "", fmt.Errorf("failed to open SBOM file %s: %w", filePath, err) + } + defer file.Close() + + // Detect the format of the SBOM + format, err := detectSBOMFormat(file) + if err != nil { + return "", fmt.Errorf("failed to detect SBOM format: %w", err) + } + + // Create the appropriate validator + validator, err := NewSBOMValidator(format) + if err != nil { + return "", err + } + + // Seek back to the beginning of the file for validation + if _, err := file.Seek(0, 0); err != nil { + return "", fmt.Errorf("failed to seek SBOM file: %w", err) + } + + // Perform validation using the selected validator + err = validator.Validate(file) + if err != nil { + return "", fmt.Errorf("validation failed for %s format: %w", format, err) + } + + log.Printf(fmt.Sprintf("SBOM file %s is valid for format: %s", filePath, format)) + return format, nil +} diff --git a/provisioner/hcp_sbom/provisioner.go b/provisioner/hcp_sbom/provisioner.go index db906c83a..0c6d9925d 100644 --- a/provisioner/hcp_sbom/provisioner.go +++ b/provisioner/hcp_sbom/provisioner.go @@ -9,18 +9,15 @@ package hcp_sbom import ( "context" "errors" - "fmt" "log" "os" - "github.com/CycloneDX/cyclonedx-go" "github.com/hashicorp/hcl/v2/hcldec" "github.com/hashicorp/packer-plugin-sdk/common" packersdk "github.com/hashicorp/packer-plugin-sdk/packer" "github.com/hashicorp/packer-plugin-sdk/template/config" "github.com/hashicorp/packer-plugin-sdk/template/interpolate" - "path/filepath" ) @@ -93,35 +90,28 @@ func (p *Provisioner) Provision( p.config.ctx.Data = generatedData // Download the files - destPath, downloadErr := p.downloadSBOM(ui, comm, generatedData) + downloadErr := p.downloadSBOM(ui, comm, generatedData) if downloadErr != nil { return fmt.Errorf("failed to download SBOM file: %w", downloadErr) } - // Validate the file - log.Printf(fmt.Sprintf("Validating SBOM file: %s\n", destPath)) - validationErr := p.validateSBOM(ui, destPath) - if validationErr != nil { - return fmt.Errorf("failed to validate SBOM file: %w", validationErr) - } - return nil } // downloadSBOM handles downloading SBOM files for the User and Packer. func (p *Provisioner) downloadSBOM( ui packersdk.Ui, comm packersdk.Communicator, generatedData map[string]interface{}, -) (string, error) { +) error { // Interpolate the source path src, err := interpolate.Render(p.config.Source, &p.config.ctx) if err != nil { - return "", fmt.Errorf("error interpolating source: %s", err) + return fmt.Errorf("error interpolating source: %s", err) } // Attempt to download SBOM for User dst, err := p.getUserDestination() if err != nil { - return "", fmt.Errorf("failed to determine user SBOM destination: %s", err) + return fmt.Errorf("failed to determine user SBOM destination: %s", err) } // If User SBOM destination is valid, try to download the SBOM file @@ -129,7 +119,7 @@ func (p *Provisioner) downloadSBOM( ui.Say(fmt.Sprintf("Attempting to download SBOM file for User: %s", src)) err = p.downloadToFile(ui, comm, src, dst) if err != nil { - return "", fmt.Errorf("user SBOM download failed: %s", err) + return fmt.Errorf("user SBOM download failed: %s", err) } ui.Say(fmt.Sprintf("User SBOM file successfully downloaded to: %s", dst)) } @@ -137,16 +127,16 @@ func (p *Provisioner) downloadSBOM( // Attempt to download SBOM for Packer dst, err = p.getPackerDestination(generatedData) if err != nil { - return "", fmt.Errorf("failed to get Packer SBOM destination: %s", err) + return fmt.Errorf("failed to get Packer SBOM destination: %s", err) } err = p.downloadToFile(ui, comm, src, dst) if err != nil { - return "", fmt.Errorf("failed to download Packer SBOM: %s", err) + return fmt.Errorf("failed to download Packer SBOM: %s", err) } ui.Say(fmt.Sprintf("Packer SBOM file successfully downloaded to: %s", dst)) - return dst, nil + return nil } // getUserDestination determines and returns the destination path for the user SBOM file. @@ -219,28 +209,4 @@ func (p *Provisioner) downloadToFile(ui packersdk.Ui, comm packersdk.Communicato } return nil -} - -// validateSBOM validates CycloneDX SBOM files -func (p *Provisioner) validateSBOM(ui packersdk.Ui, filePath string) error { - sourceFile, err := os.Open(filePath) - if err != nil { - return fmt.Errorf("failed to open file %s: %w", filePath, err) - } - defer sourceFile.Close() - - decoder := cyclonedx.NewBOMDecoder(sourceFile, cyclonedx.BOMFileFormatJSON) - bom := new(cyclonedx.BOM) - if err := decoder.Decode(bom); err != nil { - return fmt.Errorf("failed to decode CycloneDX SBOM: %w", err) - } - - if bom.BOMFormat != "CycloneDX" { - return fmt.Errorf("invalid bomFormat: %s, expected CycloneDX", bom.BOMFormat) - } - if bom.SpecVersion.String() == "" { - return fmt.Errorf("specVersion is required") - } - - return nil -} +} \ No newline at end of file diff --git a/provisioner/hcp_sbom/provisioner_test.go b/provisioner/hcp_sbom/provisioner_test.go deleted file mode 100644 index 88ff6d8f6..000000000 --- a/provisioner/hcp_sbom/provisioner_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package hcp_sbom - -import ( - "encoding/json" - "fmt" - "os" - "testing" - - "github.com/hashicorp/packer-plugin-sdk/packer" -) - -type MockUi struct { - packer.Ui -} - -func (m *MockUi) Say(message string) { - fmt.Println(message) -} - -func (m *MockUi) Error(message string) { - fmt.Println("ERROR:", message) -} - -func TestValidateSBOM(t *testing.T) { - provisioner := &Provisioner{} - ui := &MockUi{} - - tests := []struct { - name string - sbom map[string]interface{} - expectError bool - errorMsg string - }{ - { - name: "Valid SBOM", - sbom: map[string]interface{}{ - "bomFormat": "CycloneDX", - "specVersion": "1.0", - }, - expectError: false, - }, - { - name: "Invalid BomFormat", - sbom: map[string]interface{}{ - "bomFormat": "InvalidFormat", - "specVersion": "1.0", - }, - expectError: true, - errorMsg: "invalid bomFormat: InvalidFormat, expected CycloneDX", - }, - { - name: "Empty SpecVersion", - sbom: map[string]interface{}{ - "bomFormat": "CycloneDX", - "specVersion": "", - }, - expectError: true, - errorMsg: "failed to decode CycloneDX SBOM: invalid specification version", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - data, _ := json.Marshal(tt.sbom) - filePath := "test-sbom.json" - os.WriteFile(filePath, data, 0644) - defer os.Remove(filePath) - - err := provisioner.validateSBOM(ui, filePath) - if tt.expectError { - if err == nil || err.Error() != tt.errorMsg { - t.Fatalf("expected error %v, got %v", tt.errorMsg, err) - } - } else { - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - } - }) - } -}