diff --git a/command/execute.go b/command/execute.go index 308c159c3..1e303858d 100644 --- a/command/execute.go +++ b/command/execute.go @@ -28,7 +28,7 @@ import ( shelllocalpostprocessor "github.com/hashicorp/packer/post-processor/shell-local" breakpointprovisioner "github.com/hashicorp/packer/provisioner/breakpoint" fileprovisioner "github.com/hashicorp/packer/provisioner/file" - hcp_sbomprovisioner "github.com/hashicorp/packer/provisioner/hcp_sbom" + hcpsbomprovisioner "github.com/hashicorp/packer/provisioner/hcp-sbom" powershellprovisioner "github.com/hashicorp/packer/provisioner/powershell" shellprovisioner "github.com/hashicorp/packer/provisioner/shell" shelllocalprovisioner "github.com/hashicorp/packer/provisioner/shell-local" @@ -49,7 +49,7 @@ var Builders = map[string]packersdk.Builder{ var Provisioners = map[string]packersdk.Provisioner{ "breakpoint": new(breakpointprovisioner.Provisioner), "file": new(fileprovisioner.Provisioner), - "hcp_sbom": new(hcp_sbomprovisioner.Provisioner), + "hcp-sbom": new(hcpsbomprovisioner.Provisioner), "powershell": new(powershellprovisioner.Provisioner), "shell": new(shellprovisioner.Provisioner), "shell-local": new(shelllocalprovisioner.Provisioner), diff --git a/go.mod b/go.mod index 408105211..2a5cdb6f6 100644 --- a/go.mod +++ b/go.mod @@ -40,7 +40,7 @@ require ( github.com/packer-community/winrmcp v0.0.0-20180921211025-c76d91c1e7db // indirect github.com/pkg/sftp v1.13.2 // indirect github.com/posener/complete v1.2.3 - github.com/stretchr/testify v1.8.4 + github.com/stretchr/testify v1.9.0 github.com/ulikunitz/xz v0.5.10 github.com/zclconf/go-cty v1.13.3 github.com/zclconf/go-cty-yaml v1.0.1 @@ -58,10 +58,12 @@ require ( ) require ( + github.com/CycloneDX/cyclonedx-go v0.9.1 github.com/go-openapi/strfmt v0.21.10 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 ( @@ -78,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 74fb78b0f..7703c4973 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym github.com/ChrisTrenkamp/goxpath v0.0.0-20170922090931-c385f95c6022/go.mod h1:nuWgzSkT5PnyOd+272uUmV0dnAnAn42Mk7PiQC5VzN4= github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6 h1:w0E0fgc1YafGEh5cROhlROMWXiNoZqApk2PDN0M1+Ns= github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6/go.mod h1:nuWgzSkT5PnyOd+272uUmV0dnAnAn42Mk7PiQC5VzN4= +github.com/CycloneDX/cyclonedx-go v0.9.1 h1:yffaWOZsv77oTJa/SdVZYdgAgFioCeycBUKkqS2qzQM= +github.com/CycloneDX/cyclonedx-go v0.9.1/go.mod h1:NE/EWvzELOFlG6+ljX/QeMlVt9VKcTwu8u0ccsACEsw= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= @@ -38,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= @@ -78,6 +82,8 @@ github.com/biogo/hts v1.4.3 h1:vir2yUTiRkPvtp6ZTpzh9lWTKQJZXJKZ563rpAQAsRM= github.com/biogo/hts v1.4.3/go.mod h1:eW40HJ1l2ExK9C+yvvoRSftInqWsf3ue+zAEjzCGWjA= github.com/bmatcuk/doublestar v1.1.5 h1:2bNwBOmhyFEFcoB3tGvTD5xanq+4kyOZlB8wFYbMjkk= github.com/bmatcuk/doublestar v1.1.5/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= +github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= +github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/cenkalti/backoff/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTxfm6M= github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= @@ -495,13 +501,17 @@ 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= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -513,8 +523,11 @@ 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 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 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= +github.com/terminalstatic/go-xsd-validate v0.1.5/go.mod h1:18lsvYFofBflqCrvo1umpABZ99+GneNTw2kEEc8UPJw= github.com/tklauser/go-sysconf v0.3.11 h1:89WgdJhk5SNwJfu+GKyYveZ4IaJ7xAkecBo+KdJV0CM= github.com/tklauser/go-sysconf v0.3.11/go.mod h1:GqXfhXY3kiPa0nAXPDIQIWzJbMCB7AmcWpGR8lSZfqI= github.com/tklauser/numcpus v0.6.0 h1:kebhY2Qt+3U6RNK7UqpYNA+tJ23IBEGKkB7JQBfDYms= @@ -535,6 +548,10 @@ github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3k github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg= @@ -757,3 +774,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/hcl2template/types.packer_config.go b/hcl2template/types.packer_config.go index 49ca88729..1f909aca3 100644 --- a/hcl2template/types.packer_config.go +++ b/hcl2template/types.packer_config.go @@ -516,7 +516,7 @@ func (cfg *PackerConfig) getCoreBuildProvisioner(source SourceUseBlock, pb *Prov } } - if pb.PType == "hcp_sbom" { + if pb.PType == "hcp-sbom" { provisioner = &packer.SBOMInternalProvisioner{ Provisioner: provisioner, } diff --git a/packer/build.go b/packer/build.go index c1dac8944..eade2625d 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 } @@ -304,13 +309,14 @@ func (b *CoreBuild) Run(ctx context.Context, originalUi packersdk.Ui) ([]packers return nil, err } - if len(b.Provisioners) > 0 { - for _, p := range b.Provisioners { - sbomInternalProvisioner, ok := p.Provisioner.(*SBOMInternalProvisioner) - if !ok { - continue + for _, p := range b.Provisioners { + sbomInternalProvisioner, ok := p.Provisioner.(*SBOMInternalProvisioner) + if ok { + sbom := SBOM{ + Format: sbomInternalProvisioner.SBOMFormat, + CompressedData: sbomInternalProvisioner.CompressedData, } - b.SBOMFilesCompressed = append(b.SBOMFilesCompressed, sbomInternalProvisioner.CompressedData) + b.SBOMs = append(b.SBOMs, sbom) } } diff --git a/packer/core.go b/packer/core.go index 66e41a7e8..9d574294c 100644 --- a/packer/core.go +++ b/packer/core.go @@ -297,7 +297,7 @@ func (c *Core) generateCoreBuildProvisioner(rawP *template.Provisioner, rawName } } - if rawP.Type == "hcp_sbom" { + if rawP.Type == "hcp-sbom" { provisioner = &SBOMInternalProvisioner{ Provisioner: provisioner, } diff --git a/packer/provisioner.go b/packer/provisioner.go index abd8faa88..24d670950 100644 --- a/packer/provisioner.go +++ b/packer/provisioner.go @@ -6,10 +6,11 @@ package packer import ( "context" "fmt" - "io" "log" "os" + hcpSbomProvisioner "github.com/hashicorp/packer/provisioner/hcp-sbom" + "github.com/klauspost/compress/zstd" "time" @@ -240,12 +241,14 @@ func (p *DebuggedProvisioner) Provision(ctx context.Context, ui packersdk.Ui, co return p.Provisioner.Provision(ctx, ui, comm, generatedData) } -// SBOMInternalProvisioner is a Provisioner implementation that waits until a key -// press before the provisioner is actually run. +// SBOMInternalProvisioner is a wrapper provisioner for the `hcp-sbom` provisioner +// that sets the path for SBOM file download and, after the successful execution of +// the `hcp-sbom` provisioner, compresses the SBOM and prepares the data for API +// integration. type SBOMInternalProvisioner struct { Provisioner packersdk.Provisioner - TempFileLoc string CompressedData []byte + SBOMFormat string } func (p *SBOMInternalProvisioner) ConfigSpec() hcldec.ObjectSpec { return p.ConfigSpec() } @@ -258,61 +261,77 @@ func (p *SBOMInternalProvisioner) Provision( ctx context.Context, ui packersdk.Ui, comm packersdk.Communicator, generatedData map[string]interface{}, ) error { - // Get the current working directory cwd, err := os.Getwd() if err != nil { return fmt.Errorf("failed to get current working directory for Packer SBOM: %s", err) } - // Create a temporary file in the current working directory tmpFile, err := os.CreateTemp(cwd, "packer-sbom-*.json") if err != nil { return fmt.Errorf("failed to create internal temporary file for Packer SBOM: %s", err) } - defer tmpFile.Close() + + tmpFileName := tmpFile.Name() + if err = tmpFile.Close(); err != nil { + return fmt.Errorf("failed to close temporary file for Packer SBOM %s: %s", tmpFileName, err) + } + defer func(name string) { fileRemoveErr := os.Remove(name) if fileRemoveErr != nil { log.Printf("Error removing SBOM temporary file %s: %s", name, fileRemoveErr) } - }(p.TempFileLoc) + }(tmpFile.Name()) generatedData["dst"] = tmpFile.Name() - p.TempFileLoc = tmpFile.Name() err = p.Provisioner.Provision(ctx, ui, comm, generatedData) if err != nil { return err } - compressedData, err := p.compressFile(p.TempFileLoc) + sbomFormat, err := p.getSBOMFormat(tmpFile.Name()) + if err != nil { + return err + } + + compressedData, err := p.compressFile(tmpFile.Name()) if err != nil { return err } p.CompressedData = compressedData + p.SBOMFormat = sbomFormat return nil } func (p *SBOMInternalProvisioner) compressFile(filePath string) ([]byte, error) { - sourceFile, err := os.Open(filePath) + data, err := os.ReadFile(filePath) if err != nil { - return nil, err - } - defer sourceFile.Close() - - data, err := io.ReadAll(sourceFile) - if err != nil { - return nil, err + return nil, fmt.Errorf("failed to read file %s: %w", filePath, err) } - encoder, err := zstd.NewWriter(nil) + encoder, err := zstd.NewWriter(nil, zstd.WithEncoderLevel(zstd.SpeedBestCompression)) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to create zstd encoder: %w", err) } - defer encoder.Close() compressedData := encoder.EncodeAll(data, nil) - fmt.Printf(fmt.Sprintf("SBOM file compressed successfully. Size: %d bytes", len(compressedData))) + log.Printf("SBOM file compressed successfully. Size: %d bytes\n", len(compressedData)) return compressedData, nil } + +func (p *SBOMInternalProvisioner) getSBOMFormat(filePath string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", fmt.Errorf("failed to open SBOM file %s: %w", filePath, err) + } + defer file.Close() + + format, err := hcpSbomProvisioner.ValidateSBOM(file) + if err != nil { + return "", fmt.Errorf("failed to detect SBOM format: %w", err) + } + + return format, nil +} diff --git a/provisioner/hcp-sbom/provisioner.go b/provisioner/hcp-sbom/provisioner.go new file mode 100644 index 000000000..2815c6657 --- /dev/null +++ b/provisioner/hcp-sbom/provisioner.go @@ -0,0 +1,223 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +//go:generate packer-sdc mapstructure-to-hcl2 -type Config +//go:generate packer-sdc struct-markdown + +package hcp_sbom + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log" + "os" + + "path/filepath" + + "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" +) + +type Config struct { + common.PackerConfig `mapstructure:",squash"` + + // Source is a required field that specifies the path to the SBOM file that + // needs to be downloaded. + // It can be a file path or a URL. + Source string `mapstructure:"source" required:"true"` + // Destination is an optional field that specifies the path where the SBOM + // file will be downloaded to for the user. + // The 'Destination' must be a writable location. If the destination is a file, + // the SBOM will be saved or overwritten at that path. If the destination is + // a directory, a file will be created within the directory to store the SBOM. + // Any parent directories for the destination must already exist and be + // writable by the provisioning user (generally not root), otherwise, + // a "Permission Denied" error will occur. If the source path is a file, + // it is recommended that the destination path be a file as well. + Destination string `mapstructure:"destination"` + ctx interpolate.Context +} + +type Provisioner struct { + config Config +} + +func (p *Provisioner) ConfigSpec() hcldec.ObjectSpec { + return p.config.FlatMapstructure().HCL2Spec() +} + +func (p *Provisioner) Prepare(raws ...interface{}) error { + err := config.Decode(&p.config, &config.DecodeOpts{ + PluginType: "hcp-sbom", + Interpolate: true, + InterpolateContext: &p.config.ctx, + InterpolateFilter: &interpolate.RenderFilter{ + Exclude: []string{}, + }, + }, raws...) + if err != nil { + return err + } + + var errs *packersdk.MultiError + if p.config.Source == "" { + errs = packersdk.MultiErrorAppend(errs, errors.New("source must be specified")) + } + + if errs != nil && len(errs.Errors) > 0 { + return errs + } + + return nil +} + +func (p *Provisioner) Provision( + ctx context.Context, ui packersdk.Ui, comm packersdk.Communicator, + generatedData map[string]interface{}, +) error { + log.Println("Starting to provision with `hcp-sbom` provisioner") + + if generatedData == nil { + generatedData = make(map[string]interface{}) + } + p.config.ctx.Data = generatedData + + downloadErr := p.downloadAndValidateSBOM(ui, comm, generatedData) + if downloadErr != nil { + return fmt.Errorf("failed to download SBOM file: %w", downloadErr) + } + + return nil +} + +// downloadAndValidateSBOM handles downloading SBOM files for the User and Packer. +func (p *Provisioner) downloadAndValidateSBOM( + ui packersdk.Ui, comm packersdk.Communicator, generatedData map[string]interface{}, +) error { + src, err := interpolate.Render(p.config.Source, &p.config.ctx) + if err != nil { + return fmt.Errorf("error interpolating SBOM source: %s", err) + } + + var buf bytes.Buffer + if err = comm.Download(src, &buf); err != nil { + ui.Errorf("download failed for SBOM file: %s", err) + return err + } + + reader := bytes.NewReader(buf.Bytes()) + if _, err = ValidateSBOM(reader); err != nil { + ui.Errorf("validation failed for SBOM file: %s", err) + return err + } + _, err = reader.Seek(0, io.SeekStart) + if err != nil { + return err + } + + // SBOM for Packer + pkrDst, err := p.getPackerDestination(generatedData) + if err != nil { + return fmt.Errorf("failed to get Packer SBOM destination: %s", err) + } + + err = p.writeToFile(reader, pkrDst) + if err != nil { + return fmt.Errorf("failed to download Packer SBOM: %s", err) + } + _, err = reader.Seek(0, io.SeekStart) + if err != nil { + return err + } + log.Printf("Packer SBOM file successfully downloaded to: %s\n", pkrDst) + + // SBOM for User + usrDst, err := p.getUserDestination() + if err != nil { + return fmt.Errorf("failed to determine user SBOM destination: %s", err) + } + + if usrDst != "" { + err = p.writeToFile(reader, usrDst) + if err != nil { + return fmt.Errorf("failed to download User SBOM: %s", err) + } + log.Printf("User SBOM file successfully downloaded to: %s\n", usrDst) + } + return nil +} + +// getUserDestination determines and returns the destination path for the user SBOM file. +func (p *Provisioner) getUserDestination() (string, error) { + dst := p.config.Destination + if dst == "" { + log.Println("skipped downloading user SBOM file because 'Destination' is not provided") + return "", nil + } + + dst, err := interpolate.Render(dst, &p.config.ctx) + if err != nil { + return "", fmt.Errorf("error interpolating SBOM file destination for user: %s", err) + } + + // Check if the destination exists and determine its type + info, err := os.Stat(dst) + if err != nil { + if os.IsNotExist(err) { + // If destination doesn't exist, assume it's a file path and ensure parent directories are created + dir := filepath.Dir(dst) + if err := os.MkdirAll(dir, 0755); err != nil { + return "", fmt.Errorf("failed to create destination directory for user SBOM: %s\n", err) + } + } else { + return "", fmt.Errorf("failed to stat destination for user SBOM: %s\n", err) + } + } else if info.IsDir() { + // If the destination is a directory, create a temporary file inside it + tmpFile, err := os.CreateTemp(dst, "packer-user-sbom-*.json") + if err != nil { + return "", fmt.Errorf("failed to create temporary file in user SBOM directory %s: %s", dst, err) + } + dst = tmpFile.Name() + tmpFile.Close() + } + + return dst, nil +} + +// getPackerDestination retrieves the destination path for the Packer SBOM file. +func (p *Provisioner) getPackerDestination(generatedData map[string]interface{}) (string, error) { + dst, ok := generatedData["dst"].(string) // This has been set by HCPSBOMInternalProvisioner.Provision + if !ok || dst == "" { + return "", fmt.Errorf("destination path for Packer SBOM file is not valid") + } + + // Ensure the destination directory exists + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return "", fmt.Errorf("failed to create destination directory for Packer SBOM: %w", err) + } + + return dst, nil +} + +func (p *Provisioner) writeToFile(buf *bytes.Reader, dst string) error { + // Open the destination file + f, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return fmt.Errorf("failed to open destination file for SBOM: %s", err) + } + defer f.Close() + + // Write the buffer content to the destination file + if _, err = buf.WriteTo(f); err != nil { + return err + } + + return nil +} diff --git a/provisioner/hcp_sbom/provisioner.hcl2spec.go b/provisioner/hcp-sbom/provisioner.hcl2spec.go similarity index 100% rename from provisioner/hcp_sbom/provisioner.hcl2spec.go rename to provisioner/hcp-sbom/provisioner.hcl2spec.go diff --git a/provisioner/hcp-sbom/validate.go b/provisioner/hcp-sbom/validate.go new file mode 100644 index 000000000..372d61507 --- /dev/null +++ b/provisioner/hcp-sbom/validate.go @@ -0,0 +1,112 @@ +package hcp_sbom + +import ( + "bytes" + "fmt" + + "github.com/CycloneDX/cyclonedx-go" + spdxjson "github.com/spdx/tools-golang/json" + + "io" +) + +// ErrorType represents the type of validation error. +type ErrorType string + +const ( + ParsingErr ErrorType = "parsing" + ValidationErr ErrorType = "validation" +) + +// ValidationError represents an error encountered while validating an SBOM. +type ValidationError struct { + Type ErrorType + Err error +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf(" %s error: %v", e.Type, e.Err) +} + +func (e *ValidationError) Unwrap() error { + return e.Err +} + +// ValidateCycloneDX is a validation for CycloneDX in JSON format. +func ValidateCycloneDX(content io.Reader) error { + decoder := cyclonedx.NewBOMDecoder(content, cyclonedx.BOMFileFormatJSON) + bom := new(cyclonedx.BOM) + if err := decoder.Decode(bom); err != nil { + return &ValidationError{ + Type: ParsingErr, + Err: fmt.Errorf("error parsing CycloneDX SBOM: %w", err), + } + } + + if bom.BOMFormat != "CycloneDX" { + return &ValidationError{ + Type: ValidationErr, + Err: fmt.Errorf("invalid bomFormat: %s, expected CycloneDX", bom.BOMFormat), + } + } + if bom.SpecVersion.String() == "" { + return &ValidationError{ + Type: ValidationErr, + Err: fmt.Errorf("specVersion is required"), + } + } + + return nil +} + +// ValidateSPDX is a validation for SPDX in JSON format. +func ValidateSPDX(content io.Reader) error { + doc, err := spdxjson.Read(content) + if err != nil { + return &ValidationError{ + Type: ParsingErr, + Err: fmt.Errorf("error parsing SPDX JSON file: %w", err), + } + } + + if doc.SPDXVersion == "" { + return &ValidationError{ + Type: ValidationErr, + Err: fmt.Errorf("missing SPDXVersion"), + } + } + + return nil +} + +// ValidateSBOM validates the SBOM file and returns the format of the SBOM. +func ValidateSBOM(content io.Reader) (string, error) { + var buf bytes.Buffer + if _, err := io.Copy(&buf, content); err != nil { + return "", fmt.Errorf("failed to copy content: %s", err) + } + + reader := bytes.NewReader(buf.Bytes()) + + // Try validating as SPDX + spdxErr := ValidateSPDX(reader) + if spdxErr == nil { + return "spdx", nil + } else if vErr, ok := spdxErr.(*ValidationError); ok && vErr.Type == ValidationErr { + return "", spdxErr + } + + // Reset the reader's position + if _, err := reader.Seek(0, io.SeekStart); err != nil { + return "", fmt.Errorf("failed to reset reader: %s", err) + } + + cycloneDxErr := ValidateCycloneDX(reader) + if cycloneDxErr == nil { + return "cyclonedx", nil + } else if vErr, ok := cycloneDxErr.(*ValidationError); ok && vErr.Type == ValidationErr { + return "", spdxErr + } + + return "", fmt.Errorf("error validating SBOM file: invalid SBOM format") +} diff --git a/provisioner/hcp_sbom/version/version.go b/provisioner/hcp-sbom/version/version.go similarity index 100% rename from provisioner/hcp_sbom/version/version.go rename to provisioner/hcp-sbom/version/version.go diff --git a/provisioner/hcp_sbom/provisioner.go b/provisioner/hcp_sbom/provisioner.go deleted file mode 100644 index 23984ec75..000000000 --- a/provisioner/hcp_sbom/provisioner.go +++ /dev/null @@ -1,251 +0,0 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: BUSL-1.1 - -//go:generate packer-sdc mapstructure-to-hcl2 -type Config -//go:generate packer-sdc struct-markdown - -package hcp_sbom - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "strings" - - "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" -) - -type Config struct { - common.PackerConfig `mapstructure:",squash"` - Source string `mapstructure:"source" required:"true"` - Destination string `mapstructure:"destination"` - ctx interpolate.Context -} - -type Provisioner struct { - config Config -} - -func (p *Provisioner) ConfigSpec() hcldec.ObjectSpec { - return p.config.FlatMapstructure().HCL2Spec() -} - -func (p *Provisioner) Prepare(raws ...interface{}) error { - err := config.Decode(&p.config, &config.DecodeOpts{ - PluginType: "hcp-sbom", - Interpolate: true, - InterpolateContext: &p.config.ctx, - InterpolateFilter: &interpolate.RenderFilter{ - Exclude: []string{}, - }, - }, raws...) - if err != nil { - return err - } - - var errs *packersdk.MultiError - if p.config.Source == "" { - errs = packersdk.MultiErrorAppend(errs, errors.New("source must be specified")) - } - - if errs != nil && len(errs.Errors) > 0 { - return errs - } - - return nil -} - -func (p *Provisioner) Provision( - ctx context.Context, ui packersdk.Ui, comm packersdk.Communicator, - generatedData map[string]interface{}, -) error { - ui.Say( - fmt.Sprintf("Starting to provision with hcp-sbom using source: %s", - p.config.Source, - ), - ) - - if generatedData == nil { - generatedData = make(map[string]interface{}) - } - p.config.ctx.Data = generatedData - - // Download the file for Packer - destPath, downloadErr := p.downloadSBOMForPacker(ui, comm, generatedData) - if downloadErr != nil { - return fmt.Errorf("failed to download file: %w", downloadErr) - } - - // Download the file for user - p.downloadSBOMForUser(ui, comm) - - // Validate the file - ui.Say(fmt.Sprintf("Validating SBOM file %s", destPath)) - validationErr := p.validateSBOM(ui, destPath) - if validationErr != nil { - return fmt.Errorf("failed to validate SBOM file: %w", validationErr) - } - - return nil -} - -// downloadSBOMForPacker downloads SBOM from a specified source to a local -// destination set by internal SBOM provisioner. It works with all communicators -// from packersdk. -func (p *Provisioner) downloadSBOMForPacker( - ui packersdk.Ui, comm packersdk.Communicator, generatedData map[string]interface{}, -) (string, error) { - src, err := interpolate.Render(p.config.Source, &p.config.ctx) - if err != nil { - return p.config.Destination, fmt.Errorf("error interpolating source: %s", err) - } - - // FIXME:: Do we really need this? - // Check if the source is a JSON file - if filepath.Ext(src) != ".json" { - return p.config.Destination, fmt.Errorf( - "packer SBOM source file is not a JSON file: %s", src, - ) - } - - // Download the file for Packer - desti, ok := generatedData["dst"] // this has been set by HCPSBOMInternalProvisioner.Provision - if !ok { - return "", fmt.Errorf("failed to find location for Packer SBOM file") - } - - dst := fmt.Sprintf("%v", desti) - // Ensure the destination directory exists - dir := filepath.Dir(dst) - if err := os.MkdirAll(dir, os.FileMode(0755)); err != nil { - return dst, fmt.Errorf("failed to create destination directory for Packer SBOM: %s", err) - } - - // Open the destination file - f, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) - if err != nil { - return dst, fmt.Errorf("failed to open destination file: %s", err) - } - defer f.Close() - - // Create MultiWriter for the current progress - pf := io.MultiWriter(f) - - // Download the file - ui.Say(fmt.Sprintf("Downloading SBOM file %s for Packer => %s", src, dst)) - if err = comm.Download(src, pf); err != nil { - ui.Error(fmt.Sprintf("download failed for Packer SBOM file: %s", err)) - return dst, err - } - - return dst, nil -} - -// downloadSBOMForUser downloads a SBOM from a specified source to a local -// destination given by user. It works with all communicators from packersdk. -func (p *Provisioner) downloadSBOMForUser( - ui packersdk.Ui, comm packersdk.Communicator, -) { - src, err := interpolate.Render(p.config.Source, &p.config.ctx) - if err != nil { - ui.Say(fmt.Sprintf("error interpolating source: %s", err)) - return - } - - // Determine the destination path - dst := p.config.Destination - if dst == "" { - ui.Say("skipped downloading SBOM file for user because 'Destination' is not provided") - return - } - - dst, err = interpolate.Render(dst, &p.config.ctx) - if err != nil { - ui.Say(fmt.Sprintf("error interpolating SBOM file destination: %s", err)) - return - } - - if strings.HasSuffix(dst, "/") { - info, err := os.Stat(dst) - if err != nil { - ui.Say(fmt.Sprintf("failed to stat destination for SBOM: %s", err)) - return - } - - if info.IsDir() { - tmpFile, err := os.CreateTemp(dst, "packer-user-sbom-*.json") - if err != nil { - ui.Say(fmt.Sprintf("failed to create file for Packer SBOM: %s", err)) - return - } - dst = tmpFile.Name() - tmpFile.Close() - } - } - - // Ensure the destination directory exists - dir := filepath.Dir(dst) - if err := os.MkdirAll(dir, os.FileMode(0755)); err != nil { - ui.Say(fmt.Sprintf("failed to create destination directory for Packer SBOM: %s", err)) - return - } - - // Open the destination file - f, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) - if err != nil { - ui.Say(fmt.Sprintf("failed to open destination file: %s", err)) - return - } - defer f.Close() - - // Create MultiWriter for the current progress - pf := io.MultiWriter(f) - - // Download the file - ui.Say(fmt.Sprintf("Downloading SBOM file for user %s => %s", src, dst)) - if err = comm.Download(src, pf); err != nil { - ui.Error(fmt.Sprintf("download failed for user SBOM file: %s", err)) - return - } -} - -type SBOM struct { - BomFormat string `json:"bomFormat"` - SpecVersion string `json:"specVersion"` -} - -func (p *Provisioner) validateSBOM(ui packersdk.Ui, filePath string) error { - sourceFile, err := os.Open(filePath) - if err != nil { - return err - } - defer sourceFile.Close() - - data, err := io.ReadAll(sourceFile) - if err != nil { - return err - } - - var sbom SBOM - if err := json.Unmarshal(data, &sbom); err != nil { - return fmt.Errorf("failed to unmarshal JSON: %w", err) - } - - if sbom.BomFormat != "CycloneDX" { - return fmt.Errorf("invalid bomFormat: %s", sbom.BomFormat) - } - - if sbom.SpecVersion == "" { - return fmt.Errorf("specVersion is required") - } - - return nil -} diff --git a/provisioner/hcp_sbom/provisioner_test.go b/provisioner/hcp_sbom/provisioner_test.go deleted file mode 100644 index ef4475072..000000000 --- a/provisioner/hcp_sbom/provisioner_test.go +++ /dev/null @@ -1,190 +0,0 @@ -package hcp_sbom - -import ( - "encoding/json" - "fmt" - "io" - "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) -} - -type MockCommunicator struct { - packer.Communicator -} - -func (m *MockCommunicator) Download(src string, dst io.Writer) error { - _, err := dst.Write([]byte("mock SBOM content")) - return err -} - -func TestDownloadSBOMForPacker(t *testing.T) { - ui := &MockUi{} - comm := &MockCommunicator{} - - tests := []struct { - name string - config Config - expectError bool - }{ - { - name: "Source is a dir, Dest is a dir", - config: Config{ - Source: "mock-source/", - Destination: "test-dir/", - }, - expectError: true, - }, - { - name: "Source is a json file, Destination is a dir", - config: Config{ - Source: "mock-source/sbom.json", - Destination: "test-dir/", - }, - expectError: false, - }, - { - name: "Source is a json file, Destination is a json file", - config: Config{ - Source: "mock-source/sbom.json", - Destination: "sbom.json", - }, - expectError: false, - }, - { - name: "Source is a json file, Destination is a json file in test-output-data", - config: Config{ - Source: "mock-source/sbom.json", - Destination: "test-output-data/sbom.json", - }, - expectError: false, - }, - { - name: "Source is a json file, Destination is test-output-data w/o /", - config: Config{ - Source: "mock-source/sbom.json", - Destination: "test-output-data", - }, - expectError: false, - }, - { - name: "Source is a json file, Destination is empty", - config: Config{ - Source: "mock-source/sbom.json", - }, - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - provisioner := &Provisioner{ - config: tt.config, - } - - cwd, err := os.Getwd() - if err != nil { - t.Fatalf("failed to get current working directory for Packer SBOM: %s", err) - } - - tmpFile, err := os.CreateTemp(cwd, "packer-sbom-*.json") - if err != nil { - t.Fatalf("failed to create internal temporary file for Packer SBOM: %s", err) - } - generatedData := map[string]interface{}{ - "dst": tmpFile.Name(), - } - defer tmpFile.Close() - defer os.Remove(tmpFile.Name()) - - destPath, err := provisioner.downloadSBOMForPacker(ui, comm, generatedData) - if tt.expectError { - if err == nil { - t.Fatalf("expected error, got none") - } - } else { - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - if _, err := os.Stat(destPath); os.IsNotExist(err) { - t.Fatalf("expected file to exist at %s", destPath) - } - - os.RemoveAll(destPath) - } - }) - } -} - -func TestValidateSBOM(t *testing.T) { - provisioner := &Provisioner{} - ui := &MockUi{} - - tests := []struct { - name string - sbom SBOM - expectError bool - errorMsg string - }{ - { - name: "Valid SBOM", - sbom: SBOM{ - BomFormat: "CycloneDX", - SpecVersion: "1.0", - }, - expectError: false, - }, - { - name: "Invalid BomFormat", - sbom: SBOM{ - BomFormat: "InvalidFormat", - SpecVersion: "1.0", - }, - expectError: true, - errorMsg: "invalid bomFormat: InvalidFormat", - }, - { - name: "Empty SpecVersion", - sbom: SBOM{ - BomFormat: "CycloneDX", - SpecVersion: "", - }, - expectError: true, - errorMsg: "specVersion is required", - }, - } - - 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) - } - } - }) - } -} diff --git a/website/content/partials/provisioner/hcp-sbom/Config-not-required.mdx b/website/content/partials/provisioner/hcp-sbom/Config-not-required.mdx new file mode 100644 index 000000000..eb241a06c --- /dev/null +++ b/website/content/partials/provisioner/hcp-sbom/Config-not-required.mdx @@ -0,0 +1,13 @@ + + +- `destination` (string) - Destination is an optional field that specifies the path where the SBOM + file will be downloaded to for the user. + The 'Destination' must be a writable location. If the destination is a file, + the SBOM will be saved or overwritten at that path. If the destination is + a directory, a file will be created within the directory to store the SBOM. + Any parent directories for the destination must already exist and be + writable by the provisioning user (generally not root), otherwise, + a "Permission Denied" error will occur. If the source path is a file, + it is recommended that the destination path be a file as well. + + diff --git a/website/content/partials/provisioner/hcp-sbom/Config-required.mdx b/website/content/partials/provisioner/hcp-sbom/Config-required.mdx new file mode 100644 index 000000000..2f227c2b0 --- /dev/null +++ b/website/content/partials/provisioner/hcp-sbom/Config-required.mdx @@ -0,0 +1,7 @@ + + +- `source` (string) - Source is a required field that specifies the path to the SBOM file that + needs to be downloaded. + It can be a file path or a URL. + + diff --git a/website/content/partials/provisioner/hcp_sbom/Config-not-required.mdx b/website/content/partials/provisioner/hcp_sbom/Config-not-required.mdx deleted file mode 100644 index a8019fbde..000000000 --- a/website/content/partials/provisioner/hcp_sbom/Config-not-required.mdx +++ /dev/null @@ -1,5 +0,0 @@ - - -- `destination` (string) - Destination - - diff --git a/website/content/partials/provisioner/hcp_sbom/Config-required.mdx b/website/content/partials/provisioner/hcp_sbom/Config-required.mdx deleted file mode 100644 index 0cb7e7a80..000000000 --- a/website/content/partials/provisioner/hcp_sbom/Config-required.mdx +++ /dev/null @@ -1,5 +0,0 @@ - - -- `source` (string) - Source - -