diff --git a/command/execute.go b/command/execute.go index 7ad74f314..308c159c3 100644 --- a/command/execute.go +++ b/command/execute.go @@ -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), diff --git a/provisioner/hcp_sbom/provisioner.go b/provisioner/hcp_sbom/provisioner.go new file mode 100644 index 000000000..692483540 --- /dev/null +++ b/provisioner/hcp_sbom/provisioner.go @@ -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 +} diff --git a/provisioner/hcp_sbom/provisioner.hcl2spec.go b/provisioner/hcp_sbom/provisioner.hcl2spec.go new file mode 100644 index 000000000..e6ee5d227 --- /dev/null +++ b/provisioner/hcp_sbom/provisioner.hcl2spec.go @@ -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 +} diff --git a/provisioner/hcp_sbom/provisioner_test.go b/provisioner/hcp_sbom/provisioner_test.go new file mode 100644 index 000000000..b9adb5821 --- /dev/null +++ b/provisioner/hcp_sbom/provisioner_test.go @@ -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) + } +} diff --git a/provisioner/hcp_sbom/test-sbom.json b/provisioner/hcp_sbom/test-sbom.json new file mode 100644 index 000000000..fc1badee6 --- /dev/null +++ b/provisioner/hcp_sbom/test-sbom.json @@ -0,0 +1 @@ +{"bomFormat":"InvalidFormat","specVersion":"1.0"} \ No newline at end of file diff --git a/provisioner/hcp_sbom/version/version.go b/provisioner/hcp_sbom/version/version.go new file mode 100644 index 000000000..772d6d4f4 --- /dev/null +++ b/provisioner/hcp_sbom/version/version.go @@ -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) +} diff --git a/website/content/partials/provisioner/hcp_sbom/Config-not-required.mdx b/website/content/partials/provisioner/hcp_sbom/Config-not-required.mdx new file mode 100644 index 000000000..a8019fbde --- /dev/null +++ b/website/content/partials/provisioner/hcp_sbom/Config-not-required.mdx @@ -0,0 +1,5 @@ + + +- `destination` (string) - Destination + + diff --git a/website/content/partials/provisioner/hcp_sbom/Config-required.mdx b/website/content/partials/provisioner/hcp_sbom/Config-required.mdx new file mode 100644 index 000000000..0cb7e7a80 --- /dev/null +++ b/website/content/partials/provisioner/hcp_sbom/Config-required.mdx @@ -0,0 +1,5 @@ + + +- `source` (string) - Source + +