Add outer provisioner to download, validate and compress SBOM

hand-off-lucas
Devashish 2 years ago
parent 703e0de15c
commit e534589220
No known key found for this signature in database
GPG Key ID: 4642E918377AE2A4

@ -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"
hcp_sbomprovisioner "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(hcp_sbomprovisioner.Provisioner),
"powershell": new(powershellprovisioner.Provisioner),
"shell": new(shellprovisioner.Provisioner),
"shell-local": new(shelllocalprovisioner.Provisioner),

@ -0,0 +1,239 @@
// 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 (
"context"
"encoding/json"
"errors"
"fmt"
"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"
"github.com/klauspost/compress/zstd"
"io"
"os"
"path/filepath"
"strings"
)
type Config struct {
common.PackerConfig `mapstructure:",squash"`
Source string `mapstructure:"source" required:"true"`
Destination string `mapstructure:"destination"`
ctx interpolate.Context
}
type Provisioner struct {
config Config
}
func (p *Provisioner) ConfigSpec() hcldec.ObjectSpec {
return p.config.FlatMapstructure().HCL2Spec()
}
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 *packersdk.MultiError
if p.config.Source == "" {
errs = packersdk.MultiErrorAppend(errs, errors.New("source must be specified"))
}
if errs != nil && len(errs.Errors) > 0 {
return errs
}
return nil
}
func (p *Provisioner) Provision(
ctx context.Context, ui packersdk.Ui, comm packersdk.Communicator, generatedData map[string]interface{},
) error {
ui.Say(
fmt.Sprintf("Starting to provision with hcp-sbom using source: %s",
p.config.Source,
),
)
if generatedData == nil {
generatedData = make(map[string]interface{})
}
p.config.ctx.Data = generatedData
// Download the file
destPath, downloadErr := p.downloadSBOM(ui, comm)
// defer os.Remove(destPath)
if downloadErr != nil {
return fmt.Errorf("failed to download file: %w", downloadErr)
}
// Validate the file
ui.Say(fmt.Sprintf("Validating SBOM file %s", destPath))
validationErr := p.validateSBOM(ui, destPath)
if validationErr != nil {
return fmt.Errorf("failed to validate SBOM file: %w", validationErr)
}
// Compress the file
ui.Say(fmt.Sprintf("Compressing SBOM file %s", destPath))
_, compessionErr := p.compressFile(ui, destPath)
if compessionErr != nil {
return fmt.Errorf("failed to compress file: %w", compessionErr)
}
// Future: send compressedData to the internal API as per RFC
// ...
return nil
}
// downloadSBOM downloads a Software Bill of Materials (SBOM) from a specified
// source to a local destination. It works with all communicators from packersdk.
// The method returns the path to the downloaded file or an error if any issues
// occur during the download process.
func (p *Provisioner) downloadSBOM(ui packersdk.Ui, comm packersdk.Communicator) (string, error) {
src, err := interpolate.Render(p.config.Source, &p.config.ctx)
if err != nil {
return p.config.Destination, fmt.Errorf("error interpolating source: %s", err)
}
// Check if the source is a JSON file
if filepath.Ext(src) != ".json" {
return p.config.Destination, fmt.Errorf(
"packer SBOM source file is not a JSON file: %s", src,
)
}
// Determine the destination path
dst := p.config.Destination
if dst == "" {
tmpFile, err := os.CreateTemp("", "packer-sbom-*.json")
if err != nil {
return dst, fmt.Errorf(
"failed to create file for Packer SBOM: %s", err,
)
}
dst = tmpFile.Name()
tmpFile.Close()
} else {
dst, err = interpolate.Render(dst, &p.config.ctx)
if err != nil {
return dst, fmt.Errorf("error interpolating Packer SBOM destination: %s", err)
}
if strings.HasSuffix(dst, "/") {
info, err := os.Stat(dst)
if err != nil {
return dst, fmt.Errorf("failed to stat destination for Packer SBOM: %s", err)
}
if info.IsDir() {
tmpFile, err := os.CreateTemp(dst, "packer-sbom-*.json")
if err != nil {
return dst, fmt.Errorf("failed to create file for Packer SBOM: %s", err)
}
dst = tmpFile.Name()
tmpFile.Close()
}
}
}
// Ensure the destination directory exists
dir := filepath.Dir(dst)
if err := os.MkdirAll(dir, os.FileMode(0755)); err != nil {
return dst, fmt.Errorf("failed to create destination directory for Packer SBOM: %s", err)
}
// Open the destination file
f, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return dst, fmt.Errorf("failed to open destination file: %s", err)
}
defer f.Close()
// Create MultiWriter for the current progress
pf := io.MultiWriter(f)
// Download the file
ui.Say(fmt.Sprintf("Downloading SBOM file %s => %s", src, dst))
if err = comm.Download(src, pf); err != nil {
ui.Error(fmt.Sprintf("download failed for SBOM file: %s", err))
return dst, err
}
return dst, nil
}
func (p *Provisioner) compressFile(ui packersdk.Ui, filePath string) ([]byte, error) {
sourceFile, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer sourceFile.Close()
data, err := io.ReadAll(sourceFile)
if err != nil {
return nil, err
}
encoder, err := zstd.NewWriter(nil)
if err != nil {
return nil, err
}
defer encoder.Close()
compressedData := encoder.EncodeAll(data, nil)
ui.Say(fmt.Sprintf("SBOM file compressed successfully. Size: %d bytes", len(compressedData)))
return compressedData, nil
}
type SBOM struct {
BomFormat string `json:"bomFormat"`
SpecVersion string `json:"specVersion"`
}
func (p *Provisioner) validateSBOM(ui packersdk.Ui, filePath string) error {
sourceFile, err := os.Open(filePath)
if err != nil {
return err
}
defer sourceFile.Close()
data, err := io.ReadAll(sourceFile)
if err != nil {
return err
}
var sbom SBOM
if err := json.Unmarshal(data, &sbom); err != nil {
return fmt.Errorf("failed to unmarshal JSON: %w", err)
}
if sbom.BomFormat != "CycloneDX" {
return fmt.Errorf("invalid bomFormat: %s", sbom.BomFormat)
}
if sbom.SpecVersion == "" {
return fmt.Errorf("specVersion is required")
}
return nil
}

