mirror of https://github.com/hashicorp/packer
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
225 lines
7.1 KiB
225 lines
7.1 KiB
// 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, "<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 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
|
|
}
|