packer: add hcp-sbom provisioner

The hcp-sbom provisioner is a provisioner that acts essentially like a
download-only file provisioner, which also verifies the file downloaded
is a SPDX/CycloneDX JSON-encoded SBOM file, and sets up its upload to
HCP Packer later on.
pull/13268/head
Devashish 1 year ago committed by Lucas Bajolet
parent 56400f27cb
commit a353260f5d

@ -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),

@ -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

@ -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=
@ -741,3 +758,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=

@ -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,

@ -50,11 +50,19 @@ type CoreBuild struct {
onError string
l sync.Mutex
prepareCalled bool
SBOMs []SBOM
}
type SBOM struct {
Format string
CompressedData []byte
}
type BuildMetadata struct {
PackerVersion string
Plugins map[string]PluginDetails
SBOMs []SBOM
}
func (b *CoreBuild) getPluginsMetadata() map[string]PluginDetails {
@ -88,6 +96,7 @@ func (b *CoreBuild) GetMetadata() BuildMetadata {
metadata := BuildMetadata{
PackerVersion: version.FormattedVersion(),
Plugins: b.getPluginsMetadata(),
SBOMs: b.SBOMs,
}
return metadata
}
@ -300,6 +309,17 @@ 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{
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 {

@ -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,

@ -5,8 +5,15 @@ package packer
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
hcpSbomProvisioner "github.com/hashicorp/packer/provisioner/hcp-sbom"
"github.com/klauspost/compress/zstd"
"time"
"github.com/hashicorp/hcl/v2/hcldec"
@ -234,3 +241,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 string
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
}

@ -0,0 +1,231 @@
// 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"
"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"`
// The name to give the SBOM when uploaded on HCP Packer
//
// By default this will be generated, but if you prefer to have a name
// of your choosing, you can enter it here.
// The name must match the following regexp: `[a-zA-Z0-9_-]{3,36}`
//
// Note: it must be unique for a single build, otherwise the build will
// fail when uploading the SBOMs to HCP Packer, and so will the Packer
// build command.
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, "<no value>") {
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 string `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
}

@ -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
}

@ -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: "<no value>",
},
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)
}
})
}
}

@ -0,0 +1,85 @@
package hcp_sbom
import (
"bytes"
"fmt"
"strings"
"github.com/CycloneDX/cyclonedx-go"
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) (string, error) {
// Try validating as SPDX
spdxErr := validateSPDX(content)
if spdxErr == nil {
return "spdx", nil
}
if vErr, ok := spdxErr.(*ValidationError); ok {
return "", vErr
}
cycloneDxErr := validateCycloneDX(content)
if cycloneDxErr == nil {
return "cyclonedx", nil
}
if vErr, ok := cycloneDxErr.(*ValidationError); ok {
return "", vErr
}
return "", fmt.Errorf("error validating SBOM file: invalid SBOM format")
}

@ -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)
}

@ -0,0 +1,23 @@
<!-- Code generated from the comments of the Config struct in provisioner/hcp-sbom/provisioner.go; DO NOT EDIT MANUALLY -->
- `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.
- `sbom_name` (string) - The name to give the SBOM when uploaded on HCP Packer
By default this will be generated, but if you prefer to have a name
of your choosing, you can enter it here.
The name must match the following regexp: `[a-zA-Z0-9_-]{3,36}`
Note: it must be unique for a single build, otherwise the build will
fail when uploading the SBOMs to HCP Packer, and so will the Packer
build command.
<!-- End of code generated from the comments of the Config struct in provisioner/hcp-sbom/provisioner.go; -->

@ -0,0 +1,7 @@
<!-- Code generated from the comments of the Config struct in provisioner/hcp-sbom/provisioner.go; DO NOT EDIT MANUALLY -->
- `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.
<!-- End of code generated from the comments of the Config struct in provisioner/hcp-sbom/provisioner.go; -->
Loading…
Cancel
Save