github.com/hashicorp/packer@v1.14.3/provisioner/hcp-sbom/provisioner.go (about)

     1  // Copyright (c) HashiCorp, Inc.
     2  // SPDX-License-Identifier: BUSL-1.1
     3  
     4  //go:generate packer-sdc mapstructure-to-hcl2 -type Config
     5  //go:generate packer-sdc struct-markdown
     6  
     7  package hcp_sbom
     8  
     9  import (
    10  	"bytes"
    11  	"context"
    12  	"encoding/json"
    13  	"errors"
    14  	"fmt"
    15  	"log"
    16  	"os"
    17  	"regexp"
    18  	"strings"
    19  
    20  	"path/filepath"
    21  
    22  	"github.com/hashicorp/hcl/v2/hcldec"
    23  	hcpPackerModels "github.com/hashicorp/hcp-sdk-go/clients/cloud-packer-service/stable/2023-01-01/models"
    24  	"github.com/hashicorp/packer-plugin-sdk/common"
    25  	packersdk "github.com/hashicorp/packer-plugin-sdk/packer"
    26  	"github.com/hashicorp/packer-plugin-sdk/template/config"
    27  	"github.com/hashicorp/packer-plugin-sdk/template/interpolate"
    28  )
    29  
    30  type Config struct {
    31  	common.PackerConfig `mapstructure:",squash"`
    32  
    33  	// The file path or URL to the SBOM file in the Packer artifact.
    34  	// This file must either be in the SPDX or CycloneDX format.
    35  	Source string `mapstructure:"source" required:"true"`
    36  
    37  	// The path on the local machine to store a copy of the SBOM file.
    38  	// You can specify an absolute or a path relative to the working directory
    39  	// when you execute the Packer build. If the file already exists on the
    40  	// local machine, Packer overwrites the file. If the destination is a
    41  	// directory, the directory must already exist.
    42  	Destination string `mapstructure:"destination"`
    43  
    44  	// The name of the SBOM file stored in HCP Packer.
    45  	// If omitted, HCP Packer uses the build fingerprint as the file name.
    46  	// This value must be between three and 36 characters from the following set: `[A-Za-z0-9_-]`.
    47  	// You must specify a unique name for each build in an artifact version.
    48  	SbomName string `mapstructure:"sbom_name"`
    49  	ctx      interpolate.Context
    50  }
    51  
    52  type Provisioner struct {
    53  	config Config
    54  }
    55  
    56  func (p *Provisioner) ConfigSpec() hcldec.ObjectSpec {
    57  	return p.config.FlatMapstructure().HCL2Spec()
    58  }
    59  
    60  var sbomFormatRegexp = regexp.MustCompile("^[0-9A-Za-z-]{3,36}$")
    61  
    62  func (p *Provisioner) Prepare(raws ...interface{}) error {
    63  	err := config.Decode(&p.config, &config.DecodeOpts{
    64  		PluginType:         "hcp-sbom",
    65  		Interpolate:        true,
    66  		InterpolateContext: &p.config.ctx,
    67  		InterpolateFilter: &interpolate.RenderFilter{
    68  			Exclude: []string{},
    69  		},
    70  	}, raws...)
    71  	if err != nil {
    72  		return err
    73  	}
    74  
    75  	var errs error
    76  
    77  	if p.config.Source == "" {
    78  		errs = packersdk.MultiErrorAppend(errs, errors.New("source must be specified"))
    79  	}
    80  
    81  	if p.config.SbomName != "" && !sbomFormatRegexp.MatchString(p.config.SbomName) {
    82  		// Ugly but a bit of a problem with interpolation since Provisioners
    83  		// are prepared twice in HCL2.
    84  		//
    85  		// If the information used for interpolating is populated in-between the
    86  		// first call to Prepare (at the start of the build), and when the
    87  		// Provisioner is actually called, the first call will fail, as
    88  		// the value won't contain the actual interpolated value, but a
    89  		// placeholder which doesn't match the regex.
    90  		//
    91  		// Since we don't have a way to discriminate between the calls
    92  		// in the context of the provisioner, we ignore them, and later the
    93  		// HCP Packer call will fail because of the broken regex.
    94  		if strings.Contains(p.config.SbomName, "<no value>") {
    95  			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.")
    96  		} else {
    97  			errs = packersdk.MultiErrorAppend(errs, fmt.Errorf("`sbom_name` %q doesn't match the expected format, it must "+
    98  				"contain between 3 and 36 characters, all from the following set: [A-Za-z0-9_-]", p.config.SbomName))
    99  		}
   100  	}
   101  
   102  	return errs
   103  }
   104  
   105  // PackerSBOM is the type we write to the temporary JSON dump of the SBOM to
   106  // be consumed by Packer core
   107  type PackerSBOM struct {
   108  	// RawSBOM is the raw data from the SBOM downloaded from the guest
   109  	RawSBOM []byte `json:"raw_sbom"`
   110  	// Format is the format detected by the provisioner
   111  	//
   112  	// Supported values: `SPDX` or `CYCLONEDX`
   113  	Format hcpPackerModels.HashicorpCloudPacker20230101SbomFormat `json:"format"`
   114  	// Name is the name of the SBOM to be set on HCP Packer
   115  	//
   116  	// If unset, HCP Packer will generate one
   117  	Name string `json:"name,omitempty"`
   118  }
   119  
   120  func (p *Provisioner) Provision(
   121  	ctx context.Context, ui packersdk.Ui, comm packersdk.Communicator,
   122  	generatedData map[string]interface{},
   123  ) error {
   124  	log.Println("Starting to provision with `hcp-sbom` provisioner")
   125  
   126  	if generatedData == nil {
   127  		generatedData = make(map[string]interface{})
   128  	}
   129  	p.config.ctx.Data = generatedData
   130  
   131  	src := p.config.Source
   132  
   133  	pkrDst := generatedData["dst"].(string)
   134  	if pkrDst == "" {
   135  		return fmt.Errorf("packer destination path missing from configs: this is an internal error, which should be reported to be fixed.")
   136  	}
   137  
   138  	var buf bytes.Buffer
   139  	if err := comm.Download(src, &buf); err != nil {
   140  		ui.Errorf("download failed for SBOM file: %s", err)
   141  		return err
   142  	}
   143  
   144  	format, err := validateSBOM(buf.Bytes())
   145  	if err != nil {
   146  		return fmt.Errorf("validation failed for SBOM file: %s", err)
   147  	}
   148  
   149  	outFile, err := os.Create(pkrDst)
   150  	if err != nil {
   151  		return fmt.Errorf("failed to open/create output file %q: %s", pkrDst, err)
   152  	}
   153  	defer outFile.Close()
   154  
   155  	err = json.NewEncoder(outFile).Encode(PackerSBOM{
   156  		RawSBOM: buf.Bytes(),
   157  		Format:  format,
   158  		Name:    p.config.SbomName,
   159  	})
   160  	if err != nil {
   161  		return fmt.Errorf("failed to write sbom file to %q: %s", pkrDst, err)
   162  	}
   163  
   164  	if p.config.Destination == "" {
   165  		return nil
   166  	}
   167  
   168  	// SBOM for User
   169  	usrDst, err := p.getUserDestination()
   170  	if err != nil {
   171  		return fmt.Errorf("failed to compute destination path %q: %s", p.config.Destination, err)
   172  	}
   173  	err = os.WriteFile(usrDst, buf.Bytes(), 0644)
   174  	if err != nil {
   175  		return fmt.Errorf("failed to write SBOM to destination %q: %s", usrDst, err)
   176  	}
   177  
   178  	return nil
   179  }
   180  
   181  // getUserDestination determines and returns the destination path for the user SBOM file.
   182  func (p *Provisioner) getUserDestination() (string, error) {
   183  	dst := p.config.Destination
   184  
   185  	// Check if the destination exists and determine its type
   186  	info, err := os.Stat(dst)
   187  	if err == nil {
   188  		if info.IsDir() {
   189  			// If the destination is a directory, create a temporary file inside it
   190  			tmpFile, err := os.CreateTemp(dst, "packer-user-sbom-*.json")
   191  			if err != nil {
   192  				return "", fmt.Errorf("failed to create temporary file in user SBOM directory %s: %s", dst, err)
   193  			}
   194  			dst = tmpFile.Name()
   195  			tmpFile.Close()
   196  		}
   197  		return dst, nil
   198  	}
   199  
   200  	outDir := filepath.Dir(dst)
   201  	// In case the destination does not exist, we'll get the dirpath,
   202  	// and create it if it doesn't already exist
   203  	err = os.MkdirAll(outDir, 0755)
   204  	if err != nil {
   205  		return "", fmt.Errorf("failed to create destination directory for user SBOM: %s\n", err)
   206  	}
   207  
   208  	// Check if the destination is a directory after the previous step.
   209  	//
   210  	// This happens if the path specified ends with a `/`, in which case the
   211  	// destination is a directory, and we must create a temporary file in
   212  	// this destination directory.
   213  	destStat, statErr := os.Stat(dst)
   214  	if statErr == nil && destStat.IsDir() {
   215  		tmpFile, err := os.CreateTemp(outDir, "packer-user-sbom-*.json")
   216  		if err != nil {
   217  			return "", fmt.Errorf("failed to create temporary file in user SBOM directory %s: %s", dst, err)
   218  		}
   219  		dst = tmpFile.Name()
   220  		tmpFile.Close()
   221  	}
   222  
   223  	return dst, nil
   224  }