@ -0,0 +1,49 @@
// 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"`
}
// 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},
}
return s
}

@ -0,0 +1,219 @@
package hcp_sbom
import (
"encoding/json"
"fmt"
"github.com/hashicorp/packer-plugin-sdk/packer"
"github.com/klauspost/compress/zstd"
"io"
"os"
"testing"
)
type MockUi struct {
packer.Ui
}
func (m *MockUi) Say(message string) {
fmt.Println(message)
}
func (m *MockUi) Error(message string) {
fmt.Println("ERROR:", message)
}
type MockCommunicator struct {
packer.Communicator
}
func (m *MockCommunicator) Download(src string, dst io.Writer) error {
_, err := dst.Write([]byte("mock SBOM content"))
return err
}
func TestDownloadSBOM(t *testing.T) {
ui := &MockUi{}
comm := &MockCommunicator{}
tests := []struct {
name string
config Config
expectError bool
}{
{
name: "Source is a dir, Dest is a dir",
config: Config{
Source: "mock-source/",
Destination: "test-dir/",
},
expectError: true,
},
{
name: "Source is a json file, Destination is a dir",
config: Config{
Source: "mock-source/sbom.json",
Destination: "test-dir/",
},
expectError: true,
},
{
name: "Source is a json file, Destination is a json file",
config: Config{
Source: "mock-source/sbom.json",
Destination: "sbom.json",
},
expectError: false,
},
{
name: "Source is a json file, Destination is a json file in test-output-data",
config: Config{
Source: "mock-source/sbom.json",
Destination: "test-output-data/sbom.json",
},
expectError: false,
},
{
name: "Source is a json file, Destination is test-output-data w/o /",
config: Config{
Source: "mock-source/sbom.json",
Destination: "test-output-data",
},
expectError: true,
},
{
name: "Source is a json file, Destination is empty",
config: Config{
Source: "mock-source/sbom.json",
},
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
provisioner := &Provisioner{
config: tt.config,
}
destPath, err := provisioner.downloadSBOM(ui, comm)
if tt.expectError {
if err == nil {
t.Fatalf("expected error, got none")
}
} else {
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if _, err := os.Stat(destPath); os.IsNotExist(err) {
t.Fatalf("expected file to exist at %s", destPath)
}
os.RemoveAll(destPath)
}
})
}
}
func TestValidateSBOM(t *testing.T) {
provisioner := &Provisioner{}
ui := &MockUi{}
tests := []struct {
name string
sbom SBOM
expectError bool
errorMsg string
}{
{
name: "Valid SBOM",
sbom: SBOM{
BomFormat: "CycloneDX",
SpecVersion: "1.0",
},
expectError: false,
},
{
name: "Invalid BomFormat",
sbom: SBOM{
BomFormat: "InvalidFormat",
SpecVersion: "1.0",
},
expectError: true,
errorMsg: "invalid bomFormat: InvalidFormat",
},
{
name: "Empty SpecVersion",
sbom: SBOM{
BomFormat: "CycloneDX",
SpecVersion: "",
},
expectError: true,
errorMsg: "specVersion is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data, _ := json.Marshal(tt.sbom)
filePath := "test-sbom.json"
os.WriteFile(filePath, data, 0644)
defer os.Remove(filePath)
err := provisioner.validateSBOM(ui, filePath)
if tt.expectError {
if err == nil || err.Error() != tt.errorMsg {
t.Fatalf("expected error %v, got %v", tt.errorMsg, err)
}
} else {
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
})
}
}
func TestCompressFile(t *testing.T) {
ui := &MockUi{}
provisioner := &Provisioner{}
validSBOM := SBOM{
BomFormat: "CycloneDX",
SpecVersion: "1.0",
}
data, _ := json.Marshal(validSBOM)
filePath := "data.json"
//os.WriteFile(filePath, data, 0644)
//defer os.Remove(filePath)
sourceFile, err := os.Open(filePath)
if err != nil {
t.Fatalf("expected no error:%v", err)
}
defer sourceFile.Close()
data, err = io.ReadAll(sourceFile)
if err != nil {
t.Fatalf("expected no error:%v", err)
}
compressedData, err := provisioner.compressFile(ui, filePath)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
decoder, err := zstd.NewReader(nil)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
defer decoder.Close()
decompressedData, err := decoder.DecodeAll(compressedData, nil)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if string(decompressedData) != string(data) {
t.Fatalf("expected decompressed data to be '%s', got %s", data, decompressedData)
}
}

@ -0,0 +1 @@
{"bomFormat":"InvalidFormat","specVersion":"1.0"}

@ -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,5 @@
<!-- Code generated from the comments of the Config struct in provisioner/hcp_sbom/provisioner.go; DO NOT EDIT MANUALLY -->
- `destination` (string) - Destination
<!-- End of code generated from the comments of the Config struct in provisioner/hcp_sbom/provisioner.go; -->

@ -0,0 +1,5 @@
<!-- Code generated from the comments of the Config struct in provisioner/hcp_sbom/provisioner.go; DO NOT EDIT MANUALLY -->
- `source` (string) - Source
<!-- End of code generated from the comments of the Config struct in provisioner/hcp_sbom/provisioner.go; -->
Loading…
Cancel
Save