mirror of https://github.com/hashicorp/packer
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
parent
56400f27cb
commit
a353260f5d
@ -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…
Reference in new issue