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 }