diff --git a/command/build.go b/command/build.go index 58b548b008f..3caa0b9d842 100644 --- a/command/build.go +++ b/command/build.go @@ -315,6 +315,15 @@ Check that you are using an HCP Ready integration before trying again: artifacts.Unlock() } } + + // If the build succeeded but uploading to HCP failed, + // Packer should exit non-zero, so we re-assign the + // error to account for this case. + if hcperr != nil && err == nil { + errs.Lock() + errs.m[name] = hcperr + errs.Unlock() + } }() if cla.Debug { diff --git a/command/execute.go b/command/execute.go index 7ad74f314d4..1e303858d61 100644 --- a/command/execute.go +++ b/command/execute.go @@ -28,6 +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" + 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" @@ -48,6 +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(hcpsbomprovisioner.Provisioner), "powershell": new(powershellprovisioner.Provisioner), "shell": new(shellprovisioner.Provisioner), "shell-local": new(shelllocalprovisioner.Provisioner), diff --git a/go.mod b/go.mod index 7ffca662add..db2d02f1f29 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/hashicorp/hcp-sdk-go v0.131.0 github.com/hashicorp/packer-plugin-sdk v0.6.0 github.com/jehiah/go-strftime v0.0.0-20171201141054-1d33003b3869 - github.com/klauspost/compress v1.13.6 // indirect + github.com/klauspost/compress v1.13.6 github.com/klauspost/pgzip v1.2.5 github.com/masterzen/winrm v0.0.0-20210623064412-3b76017826b0 github.com/mattn/go-runewidth v0.0.13 // indirect @@ -57,10 +57,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 ( @@ -77,6 +79,7 @@ require ( github.com/Microsoft/go-winio v0.6.1 // indirect github.com/ProtonMail/go-crypto v1.1.3 // 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 abafd431c39..7c8fe8a5e2f 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/cenkalti/backoff/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTxfm6M= github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -490,6 +496,9 @@ github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0 github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M= 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= @@ -509,8 +518,12 @@ 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/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.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= @@ -533,6 +546,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= @@ -739,3 +756,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 cb81441d819..bf0e9636c70 100644 --- a/hcl2template/types.packer_config.go +++ b/hcl2template/types.packer_config.go @@ -573,6 +573,12 @@ func (cfg *PackerConfig) getCoreBuildProvisioner(source SourceUseBlock, pb *Prov } } + if pb.PType == "hcp-sbom" { + provisioner = &packer.SBOMInternalProvisioner{ + Provisioner: provisioner, + } + } + return packer.CoreBuildProvisioner{ PType: pb.PType, PName: pb.PName, diff --git a/internal/hcp/api/service_build.go b/internal/hcp/api/service_build.go index 946d8d08e42..a857192c34a 100644 --- a/internal/hcp/api/service_build.go +++ b/internal/hcp/api/service_build.go @@ -6,6 +6,7 @@ import ( hcpPackerAPI "github.com/hashicorp/hcp-sdk-go/clients/cloud-packer-service/stable/2023-01-01/client/packer_service" hcpPackerModels "github.com/hashicorp/hcp-sdk-go/clients/cloud-packer-service/stable/2023-01-01/models" + "github.com/hashicorp/packer/packer" ) func (c *Client) CreateBuild( @@ -93,3 +94,27 @@ func (c *Client) UpdateBuild( return resp.Payload.Build.ID, nil } + +func (c *Client) UploadSbom( + ctx context.Context, + bucketName, fingerprint string, + buildID string, + sbom packer.SBOM, +) error { + + params := hcpPackerAPI.NewPackerServiceUploadSbomParamsWithContext(ctx) + params.BuildID = buildID + params.LocationOrganizationID = c.OrganizationID + params.LocationProjectID = c.ProjectID + params.BucketName = bucketName + params.Fingerprint = fingerprint + + params.Body = &hcpPackerModels.HashicorpCloudPacker20230101UploadSbomBody{ + CompressedSbom: sbom.CompressedData, + Format: &sbom.Format, + Name: sbom.Name, + } + + _, err := c.Packer.PackerServiceUploadSbom(params, nil) + return err +} diff --git a/internal/hcp/registry/types.bucket.go b/internal/hcp/registry/types.bucket.go index 17f3e34c027..1e06439c487 100644 --- a/internal/hcp/registry/types.bucket.go +++ b/internal/hcp/registry/types.bucket.go @@ -12,6 +12,8 @@ import ( "sync" "time" + "github.com/hashicorp/packer/packer" + "github.com/hashicorp/go-multierror" hcpPackerModels "github.com/hashicorp/hcp-sdk-go/clients/cloud-packer-service/stable/2023-01-01/models" packerSDK "github.com/hashicorp/packer-plugin-sdk/packer" @@ -222,6 +224,18 @@ func (bucket *Bucket) UpdateBuildStatus( return nil } +func (bucket *Bucket) uploadSbom(ctx context.Context, buildName string, sbom packer.SBOM) error { + buildToUpdate, err := bucket.Version.Build(buildName) + if err != nil { + return err + } + + if buildToUpdate.ID == "" { + return fmt.Errorf("the build for the component %q does not have a valid id", buildName) + } + return bucket.client.UploadSbom(ctx, bucket.Name, bucket.Version.Fingerprint, buildToUpdate.ID, sbom) +} + // markBuildComplete should be called to set a build on the HCP Packer registry to DONE. // Upon a successful call markBuildComplete will publish all artifacts created by the named build, // and set the build to done. A build with no artifacts can not be set to DONE. @@ -610,7 +624,6 @@ func (bucket *Bucket) completeBuild( doneCh, ok := bucket.RunningBuilds[buildName] if !ok { log.Print("[ERROR] done build does not have an entry in the heartbeat table, state will be inconsistent.") - } else { log.Printf("[TRACE] signal stopping heartbeats") // Stop heartbeating @@ -630,6 +643,23 @@ func (bucket *Bucket) completeBuild( return packerSDKArtifacts, fmt.Errorf("build failed, not uploading artifacts") } + artifacts, err := bucket.doCompleteBuild(ctx, buildName, packerSDKArtifacts, buildErr) + if err != nil { + err := bucket.UpdateBuildStatus(ctx, buildName, hcpPackerModels.HashicorpCloudPacker20230101BuildStatusBUILDFAILED) + if err != nil { + log.Printf("[ERROR] failed to update build %q status to FAILED: %s", buildName, err) + } + } + + return artifacts, err +} + +func (bucket *Bucket) doCompleteBuild( + ctx context.Context, + buildName string, + packerSDKArtifacts []packerSDK.Artifact, + buildErr error, +) ([]packerSDK.Artifact, error) { for _, art := range packerSDKArtifacts { var sdkImages []packerSDKRegistry.Image decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ @@ -673,6 +703,13 @@ func (bucket *Bucket) completeBuild( } } + for _, sbom := range build.CompressedSboms { + err = bucket.uploadSbom(ctx, buildName, sbom) + if err != nil { + return packerSDKArtifacts, fmt.Errorf("Failed to upload sboms %s", err) + } + } + parErr := bucket.markBuildComplete(ctx, buildName) if parErr != nil { return packerSDKArtifacts, fmt.Errorf( diff --git a/internal/hcp/registry/types.builds.go b/internal/hcp/registry/types.builds.go index dc7e132762c..0ca531c2c1c 100644 --- a/internal/hcp/registry/types.builds.go +++ b/internal/hcp/registry/types.builds.go @@ -6,6 +6,8 @@ package registry import ( "fmt" + "github.com/hashicorp/packer/packer" + hcpPackerModels "github.com/hashicorp/hcp-sdk-go/clients/cloud-packer-service/stable/2023-01-01/models" packerSDKRegistry "github.com/hashicorp/packer-plugin-sdk/packer/registry/image" ) @@ -20,6 +22,8 @@ type Build struct { Artifacts map[string]packerSDKRegistry.Image Status hcpPackerModels.HashicorpCloudPacker20230101BuildStatus Metadata hcpPackerModels.HashicorpCloudPacker20230101BuildMetadata + + CompressedSboms []packer.SBOM } // NewBuildFromCloudPackerBuild converts a HashicorpCloudPackerBuild to a local build that can be tracked and diff --git a/internal/hcp/registry/types.version.go b/internal/hcp/registry/types.version.go index 0caf6229c11..819e09e4602 100644 --- a/internal/hcp/registry/types.version.go +++ b/internal/hcp/registry/types.version.go @@ -205,5 +205,8 @@ func (version *Version) AddMetadataToBuild( buildToUpdate.Metadata.Vcs = globalMetadata.Vcs buildToUpdate.Metadata.Cicd = globalMetadata.Cicd + // TODO IMO this shouldn't be metadata + buildToUpdate.CompressedSboms = buildMetadata.SBOMs + return nil } diff --git a/packer/build.go b/packer/build.go index 8b62ec53799..4a311461e96 100644 --- a/packer/build.go +++ b/packer/build.go @@ -9,6 +9,7 @@ import ( "log" "sync" + hcpPackerModels "github.com/hashicorp/hcp-sdk-go/clients/cloud-packer-service/stable/2023-01-01/models" "github.com/hashicorp/packer-plugin-sdk/common" packersdk "github.com/hashicorp/packer-plugin-sdk/packer" "github.com/hashicorp/packer-plugin-sdk/packerbuilderdata" @@ -50,11 +51,20 @@ type CoreBuild struct { onError string l sync.Mutex prepareCalled bool + + SBOMs []SBOM +} + +type SBOM struct { + Name string + Format hcpPackerModels.HashicorpCloudPacker20230101SbomFormat + CompressedData []byte } type BuildMetadata struct { PackerVersion string Plugins map[string]PluginDetails + SBOMs []SBOM } func (b *CoreBuild) getPluginsMetadata() map[string]PluginDetails { @@ -88,6 +98,7 @@ func (b *CoreBuild) GetMetadata() BuildMetadata { metadata := BuildMetadata{ PackerVersion: version.FormattedVersion(), Plugins: b.getPluginsMetadata(), + SBOMs: b.SBOMs, } return metadata } @@ -300,6 +311,18 @@ func (b *CoreBuild) Run(ctx context.Context, originalUi packersdk.Ui) ([]packers return nil, err } + for _, p := range b.Provisioners { + sbomInternalProvisioner, ok := p.Provisioner.(*SBOMInternalProvisioner) + if ok { + sbom := SBOM{ + Name: sbomInternalProvisioner.SBOMName, + Format: sbomInternalProvisioner.SBOMFormat, + CompressedData: sbomInternalProvisioner.CompressedData, + } + b.SBOMs = append(b.SBOMs, sbom) + } + } + // If there was no result, don't worry about running post-processors // because there is nothing they can do, just return. if builderArtifact == nil { diff --git a/packer/core.go b/packer/core.go index 6bff2df060a..f6724cda9ef 100644 --- a/packer/core.go +++ b/packer/core.go @@ -296,6 +296,13 @@ func (c *Core) generateCoreBuildProvisioner(rawP *template.Provisioner, rawName Provisioner: provisioner, } } + + if rawP.Type == "hcp-sbom" { + provisioner = &SBOMInternalProvisioner{ + Provisioner: provisioner, + } + } + cbp = CoreBuildProvisioner{ PType: rawP.Type, Provisioner: provisioner, diff --git a/packer/provisioner.go b/packer/provisioner.go index 81dce0ecfc0..4be4f99ddee 100644 --- a/packer/provisioner.go +++ b/packer/provisioner.go @@ -5,8 +5,16 @@ package packer import ( "context" + "encoding/json" "fmt" "log" + "os" + + hcpSbomProvisioner "github.com/hashicorp/packer/provisioner/hcp-sbom" + + hcpPackerModels "github.com/hashicorp/hcp-sdk-go/clients/cloud-packer-service/stable/2023-01-01/models" + "github.com/klauspost/compress/zstd" + "time" "github.com/hashicorp/hcl/v2/hcldec" @@ -234,3 +242,75 @@ func (p *DebuggedProvisioner) Provision(ctx context.Context, ui packersdk.Ui, co return p.Provisioner.Provision(ctx, ui, comm, generatedData) } + +// 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 + CompressedData []byte + SBOMFormat hcpPackerModels.HashicorpCloudPacker20230101SbomFormat + SBOMName string +} + +func (p *SBOMInternalProvisioner) ConfigSpec() hcldec.ObjectSpec { return p.ConfigSpec() } +func (p *SBOMInternalProvisioner) FlatConfig() interface{} { return p.FlatConfig() } +func (p *SBOMInternalProvisioner) Prepare(raws ...interface{}) error { + return p.Provisioner.Prepare(raws...) +} + +func (p *SBOMInternalProvisioner) Provision( + ctx context.Context, ui packersdk.Ui, comm packersdk.Communicator, + generatedData map[string]interface{}, +) error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current working directory for Packer SBOM: %s", err) + } + + 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) + } + + 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) + } + }(tmpFile.Name()) + + generatedData["dst"] = tmpFile.Name() + + err = p.Provisioner.Provision(ctx, ui, comm, generatedData) + if err != nil { + return err + } + + packerSbom, err := os.Open(tmpFileName) + if err != nil { + return fmt.Errorf("failed to open Packer SBOM file %q: %s", tmpFileName, err) + } + + provisionerOut := &hcpSbomProvisioner.PackerSBOM{} + err = json.NewDecoder(packerSbom).Decode(provisionerOut) + if err != nil { + return fmt.Errorf("malformed packer SBOM output from file %q: %s", tmpFileName, err) + } + + encoder, err := zstd.NewWriter(nil, zstd.WithEncoderLevel(zstd.SpeedBestCompression)) + if err != nil { + return fmt.Errorf("failed to create zstd encoder: %s", err) + } + p.CompressedData = encoder.EncodeAll(provisionerOut.RawSBOM, nil) + p.SBOMFormat = provisionerOut.Format + p.SBOMName = provisionerOut.Name + + return nil +} diff --git a/packer_test/provisioner_tests/hcp-sbom/provisioner_test.go b/packer_test/provisioner_tests/hcp-sbom/provisioner_test.go new file mode 100644 index 00000000000..81f5a8e06ea --- /dev/null +++ b/packer_test/provisioner_tests/hcp-sbom/provisioner_test.go @@ -0,0 +1,151 @@ +package plugin_tests + +import ( + "os" + + "github.com/hashicorp/packer/packer_test/common/check" +) + +func (ts *PackerHCPSbomTestSuite) TestSourceNotExisting() { + ts.SkipNoAcc() + + dir := ts.MakePluginDir() + defer dir.Cleanup() + + ts.PackerCommand().UsePluginDir(dir). + SetArgs("plugins", "install", "github.com/hashicorp/docker"). + Assert(check.MustSucceed()) + + ts.PackerCommand().UsePluginDir(dir). + AddEnv("HOME", os.Getenv("HOME")). + AddEnv("PATH", os.Getenv("PATH")). + SetArgs("build", "templates/source_not_existing.pkr.hcl"). + Assert(check.MustFail(), check.Grep("download failed for SBOM file")) +} + +// Greayed out because the communicator for the docker plugin does not return an error +// when downloading a full directory, instead it returns a 0-byte stream without an error. +// +// So the sbom provisioner fails with a validation error instead of a file not found type +// of error. +// +// func (ts *PackerHCPSbomTestSuite) TestSourceIsDir() { +// ts.SkipNoAcc() +// +// path, cleanup := ts.MakePluginDir() +// defer cleanup() +// +// ts.PackerCommand().UsePluginDir(path). +// SetArgs("plugins", "install", "github.com/hashicorp/docker"). +// Assert(check.MustSucceed()) +// +// ts.PackerCommand().UsePluginDir(path). +// SetArgs("build", "templates/source_is_dir.pkr.hcl"). +// Assert(check.MustFail(), check.Grep("download failed for SBOM file"), check.Dump(ts.T())) +// } + +// * output file - does not exist, and intermediate dirs don't exist +func (ts *PackerHCPSbomTestSuite) TestDestFile_NoIntermediateDirs() { + ts.SkipNoAcc() + + dir := ts.MakePluginDir() + defer dir.Cleanup() + + ts.PackerCommand().UsePluginDir(dir). + SetArgs("plugins", "install", "github.com/hashicorp/docker"). + Assert(check.MustSucceed()) + + ts.PackerCommand().UsePluginDir(dir). + AddEnv("HOME", os.Getenv("HOME")). + AddEnv("PATH", os.Getenv("PATH")). + SetArgs("build", "./templates/dest_is_file_no_interm_dirs.pkr.hcl"). + Assert(check.MustSucceed(), check.FileExists("sbom/sbom_cyclonedx.json", false)) + + os.RemoveAll("sbom") +} + +// * output file - does not exist, and intermediate dirs already exist +func (ts *PackerHCPSbomTestSuite) TestDestFile_WithIntermediateDirs() { + ts.SkipNoAcc() + + dir := ts.MakePluginDir() + defer dir.Cleanup() + + os.MkdirAll("sbom", 0755) + + ts.PackerCommand().UsePluginDir(dir). + SetArgs("plugins", "install", "github.com/hashicorp/docker"). + Assert(check.MustSucceed()) + + ts.PackerCommand().UsePluginDir(dir). + AddEnv("HOME", os.Getenv("HOME")). + AddEnv("PATH", os.Getenv("PATH")). + SetArgs("build", "./templates/dest_is_file_no_interm_dirs.pkr.hcl"). + Assert(check.MustSucceed(), check.FileExists("sbom/sbom_cyclonedx.json", false)) + + os.RemoveAll("sbom") +} + +// * output directory (without trailing slash) - directory exists +func (ts *PackerHCPSbomTestSuite) TestDestDir_NoTrailingSlash() { + ts.SkipNoAcc() + + dir := ts.MakePluginDir() + defer dir.Cleanup() + + os.MkdirAll("sbom", 0755) + + ts.PackerCommand().UsePluginDir(dir). + SetArgs("plugins", "install", "github.com/hashicorp/docker"). + Assert(check.MustSucceed()) + + ts.PackerCommand().UsePluginDir(dir). + AddEnv("HOME", os.Getenv("HOME")). + AddEnv("PATH", os.Getenv("PATH")). + SetArgs("build", "./templates/dest_is_dir.pkr.hcl"). + Assert(check.MustSucceed(), check.FileGlob("./sbom/packer-user-sbom-*.json")) + + os.RemoveAll("sbom") +} + +// * output directory (with trailing slash) - directory exists +func (ts *PackerHCPSbomTestSuite) TestDestDir_WithTrailingSlash() { + ts.SkipNoAcc() + + dir := ts.MakePluginDir() + defer dir.Cleanup() + + os.MkdirAll("sbom", 0755) + + ts.PackerCommand().UsePluginDir(dir). + SetArgs("plugins", "install", "github.com/hashicorp/docker"). + Assert(check.MustSucceed()) + + ts.PackerCommand().UsePluginDir(dir). + AddEnv("HOME", os.Getenv("HOME")). + AddEnv("PATH", os.Getenv("PATH")). + SetArgs("build", "./templates/dest_is_dir_with_trailing_slash.pkr.hcl"). + Assert(check.MustSucceed(), check.FileGlob("./sbom/packer-user-sbom-*.json")) + + os.RemoveAll("sbom") +} + +// * output directory (with trailing slash) - directory doesn't exist +func (ts *PackerHCPSbomTestSuite) TestDestDir_WithTrailingSlash_NoDir() { + ts.SkipNoAcc() + + dir := ts.MakePluginDir() + defer dir.Cleanup() + + ts.PackerCommand().UsePluginDir(dir). + SetArgs("plugins", "install", "github.com/hashicorp/docker"). + Assert(check.MustSucceed()) + + ts.PackerCommand().UsePluginDir(dir). + AddEnv("HOME", os.Getenv("HOME")). + AddEnv("PATH", os.Getenv("PATH")). + SetArgs("build", "./templates/dest_is_dir_with_trailing_slash.pkr.hcl"). + Assert(check.MustSucceed(), check.FileGlob("./sbom/packer-user-sbom-*.json")) + + os.RemoveAll("sbom") +} diff --git a/packer_test/provisioner_tests/hcp-sbom/suite_test.go b/packer_test/provisioner_tests/hcp-sbom/suite_test.go new file mode 100644 index 00000000000..a3855ebb660 --- /dev/null +++ b/packer_test/provisioner_tests/hcp-sbom/suite_test.go @@ -0,0 +1,23 @@ +package plugin_tests + +import ( + "testing" + + "github.com/hashicorp/packer/packer_test/common" + "github.com/stretchr/testify/suite" +) + +type PackerHCPSbomTestSuite struct { + *common.PackerTestSuite +} + +func Test_PackerPluginSuite(t *testing.T) { + baseSuite, cleanup := common.InitBaseSuite(t) + defer cleanup() + + ts := &PackerHCPSbomTestSuite{ + baseSuite, + } + + suite.Run(t, ts) +} diff --git a/packer_test/provisioner_tests/hcp-sbom/templates/dest_is_dir.pkr.hcl b/packer_test/provisioner_tests/hcp-sbom/templates/dest_is_dir.pkr.hcl new file mode 100644 index 00000000000..1a405a50bee --- /dev/null +++ b/packer_test/provisioner_tests/hcp-sbom/templates/dest_is_dir.pkr.hcl @@ -0,0 +1,36 @@ +packer { + required_plugins { + docker = { + version = ">= 1.0.0" + source = "github.com/hashicorp/docker" + } + } +} + +source "docker" "ubuntu" { + image = "ubuntu:20.04" + commit = true +} + +build { + sources = ["source.docker.ubuntu"] + + provisioner "shell" { + inline = [ + "apt-get update -y", + "apt-get install -y curl", + "bash -c \"$(curl -sSL https://install.mondoo.com/sh)\"" + ] + } + + provisioner "shell" { + inline = [ + "cnquery sbom --output cyclonedx-json --output-target /tmp/sbom_cyclonedx.json", + ] + } + + provisioner "hcp-sbom" { + source = "/tmp/sbom_cyclonedx.json" + destination = "./sbom" + } +} diff --git a/packer_test/provisioner_tests/hcp-sbom/templates/dest_is_dir_with_trailing_slash.pkr.hcl b/packer_test/provisioner_tests/hcp-sbom/templates/dest_is_dir_with_trailing_slash.pkr.hcl new file mode 100644 index 00000000000..9d9ca4506b1 --- /dev/null +++ b/packer_test/provisioner_tests/hcp-sbom/templates/dest_is_dir_with_trailing_slash.pkr.hcl @@ -0,0 +1,36 @@ +packer { + required_plugins { + docker = { + version = ">= 1.0.0" + source = "github.com/hashicorp/docker" + } + } +} + +source "docker" "ubuntu" { + image = "ubuntu:20.04" + commit = true +} + +build { + sources = ["source.docker.ubuntu"] + + provisioner "shell" { + inline = [ + "apt-get update -y", + "apt-get install -y curl", + "bash -c \"$(curl -sSL https://install.mondoo.com/sh)\"" + ] + } + + provisioner "shell" { + inline = [ + "cnquery sbom --output cyclonedx-json --output-target /tmp/sbom_cyclonedx.json", + ] + } + + provisioner "hcp-sbom" { + source = "/tmp/sbom_cyclonedx.json" + destination = "./sbom/" + } +} diff --git a/packer_test/provisioner_tests/hcp-sbom/templates/dest_is_file_no_interm_dirs.pkr.hcl b/packer_test/provisioner_tests/hcp-sbom/templates/dest_is_file_no_interm_dirs.pkr.hcl new file mode 100644 index 00000000000..9d4bcb2daec --- /dev/null +++ b/packer_test/provisioner_tests/hcp-sbom/templates/dest_is_file_no_interm_dirs.pkr.hcl @@ -0,0 +1,36 @@ +packer { + required_plugins { + docker = { + version = ">= 1.0.0" + source = "github.com/hashicorp/docker" + } + } +} + +source "docker" "ubuntu" { + image = "ubuntu:20.04" + commit = true +} + +build { + sources = ["source.docker.ubuntu"] + + provisioner "shell" { + inline = [ + "apt-get update -y", + "apt-get install -y curl", + "bash -c \"$(curl -sSL https://install.mondoo.com/sh)\"" + ] + } + + provisioner "shell" { + inline = [ + "cnquery sbom --output cyclonedx-json --output-target /tmp/sbom_cyclonedx.json", + ] + } + + provisioner "hcp-sbom" { + source = "/tmp/sbom_cyclonedx.json" + destination = "./sbom/sbom_cyclonedx.json" + } +} diff --git a/packer_test/provisioner_tests/hcp-sbom/templates/dest_is_file_with_interm_dirs.pkr.hcl b/packer_test/provisioner_tests/hcp-sbom/templates/dest_is_file_with_interm_dirs.pkr.hcl new file mode 100644 index 00000000000..37ccbcc3b60 --- /dev/null +++ b/packer_test/provisioner_tests/hcp-sbom/templates/dest_is_file_with_interm_dirs.pkr.hcl @@ -0,0 +1,36 @@ +packer { + required_plugins { + docker = { + version = ">= 1.0.0" + source = "github.com/hashicorp/docker" + } + } +} + +source "docker" "ubuntu" { + image = "ubuntu:20.04" + commit = true +} + +build { + sources = ["source.docker.ubuntu"] + + provisioner "shell" { + inline = [ + "apt-get update -y", + "apt-get install -y curl", + "bash -c \"$(curl -sSL https://install.mondoo.com/sh)\"" + ] + } + + provisioner "shell" { + inline = [ + "cnquery sbom --output cyclonedx-json --output-target /tmp/sbom_cyclonedx.json", + ] + } + + provisioner "hcp-sbom" { + source = "/tmp/sbom_cyclonedx.json" + destination = "./sbom/sbom_cyclonedx" + } +} diff --git a/packer_test/provisioner_tests/hcp-sbom/templates/source_is_dir.pkr.hcl b/packer_test/provisioner_tests/hcp-sbom/templates/source_is_dir.pkr.hcl new file mode 100644 index 00000000000..02522488d52 --- /dev/null +++ b/packer_test/provisioner_tests/hcp-sbom/templates/source_is_dir.pkr.hcl @@ -0,0 +1,21 @@ +packer { + required_plugins { + docker = { + version = ">= 1.0.0" + source = "github.com/hashicorp/docker" + } + } +} + +source "docker" "ubuntu" { + image = "ubuntu:20.04" + commit = true +} + +build { + sources = ["source.docker.ubuntu"] + + provisioner "hcp-sbom" { + source = "/tmp" + } +} diff --git a/packer_test/provisioner_tests/hcp-sbom/templates/source_not_existing.pkr.hcl b/packer_test/provisioner_tests/hcp-sbom/templates/source_not_existing.pkr.hcl new file mode 100644 index 00000000000..a66b9968501 --- /dev/null +++ b/packer_test/provisioner_tests/hcp-sbom/templates/source_not_existing.pkr.hcl @@ -0,0 +1,21 @@ +packer { + required_plugins { + docker = { + version = ">= 1.0.0" + source = "github.com/hashicorp/docker" + } + } +} + +source "docker" "ubuntu" { + image = "ubuntu:20.04" + commit = true +} + +build { + sources = ["source.docker.ubuntu"] + + provisioner "hcp-sbom" { + source = "/tmp/sbom_cyclonedx.json" + } +} diff --git a/provisioner/hcp-sbom/provisioner.go b/provisioner/hcp-sbom/provisioner.go new file mode 100644 index 00000000000..4371390861c --- /dev/null +++ b/provisioner/hcp-sbom/provisioner.go @@ -0,0 +1,224 @@ +// 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" + "encoding/json" + "errors" + "fmt" + "log" + "os" + "regexp" + "strings" + + "path/filepath" + + "github.com/hashicorp/hcl/v2/hcldec" + hcpPackerModels "github.com/hashicorp/hcp-sdk-go/clients/cloud-packer-service/stable/2023-01-01/models" + "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"` + + // The file path or URL to the SBOM file in the Packer artifact. + // This file must either be in the SPDX or CycloneDX format. + Source string `mapstructure:"source" required:"true"` + + // The path on the local machine to store a copy of the SBOM file. + // You can specify an absolute or a path relative to the working directory + // when you execute the Packer build. If the file already exists on the + // local machine, Packer overwrites the file. If the destination is a + // directory, the directory must already exist. + Destination string `mapstructure:"destination"` + + // The name of the SBOM file stored in HCP Packer. + // If omitted, HCP Packer uses the build fingerprint as the file name. + // This value must be between three and 36 characters from the following set: `[A-Za-z0-9_-]`. + // You must specify a unique name for each build in an artifact version. + SbomName string `mapstructure:"sbom_name"` + ctx interpolate.Context +} + +type Provisioner struct { + config Config +} + +func (p *Provisioner) ConfigSpec() hcldec.ObjectSpec { + return p.config.FlatMapstructure().HCL2Spec() +} + +var sbomFormatRegexp = regexp.MustCompile("^[0-9A-Za-z-]{3,36}$") + +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 error + + if p.config.Source == "" { + errs = packersdk.MultiErrorAppend(errs, errors.New("source must be specified")) + } + + if p.config.SbomName != "" && !sbomFormatRegexp.MatchString(p.config.SbomName) { + // Ugly but a bit of a problem with interpolation since Provisioners + // are prepared twice in HCL2. + // + // If the information used for interpolating is populated in-between the + // first call to Prepare (at the start of the build), and when the + // Provisioner is actually called, the first call will fail, as + // the value won't contain the actual interpolated value, but a + // placeholder which doesn't match the regex. + // + // Since we don't have a way to discriminate between the calls + // in the context of the provisioner, we ignore them, and later the + // HCP Packer call will fail because of the broken regex. + if strings.Contains(p.config.SbomName, "") { + log.Printf("[WARN] interpolation incomplete for `sbom_name`, will possibly retry later with data populated into context, otherwise will fail when uploading to HCP Packer.") + } else { + errs = packersdk.MultiErrorAppend(errs, fmt.Errorf("`sbom_name` %q doesn't match the expected format, it must "+ + "contain between 3 and 36 characters, all from the following set: [A-Za-z0-9_-]", p.config.SbomName)) + } + } + + return errs +} + +// PackerSBOM is the type we write to the temporary JSON dump of the SBOM to +// be consumed by Packer core +type PackerSBOM struct { + // RawSBOM is the raw data from the SBOM downloaded from the guest + RawSBOM []byte `json:"raw_sbom"` + // Format is the format detected by the provisioner + // + // Supported values: `SPDX` or `CYCLONEDX` + Format hcpPackerModels.HashicorpCloudPacker20230101SbomFormat `json:"format"` + // Name is the name of the SBOM to be set on HCP Packer + // + // If unset, HCP Packer will generate one + Name string `json:"name,omitempty"` +} + +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 + + src := p.config.Source + + pkrDst := generatedData["dst"].(string) + if pkrDst == "" { + return fmt.Errorf("packer destination path missing from configs: this is an internal error, which should be reported to be fixed.") + } + + var buf bytes.Buffer + if err := comm.Download(src, &buf); err != nil { + ui.Errorf("download failed for SBOM file: %s", err) + return err + } + + format, err := validateSBOM(buf.Bytes()) + if err != nil { + return fmt.Errorf("validation failed for SBOM file: %s", err) + } + + outFile, err := os.Create(pkrDst) + if err != nil { + return fmt.Errorf("failed to open/create output file %q: %s", pkrDst, err) + } + defer outFile.Close() + + err = json.NewEncoder(outFile).Encode(PackerSBOM{ + RawSBOM: buf.Bytes(), + Format: format, + Name: p.config.SbomName, + }) + if err != nil { + return fmt.Errorf("failed to write sbom file to %q: %s", pkrDst, err) + } + + if p.config.Destination == "" { + return nil + } + + // SBOM for User + usrDst, err := p.getUserDestination() + if err != nil { + return fmt.Errorf("failed to compute destination path %q: %s", p.config.Destination, err) + } + err = os.WriteFile(usrDst, buf.Bytes(), 0644) + if err != nil { + return fmt.Errorf("failed to write SBOM to destination %q: %s", usrDst, err) + } + + return nil +} + +// getUserDestination determines and returns the destination path for the user SBOM file. +func (p *Provisioner) getUserDestination() (string, error) { + dst := p.config.Destination + + // Check if the destination exists and determine its type + info, err := os.Stat(dst) + if err == nil { + 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 + } + + outDir := filepath.Dir(dst) + // In case the destination does not exist, we'll get the dirpath, + // and create it if it doesn't already exist + err = os.MkdirAll(outDir, 0755) + if err != nil { + return "", fmt.Errorf("failed to create destination directory for user SBOM: %s\n", err) + } + + // Check if the destination is a directory after the previous step. + // + // This happens if the path specified ends with a `/`, in which case the + // destination is a directory, and we must create a temporary file in + // this destination directory. + destStat, statErr := os.Stat(dst) + if statErr == nil && destStat.IsDir() { + tmpFile, err := os.CreateTemp(outDir, "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 +} diff --git a/provisioner/hcp-sbom/provisioner.hcl2spec.go b/provisioner/hcp-sbom/provisioner.hcl2spec.go new file mode 100644 index 00000000000..4df5397c093 --- /dev/null +++ b/provisioner/hcp-sbom/provisioner.hcl2spec.go @@ -0,0 +1,51 @@ +// Code generated by "packer-sdc mapstructure-to-hcl2"; DO NOT EDIT. + +package hcp_sbom + +import ( + "github.com/hashicorp/hcl/v2/hcldec" + "github.com/zclconf/go-cty/cty" +) + +// FlatConfig is an auto-generated flat version of Config. +// Where the contents of a field with a `mapstructure:,squash` tag are bubbled up. +type FlatConfig struct { + PackerBuildName *string `mapstructure:"packer_build_name" cty:"packer_build_name" hcl:"packer_build_name"` + PackerBuilderType *string `mapstructure:"packer_builder_type" cty:"packer_builder_type" hcl:"packer_builder_type"` + PackerCoreVersion *string `mapstructure:"packer_core_version" cty:"packer_core_version" hcl:"packer_core_version"` + PackerDebug *bool `mapstructure:"packer_debug" cty:"packer_debug" hcl:"packer_debug"` + PackerForce *bool `mapstructure:"packer_force" cty:"packer_force" hcl:"packer_force"` + PackerOnError *string `mapstructure:"packer_on_error" cty:"packer_on_error" hcl:"packer_on_error"` + PackerUserVars map[string]string `mapstructure:"packer_user_variables" cty:"packer_user_variables" hcl:"packer_user_variables"` + PackerSensitiveVars []string `mapstructure:"packer_sensitive_variables" cty:"packer_sensitive_variables" hcl:"packer_sensitive_variables"` + Source *string `mapstructure:"source" required:"true" cty:"source" hcl:"source"` + Destination *string `mapstructure:"destination" cty:"destination" hcl:"destination"` + SbomName *string `mapstructure:"sbom_name" cty:"sbom_name" hcl:"sbom_name"` +} + +// FlatMapstructure returns a new FlatConfig. +// FlatConfig is an auto-generated flat version of Config. +// Where the contents a fields with a `mapstructure:,squash` tag are bubbled up. +func (*Config) FlatMapstructure() interface{ HCL2Spec() map[string]hcldec.Spec } { + return new(FlatConfig) +} + +// HCL2Spec returns the hcl spec of a Config. +// This spec is used by HCL to read the fields of Config. +// The decoded values from this spec will then be applied to a FlatConfig. +func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec { + s := map[string]hcldec.Spec{ + "packer_build_name": &hcldec.AttrSpec{Name: "packer_build_name", Type: cty.String, Required: false}, + "packer_builder_type": &hcldec.AttrSpec{Name: "packer_builder_type", Type: cty.String, Required: false}, + "packer_core_version": &hcldec.AttrSpec{Name: "packer_core_version", Type: cty.String, Required: false}, + "packer_debug": &hcldec.AttrSpec{Name: "packer_debug", Type: cty.Bool, Required: false}, + "packer_force": &hcldec.AttrSpec{Name: "packer_force", Type: cty.Bool, Required: false}, + "packer_on_error": &hcldec.AttrSpec{Name: "packer_on_error", Type: cty.String, Required: false}, + "packer_user_variables": &hcldec.AttrSpec{Name: "packer_user_variables", Type: cty.Map(cty.String), Required: false}, + "packer_sensitive_variables": &hcldec.AttrSpec{Name: "packer_sensitive_variables", Type: cty.List(cty.String), Required: false}, + "source": &hcldec.AttrSpec{Name: "source", Type: cty.String, Required: false}, + "destination": &hcldec.AttrSpec{Name: "destination", Type: cty.String, Required: false}, + "sbom_name": &hcldec.AttrSpec{Name: "sbom_name", Type: cty.String, Required: false}, + } + return s +} diff --git a/provisioner/hcp-sbom/provisioner_test.go b/provisioner/hcp-sbom/provisioner_test.go new file mode 100644 index 00000000000..aff0323e04a --- /dev/null +++ b/provisioner/hcp-sbom/provisioner_test.go @@ -0,0 +1,86 @@ +package hcp_sbom + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/packer-plugin-sdk/template/interpolate" +) + +func TestConfigPrepare(t *testing.T) { + tests := []struct { + name string + inputConfig map[string]interface{} + interpolateContext interpolate.Context + expectConfig *Config + expectError bool + }{ + { + "empty config, should error without a source", + map[string]interface{}{}, + interpolate.Context{}, + nil, + true, + }, + { + "config with full context for interpolation: success", + map[string]interface{}{ + "source": "{{ .Name }}", + }, + interpolate.Context{ + Data: &struct { + Name string + }{ + Name: "testInterpolate", + }, + }, + &Config{ + Source: "testInterpolate", + }, + false, + }, + { + // Note: this will look weird to reviewers, but is actually + // expected for the moment. + // Refer to the comment in `Prepare` for context as to WHY + // this cannot be considered an error. + "config with sbom name as interpolated value, without it in context, replace with a placeholder", + map[string]interface{}{ + "source": "test", + "sbom_name": "{{ .Name }}", + }, + interpolate.Context{}, + &Config{ + Source: "test", + SbomName: "", + }, + false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prov := &Provisioner{} + prov.config.ctx = tt.interpolateContext + err := prov.Prepare(tt.inputConfig) + if err != nil && !tt.expectError { + t.Fatalf("configuration unexpectedly failed to prepare: %s", err) + } + + if err == nil && tt.expectError { + t.Fatalf("configuration succeeded to prepare, but should have failed") + } + + if err != nil { + t.Logf("config had error %q", err) + return + } + + diff := cmp.Diff(prov.config, *tt.expectConfig, cmpopts.IgnoreUnexported(Config{})) + if diff != "" { + t.Errorf("configuration returned by `Prepare` is different from what was expected: %s", diff) + } + }) + } +} diff --git a/provisioner/hcp-sbom/validate.go b/provisioner/hcp-sbom/validate.go new file mode 100644 index 00000000000..7343dcb9bbb --- /dev/null +++ b/provisioner/hcp-sbom/validate.go @@ -0,0 +1,86 @@ +package hcp_sbom + +import ( + "bytes" + "fmt" + "strings" + + "github.com/CycloneDX/cyclonedx-go" + hcpPackerModels "github.com/hashicorp/hcp-sdk-go/clients/cloud-packer-service/stable/2023-01-01/models" + spdxjson "github.com/spdx/tools-golang/json" +) + +// ValidationError represents an error encountered while validating an SBOM. +type ValidationError struct { + Err error +} + +func (e *ValidationError) Error() string { + return e.Err.Error() +} + +func (e *ValidationError) Unwrap() error { + return e.Err +} + +// ValidateCycloneDX is a validation for CycloneDX in JSON format. +func validateCycloneDX(content []byte) error { + decoder := cyclonedx.NewBOMDecoder(bytes.NewBuffer(content), cyclonedx.BOMFileFormatJSON) + bom := new(cyclonedx.BOM) + if err := decoder.Decode(bom); err != nil { + return fmt.Errorf("error parsing CycloneDX SBOM: %w", err) + } + + if !strings.EqualFold(bom.BOMFormat, "CycloneDX") { + return &ValidationError{ + Err: fmt.Errorf("invalid bomFormat: %q, expected CycloneDX", bom.BOMFormat), + } + } + if bom.SpecVersion.String() == "" { + return &ValidationError{ + Err: fmt.Errorf("specVersion is required"), + } + } + + return nil +} + +// validateSPDX is a validation for SPDX in JSON format. +func validateSPDX(content []byte) error { + doc, err := spdxjson.Read(bytes.NewBuffer(content)) + if err != nil { + return fmt.Errorf("error parsing SPDX JSON file: %w", err) + } + + if doc.SPDXVersion == "" { + return &ValidationError{ + Err: fmt.Errorf("missing SPDXVersion"), + } + } + + return nil +} + +// validateSBOM validates the SBOM file and returns the format of the SBOM. +func validateSBOM(content []byte) (hcpPackerModels.HashicorpCloudPacker20230101SbomFormat, error) { + // Try validating as SPDX + spdxErr := validateSPDX(content) + if spdxErr == nil { + return hcpPackerModels.HashicorpCloudPacker20230101SbomFormatSPDX, nil + } + + if vErr, ok := spdxErr.(*ValidationError); ok { + return "", vErr + } + + cycloneDxErr := validateCycloneDX(content) + if cycloneDxErr == nil { + return hcpPackerModels.HashicorpCloudPacker20230101SbomFormatCYCLONEDX, nil + } + + if vErr, ok := cycloneDxErr.(*ValidationError); ok { + return "", vErr + } + + 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 new file mode 100644 index 00000000000..772d6d4f444 --- /dev/null +++ b/provisioner/hcp-sbom/version/version.go @@ -0,0 +1,16 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package version + +import ( + "github.com/hashicorp/packer-plugin-sdk/version" + packerVersion "github.com/hashicorp/packer/version" +) + +var HCPSBOMPluginVersion *version.PluginVersion + +func init() { + HCPSBOMPluginVersion = version.NewPluginVersion( + packerVersion.Version, packerVersion.VersionPrerelease, packerVersion.VersionMetadata) +} diff --git a/website/content/community-plugins.mdx b/website/content/community-plugins.mdx index fa245b73dfd..43a427c3f95 100644 --- a/website/content/community-plugins.mdx +++ b/website/content/community-plugins.mdx @@ -24,6 +24,7 @@ HashiCorp maintainers for advice on how to get started contributing. ## Provisioners - File +- HCP SBOM - InSpec - PowerShell - Shell diff --git a/website/content/docs/provisioners/hcp-sbom.mdx b/website/content/docs/provisioners/hcp-sbom.mdx new file mode 100644 index 00000000000..f0bd15cca39 --- /dev/null +++ b/website/content/docs/provisioners/hcp-sbom.mdx @@ -0,0 +1,137 @@ +--- +description: | + The hcp-sbom Packer provisioner uploads a CycloneDX or SPDX JSON-formatted software bill of materials record to HCP Packer. +page_title: HCP SBOM - Provisioners +--- + + + + + +# HCP SBOM Provisioner + +Type: `hcp-sbom` + +The `hcp-sbom` provisioner uploads software bill of materials (SBOM) files from artifacts built by Packer to HCP Packer. You must format SBOM files you want to upload as JSON and follow either the [SPDX](https://spdx.github.io/spdx-spec/latest) or [CycloneDX](https://cyclonedx.org/) specification. HCP Packer ties these SBOM files to the version of the artifact that Packer builds. + +## Example + +The following example uploads an SBOM from the local `/tmp` directory and stores a copy at `./sbom/sbom_cyclonedx.json` on the local machine. + + + + +```hcl +provisioner "hcp-sbom" { + source = "/tmp/sbom_cyclonedx.json" + destination = "./sbom/sbom_cyclonedx.json" + sbom_name = "sbom-cyclonedx" +} +``` + + + + +```json +{ + "type": "hcp-sbom", + "source": "/tmp/sbom_cyclonedx.json", + "destination": "./sbom/sbom_cyclonedx.json", + "sbom_name": "sbom-cyclonedx" +} +``` + + + + +## Configuration reference + +You can specify the following configuration options. + +Required parameters: + +@include 'provisioner/hcp-sbom/Config-required.mdx' + +Optional parameters: + +@include '/provisioner/hcp-sbom/Config-not-required.mdx' + +## Example usage + + + + +```hcl +packer { + required_plugins { + docker = { + version = ">= 1.0.0" + source = "github.com/hashicorp/docker" + } + } +} + +source "docker" "ubuntu" { + image = "ubuntu:20.04" + commit = true +} + +build { + sources = ["source.docker.ubuntu"] + + hcp_packer_registry { + bucket_name = "test-bucket" + } + + + provisioner "shell" { + inline = [ + "apt-get update -y", + "apt-get install -y curl gpg", + "bash -c \"$(curl -sSL https://install.mondoo.com/sh)\"", + "cnquery sbom --output cyclonedx-json --output-target /tmp/sbom_cyclonedx.json", + ] + } + + provisioner "hcp-sbom" { + source = "/tmp/sbom_cyclonedx.json" + destination = "./sbom" + sbom_name = "sbom-cyclonedx" + } +} +``` + + + + +```json +{ + "builders": [ + { + "type": "docker", + "image": "ubuntu:20.04", + "commit": true + } + ], + "provisioners": [ + { + "type": "shell", + "inline": [ + "apt-get update -y", + "apt-get install -y curl", + "bash -c \"$(curl -sSL https://install.mondoo.com/sh)\"", + "cnquery sbom --output cyclonedx-json --output-target /tmp/sbom_cyclonedx.json" + ] + }, + { + "type": "hcp-sbom", + "source": "/tmp/sbom_cyclonedx.json", + "destination": "./sbom", + "sbom_name": "sbom-cyclonedx" + } + ] +} +``` + + + \ No newline at end of file diff --git a/website/content/docs/provisioners/index.mdx b/website/content/docs/provisioners/index.mdx index e6144beaef4..da2603e80ac 100644 --- a/website/content/docs/provisioners/index.mdx +++ b/website/content/docs/provisioners/index.mdx @@ -20,6 +20,8 @@ The following provisioners are included with Packer: - [Breakpoint](/packer/docs/provisioners/breakpoint) - pause until the user presses `Enter` to resume a build. - [File](/packer/docs/provisioners/file) - upload files to machines image during a build. +- [HCP SBOM](/packer/docs/provisioners/hcp-sbom) - upload an SBOM and associate it with an artifact + version in the HCP Packer registry. - [Shell](/packer/docs/provisioners/shell) - run shell scripts on the machines image during a build. - [Local Shell](/packer/docs/provisioners/shell-local) - run shell scripts on the host running Packer during a build. 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 00000000000..fbba4f3c852 --- /dev/null +++ b/website/content/partials/provisioner/hcp-sbom/Config-not-required.mdx @@ -0,0 +1,14 @@ + + +- `destination` (string) - The path on the local machine to store a copy of the SBOM file. + You can specify an absolute or a path relative to the working directory + when you execute the Packer build. If the file already exists on the + local machine, Packer overwrites the file. If the destination is a + directory, the directory must already exist. + +- `sbom_name` (string) - The name of the SBOM file stored in HCP Packer. + If omitted, HCP Packer uses the build fingerprint as the file name. + This value must be between three and 36 characters from the following set: `[A-Za-z0-9_-]`. + You must specify a unique name for each build in an artifact version. + + 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 00000000000..4df8744eb32 --- /dev/null +++ b/website/content/partials/provisioner/hcp-sbom/Config-required.mdx @@ -0,0 +1,6 @@ + + +- `source` (string) - The file path or URL to the SBOM file in the Packer artifact. + This file must either be in the SPDX or CycloneDX format. + + diff --git a/website/data/docs-nav-data.json b/website/data/docs-nav-data.json index 51b173740f0..65ed0cb2dce 100644 --- a/website/data/docs-nav-data.json +++ b/website/data/docs-nav-data.json @@ -792,6 +792,10 @@ "title": "File", "path": "provisioners/file" }, + { + "title": "HCP SBOM", + "path": "provisioners/hcp-sbom" + }, { "title": "PowerShell", "path": "provisioners/powershell"