From 4712246897e223c9c62993ca4768cfdfc59bb214 Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Tue, 21 Nov 2023 13:29:58 -0500 Subject: [PATCH] Fix the `attest` command (#2337) * fix attest command Signed-off-by: Alex Goodman * add notification on how to access the attestation Signed-off-by: Alex Goodman * fix integration test Signed-off-by: Alex Goodman --------- Signed-off-by: Alex Goodman --- cmd/syft/cli/commands/attest.go | 244 ++++++++-------- cmd/syft/cli/commands/attest_test.go | 268 ++++++++++++++++++ cmd/syft/cli/commands/packages.go | 102 ++++--- cmd/syft/cli/options/format_cyclonedx_json.go | 6 +- cmd/syft/cli/options/format_cyclonedx_xml.go | 6 +- cmd/syft/cli/options/format_spdx_json.go | 6 +- cmd/syft/cli/options/format_syft_json.go | 6 +- cmd/syft/cli/options/output.go | 10 + cmd/syft/cli/options/output_test.go | 22 ++ test/integration/convert_test.go | 5 +- 10 files changed, 509 insertions(+), 166 deletions(-) create mode 100644 cmd/syft/cli/commands/attest_test.go diff --git a/cmd/syft/cli/commands/attest.go b/cmd/syft/cli/commands/attest.go index 69dfe4317..c3067933e 100644 --- a/cmd/syft/cli/commands/attest.go +++ b/cmd/syft/cli/commands/attest.go @@ -2,6 +2,7 @@ package commands import ( "fmt" + "io" "os" "os/exec" "strings" @@ -11,15 +12,14 @@ import ( "github.com/wagoodman/go-progress" "github.com/anchore/clio" - "github.com/anchore/stereoscope/pkg/image" "github.com/anchore/syft/cmd/syft/cli/options" "github.com/anchore/syft/cmd/syft/internal/ui" "github.com/anchore/syft/internal" "github.com/anchore/syft/internal/bus" - "github.com/anchore/syft/internal/file" "github.com/anchore/syft/internal/log" "github.com/anchore/syft/syft/event" "github.com/anchore/syft/syft/event/monitor" + "github.com/anchore/syft/syft/format" "github.com/anchore/syft/syft/format/cyclonedxjson" "github.com/anchore/syft/syft/format/spdxjson" "github.com/anchore/syft/syft/format/spdxtagvalue" @@ -33,6 +33,7 @@ const ( ` attestSchemeHelp = "\n " + schemeHelpHeader + "\n" + imageSchemeHelp attestHelp = attestExample + attestSchemeHelp + cosignBinName = "cosign" ) type attestOptions struct { @@ -46,24 +47,7 @@ type attestOptions struct { func Attest(app clio.Application) *cobra.Command { id := app.ID() - opts := &attestOptions{ - UpdateCheck: options.DefaultUpdateCheck(), - Output: options.Output{ - AllowMultipleOutputs: false, - AllowableOptions: []string{ - string(syftjson.ID), - string(cyclonedxjson.ID), - string(spdxjson.ID), - string(spdxtagvalue.ID), - }, - Outputs: []string{syftjson.ID.String()}, - OutputFile: options.OutputFile{ // nolint:staticcheck - Enabled: false, // explicitly not allowed - }, - Format: options.DefaultFormat(), - }, - Catalog: options.DefaultCatalog(), - } + opts := defaultAttestOptions() // template format explicitly not allowed opts.Format.Template.Enabled = false @@ -82,83 +66,96 @@ func Attest(app clio.Application) *cobra.Command { restoreStdout := ui.CaptureStdoutToTraceLog() defer restoreStdout() - return runAttest(id, opts, args[0]) + return runAttest(id, &opts, args[0]) }, - }, opts) + }, &opts) +} + +func defaultAttestOptions() attestOptions { + return attestOptions{ + Output: defaultAttestOutputOptions(), + UpdateCheck: options.DefaultUpdateCheck(), + Catalog: options.DefaultCatalog(), + } +} + +func defaultAttestOutputOptions() options.Output { + return options.Output{ + AllowMultipleOutputs: false, + AllowToFile: false, + AllowableOptions: []string{ + string(syftjson.ID), + string(cyclonedxjson.ID), + string(spdxjson.ID), + string(spdxtagvalue.ID), + }, + Outputs: []string{syftjson.ID.String()}, + OutputFile: options.OutputFile{ // nolint:staticcheck + Enabled: false, // explicitly not allowed + }, + Format: options.DefaultFormat(), + } } //nolint:funlen func runAttest(id clio.Identification, opts *attestOptions, userInput string) error { - _, err := exec.LookPath("cosign") - if err != nil { - // when cosign is not installed the error will be rendered like so: - // 2023/06/30 08:31:52 error during command execution: 'syft attest' requires cosign to be installed: exec: "cosign": executable file not found in $PATH - return fmt.Errorf("'syft attest' requires cosign to be installed: %w", err) - } - - s, err := buildSBOM(id, &opts.Catalog, userInput) - if err != nil { - return fmt.Errorf("unable to build SBOM: %w", err) + // TODO: what other validation here besides binary name? + if !commandExists(cosignBinName) { + return fmt.Errorf("'syft attest' requires cosign to be installed, however it does not appear to be on PATH") } + // this is the file that will contain the SBOM being attested f, err := os.CreateTemp("", "syft-attest-") if err != nil { return fmt.Errorf("unable to create temp file: %w", err) } defer os.Remove(f.Name()) - writer, err := opts.SBOMWriter() + s, err := generateSBOMForAttestation(id, &opts.Catalog, userInput) if err != nil { - return fmt.Errorf("unable to create SBOM writer: %w", err) + return fmt.Errorf("unable to build SBOM: %w", err) } - if err := writer.Write(*s); err != nil { - return fmt.Errorf("unable to write SBOM to temp file: %w", err) + if err = writeSBOMToFormattedFile(s, f, opts); err != nil { + return fmt.Errorf("unable to write SBOM to file: %w", err) } - // TODO: what other validation here besides binary name? - cmd := "cosign" - if !commandExists(cmd) { - return fmt.Errorf("unable to find cosign in PATH; make sure you have it installed") + if err = createAttestation(f.Name(), opts, userInput); err != nil { + return err } - outputNames := opts.OutputNameSet() - var outputName string - switch outputNames.Size() { - case 0: - return fmt.Errorf("no output format specified") - case 1: - outputName = outputNames.List()[0] - default: - return fmt.Errorf("multiple output formats specified: %s", strings.Join(outputNames.List(), ", ")) + bus.Notify("Attestation has been created, please check your registry for the output or use the cosign command:") + bus.Notify(fmt.Sprintf("cosign download attestation %s", userInput)) + return nil +} + +func writeSBOMToFormattedFile(s *sbom.SBOM, sbomFile io.Writer, opts *attestOptions) error { + if sbomFile == nil { + return fmt.Errorf("no output file provided") } - // Select Cosign predicate type based on defined output type - // As orientation, check: https://github.com/sigstore/cosign/blob/main/pkg/cosign/attestation/attestation.go - var predicateType string - switch strings.ToLower(outputName) { - case "cyclonedx-json": - predicateType = "cyclonedx" - case "spdx-tag-value", "spdx-tv": - predicateType = "spdx" - case "spdx-json", "json": - predicateType = "spdxjson" - default: - predicateType = "custom" + encs, err := opts.Format.Encoders() + if err != nil { + return fmt.Errorf("unable to create encoders: %w", err) } - args := []string{"attest", userInput, "--predicate", f.Name(), "--type", predicateType} - if opts.Attest.Key != "" { - args = append(args, "--key", opts.Attest.Key.String()) + encoders := format.NewEncoderCollection(encs...) + encoder := encoders.GetByString(opts.Outputs[0]) + if encoder == nil { + return fmt.Errorf("unable to find encoder for %q", opts.Outputs[0]) } - execCmd := exec.Command(cmd, args...) - execCmd.Env = os.Environ() - if opts.Attest.Key != "" { - execCmd.Env = append(execCmd.Env, fmt.Sprintf("COSIGN_PASSWORD=%s", opts.Attest.Password)) - } else { - // no key provided, use cosign's keyless mode - execCmd.Env = append(execCmd.Env, "COSIGN_EXPERIMENTAL=1") + if err = encoder.Encode(sbomFile, *s); err != nil { + return fmt.Errorf("unable to encode SBOM: %w", err) + } + + return nil +} + +func createAttestation(sbomFilepath string, opts *attestOptions, userInput string) error { + execCmd, err := attestCommand(sbomFilepath, opts, userInput) + if err != nil { + return fmt.Errorf("unable to craft attest command: %w", err) } log.WithFields("cmd", strings.Join(execCmd.Args, " ")).Trace("creating attestation") @@ -201,59 +198,67 @@ func runAttest(id clio.Identification, opts *attestOptions, userInput string) er } mon.SetCompleted() - return nil } -func buildSBOM(id clio.Identification, opts *options.Catalog, userInput string) (*sbom.SBOM, error) { - cfg := source.DetectConfig{ - DefaultImageSource: opts.DefaultImagePullSource, +func attestCommand(sbomFilepath string, opts *attestOptions, userInput string) (*exec.Cmd, error) { + outputNames := opts.OutputNameSet() + var outputName string + switch outputNames.Size() { + case 0: + return nil, fmt.Errorf("no output format specified") + case 1: + outputName = outputNames.List()[0] + default: + return nil, fmt.Errorf("multiple output formats specified: %s", strings.Join(outputNames.List(), ", ")) } - detection, err := source.Detect(userInput, cfg) + + args := []string{"attest", userInput, "--predicate", sbomFilepath, "--type", predicateType(outputName), "-y"} + if opts.Attest.Key != "" { + args = append(args, "--key", opts.Attest.Key.String()) + } + + execCmd := exec.Command(cosignBinName, args...) + execCmd.Env = os.Environ() + if opts.Attest.Key != "" { + execCmd.Env = append(execCmd.Env, fmt.Sprintf("COSIGN_PASSWORD=%s", opts.Attest.Password)) + } else { + // no key provided, use cosign's keyless mode + execCmd.Env = append(execCmd.Env, "COSIGN_EXPERIMENTAL=1") + } + + return execCmd, nil +} + +func predicateType(outputName string) string { + // Select Cosign predicate type based on defined output type + // As orientation, check: https://github.com/sigstore/cosign/blob/main/pkg/cosign/attestation/attestation.go + switch strings.ToLower(outputName) { + case "cyclonedx-json": + return "cyclonedx" + case "spdx-tag-value", "spdx-tv": + return "spdx" + case "spdx-json", "json": + return "spdxjson" + default: + return "custom" + } +} + +func generateSBOMForAttestation(id clio.Identification, opts *options.Catalog, userInput string) (*sbom.SBOM, error) { + src, err := getSource(opts, userInput, onlyContainerImages) + if err != nil { - return nil, fmt.Errorf("could not deteremine source: %w", err) + return nil, err } - if detection.IsContainerImage() { - return nil, fmt.Errorf("attestations are only supported for oci images at this time") - } - - var platform *image.Platform - - if opts.Platform != "" { - platform, err = image.NewPlatform(opts.Platform) - if err != nil { - return nil, fmt.Errorf("invalid platform: %w", err) + defer func() { + if src != nil { + if err := src.Close(); err != nil { + log.Tracef("unable to close source: %+v", err) + } } - } - - hashers, err := file.Hashers(opts.Source.File.Digests...) - if err != nil { - return nil, fmt.Errorf("invalid hash: %w", err) - } - - src, err := detection.NewSource( - source.DetectionSourceConfig{ - Alias: source.Alias{ - Name: opts.Source.Name, - Version: opts.Source.Version, - }, - RegistryOptions: opts.Registry.ToOptions(), - Platform: platform, - Exclude: source.ExcludeConfig{ - Paths: opts.Exclusions, - }, - DigestAlgorithms: hashers, - BasePath: opts.BasePath, - }, - ) - - if src != nil { - defer src.Close() - } - if err != nil { - return nil, fmt.Errorf("failed to construct source from user input %q: %w", userInput, err) - } + }() s, err := generateSBOM(id, src, opts) if err != nil { @@ -267,6 +272,13 @@ func buildSBOM(id clio.Identification, opts *options.Catalog, userInput string) return s, nil } +func onlyContainerImages(d *source.Detection) error { + if !d.IsContainerImage() { + return fmt.Errorf("attestations are only supported for oci images at this time") + } + return nil +} + func commandExists(cmd string) bool { _, err := exec.LookPath(cmd) return err == nil diff --git a/cmd/syft/cli/commands/attest_test.go b/cmd/syft/cli/commands/attest_test.go new file mode 100644 index 000000000..31ab90e89 --- /dev/null +++ b/cmd/syft/cli/commands/attest_test.go @@ -0,0 +1,268 @@ +package commands + +import ( + "bytes" + "fmt" + "os/exec" + "regexp" + "strings" + "testing" + + "github.com/scylladb/go-set/strset" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anchore/clio" + "github.com/anchore/syft/cmd/syft/cli/options" + "github.com/anchore/syft/syft/sbom" + "github.com/anchore/syft/syft/source" +) + +func Test_writeSBOMToFormattedFile(t *testing.T) { + type args struct { + s *sbom.SBOM + opts *attestOptions + } + tests := []struct { + name string + args args + wantSbomFile string + wantErr bool + }{ + { + name: "go case", + args: args{ + opts: &attestOptions{ + Output: func() options.Output { + def := defaultAttestOutputOptions() + def.Outputs = []string{"syft-json"} + return def + }(), + }, + s: &sbom.SBOM{ + Artifacts: sbom.Artifacts{}, + Relationships: nil, + Source: source.Description{ + ID: "source-id", + Name: "source-name", + Version: "source-version", + }, + Descriptor: sbom.Descriptor{ + Name: "syft-test", + Version: "non-version", + }, + }, + }, + wantSbomFile: `{ + "artifacts": [], + "artifactRelationships": [], + "source": { + "id": "source-id", + "name": "source-name", + "version": "source-version", + "type": "", + "metadata": null + }, + "distro": {}, + "descriptor": { + "name": "syft-test", + "version": "non-version" + }, + "schema": {} +}`, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sbomFile := &bytes.Buffer{} + + err := writeSBOMToFormattedFile(tt.args.s, sbomFile, tt.args.opts) + if (err != nil) != tt.wantErr { + t.Errorf("writeSBOMToFormattedFile() error = %v, wantErr %v", err, tt.wantErr) + return + } + + // redact the schema block + re := regexp.MustCompile(`(?s)"schema":\W*\{.*?},?`) + subject := re.ReplaceAllString(sbomFile.String(), `"schema":{}`) + + assert.JSONEq(t, tt.wantSbomFile, subject) + }) + } +} + +func Test_attestCommand(t *testing.T) { + cmdPrefix := cosignBinName + lp, err := exec.LookPath(cosignBinName) + if err == nil { + cmdPrefix = lp + } + + fullCmd := func(args string) string { + return fmt.Sprintf("%s %s", cmdPrefix, args) + } + + type args struct { + sbomFilepath string + opts attestOptions + userInput string + } + tests := []struct { + name string + args args + wantCmd string + wantEnvVars map[string]string + notEnvVars []string + wantErr require.ErrorAssertionFunc + }{ + { + name: "with key and password", + args: args{ + userInput: "myimage", + sbomFilepath: "/tmp/sbom-filepath.json", + opts: func() attestOptions { + def := defaultAttestOptions() + def.Outputs = []string{"syft-json"} + def.Attest.Key = "key" + def.Attest.Password = "password" + return def + }(), + }, + wantCmd: fullCmd("attest myimage --predicate /tmp/sbom-filepath.json --type custom -y --key key"), + wantEnvVars: map[string]string{ + "COSIGN_PASSWORD": "password", + }, + notEnvVars: []string{ + "COSIGN_EXPERIMENTAL", // only for keyless + }, + }, + { + name: "keyless", + args: args{ + userInput: "myimage", + sbomFilepath: "/tmp/sbom-filepath.json", + opts: func() attestOptions { + def := defaultAttestOptions() + def.Outputs = []string{"syft-json"} + return def + }(), + }, + wantCmd: fullCmd("attest myimage --predicate /tmp/sbom-filepath.json --type custom -y"), + wantEnvVars: map[string]string{ + "COSIGN_EXPERIMENTAL": "1", + }, + notEnvVars: []string{ + "COSIGN_PASSWORD", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.wantErr == nil { + tt.wantErr = require.NoError + } + + got, err := attestCommand(tt.args.sbomFilepath, &tt.args.opts, tt.args.userInput) + tt.wantErr(t, err) + if err != nil { + return + } + + require.NotNil(t, got) + assert.Equal(t, tt.wantCmd, got.String()) + + gotEnv := strset.New(got.Env...) + + for k, v := range tt.wantEnvVars { + assert.True(t, gotEnv.Has(fmt.Sprintf("%s=%s", k, v))) + } + + for _, k := range tt.notEnvVars { + for _, env := range got.Env { + fields := strings.Split(env, "=") + if fields[0] == k { + t.Errorf("attestCommand() unexpected environment variable %s", k) + } + } + } + }) + } +} + +func Test_predicateType(t *testing.T) { + tests := []struct { + name string + want string + }{ + { + name: "cyclonedx-json", + want: "cyclonedx", + }, + { + name: "spdx-tag-value", + want: "spdx", + }, + { + name: "spdx-tv", + want: "spdx", + }, + { + name: "spdx-json", + want: "spdxjson", + }, + { + name: "json", + want: "spdxjson", + }, + { + name: "syft-json", + want: "custom", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, predicateType(tt.name), "predicateType(%v)", tt.name) + }) + } +} + +func Test_buildSBOMForAttestation(t *testing.T) { + // note: this test is only meant to test that the filter function is wired + // and not the correctness of the function in depth + type args struct { + id clio.Identification + opts *options.Catalog + userInput string + } + tests := []struct { + name string + args args + want *sbom.SBOM + wantErr require.ErrorAssertionFunc + }{ + { + name: "do not allow directory scans", + args: args{ + opts: func() *options.Catalog { + def := defaultAttestOptions() + return &def.Catalog + }(), + userInput: "dir:/tmp/something", + }, + wantErr: require.Error, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.wantErr == nil { + tt.wantErr = require.NoError + } + _, err := generateSBOMForAttestation(tt.args.id, tt.args.opts, tt.args.userInput) + tt.wantErr(t, err) + if err != nil { + return + } + }) + } +} diff --git a/cmd/syft/cli/commands/packages.go b/cmd/syft/cli/commands/packages.go index 1e239fb7a..290ab570b 100644 --- a/cmd/syft/cli/commands/packages.go +++ b/cmd/syft/cli/commands/packages.go @@ -117,51 +117,10 @@ func runPackages(id clio.Identification, opts *packagesOptions, userInput string return err } - detection, err := source.Detect( - userInput, - source.DetectConfig{ - DefaultImageSource: opts.DefaultImagePullSource, - }, - ) - if err != nil { - return fmt.Errorf("could not deteremine source: %w", err) - } - - var platform *image.Platform - - if opts.Platform != "" { - platform, err = image.NewPlatform(opts.Platform) - if err != nil { - return fmt.Errorf("invalid platform: %w", err) - } - } - - hashers, err := file.Hashers(opts.Source.File.Digests...) - if err != nil { - return fmt.Errorf("invalid hash: %w", err) - } - - src, err := detection.NewSource( - source.DetectionSourceConfig{ - Alias: source.Alias{ - Name: opts.Source.Name, - Version: opts.Source.Version, - }, - RegistryOptions: opts.Registry.ToOptions(), - Platform: platform, - Exclude: source.ExcludeConfig{ - Paths: opts.Exclusions, - }, - DigestAlgorithms: hashers, - BasePath: opts.BasePath, - }, - ) + src, err := getSource(&opts.Catalog, userInput) if err != nil { - if userInput == "power-user" { - bus.Notify("Note: the 'power-user' command has been removed.") - } - return fmt.Errorf("failed to construct source from user input %q: %w", userInput, err) + return err } defer func() { @@ -188,6 +147,63 @@ func runPackages(id clio.Identification, opts *packagesOptions, userInput string return nil } +func getSource(opts *options.Catalog, userInput string, filters ...func(*source.Detection) error) (source.Source, error) { + detection, err := source.Detect( + userInput, + source.DetectConfig{ + DefaultImageSource: opts.DefaultImagePullSource, + }, + ) + if err != nil { + return nil, fmt.Errorf("could not deteremine source: %w", err) + } + + for _, filter := range filters { + if err := filter(detection); err != nil { + return nil, err + } + } + + var platform *image.Platform + + if opts.Platform != "" { + platform, err = image.NewPlatform(opts.Platform) + if err != nil { + return nil, fmt.Errorf("invalid platform: %w", err) + } + } + + hashers, err := file.Hashers(opts.Source.File.Digests...) + if err != nil { + return nil, fmt.Errorf("invalid hash: %w", err) + } + + src, err := detection.NewSource( + source.DetectionSourceConfig{ + Alias: source.Alias{ + Name: opts.Source.Name, + Version: opts.Source.Version, + }, + RegistryOptions: opts.Registry.ToOptions(), + Platform: platform, + Exclude: source.ExcludeConfig{ + Paths: opts.Exclusions, + }, + DigestAlgorithms: hashers, + BasePath: opts.BasePath, + }, + ) + + if err != nil { + if userInput == "power-user" { + bus.Notify("Note: the 'power-user' command has been removed.") + } + return nil, fmt.Errorf("failed to construct source from user input %q: %w", userInput, err) + } + + return src, nil +} + func generateSBOM(id clio.Identification, src source.Source, opts *options.Catalog) (*sbom.SBOM, error) { tasks, err := eventloop.Tasks(opts) if err != nil { diff --git a/cmd/syft/cli/options/format_cyclonedx_json.go b/cmd/syft/cli/options/format_cyclonedx_json.go index 01366d7fb..8d2fecbba 100644 --- a/cmd/syft/cli/options/format_cyclonedx_json.go +++ b/cmd/syft/cli/options/format_cyclonedx_json.go @@ -32,8 +32,12 @@ func (o FormatCyclonedxJSON) formatEncoders() ([]sbom.FormatEncoder, error) { } func (o FormatCyclonedxJSON) buildConfig(version string) cyclonedxjson.EncoderConfig { + var pretty bool + if o.Pretty != nil { + pretty = *o.Pretty + } return cyclonedxjson.EncoderConfig{ Version: version, - Pretty: *o.Pretty, + Pretty: pretty, } } diff --git a/cmd/syft/cli/options/format_cyclonedx_xml.go b/cmd/syft/cli/options/format_cyclonedx_xml.go index e1376a545..359806db3 100644 --- a/cmd/syft/cli/options/format_cyclonedx_xml.go +++ b/cmd/syft/cli/options/format_cyclonedx_xml.go @@ -32,8 +32,12 @@ func (o FormatCyclonedxXML) formatEncoders() ([]sbom.FormatEncoder, error) { } func (o FormatCyclonedxXML) buildConfig(version string) cyclonedxxml.EncoderConfig { + var pretty bool + if o.Pretty != nil { + pretty = *o.Pretty + } return cyclonedxxml.EncoderConfig{ Version: version, - Pretty: *o.Pretty, + Pretty: pretty, } } diff --git a/cmd/syft/cli/options/format_spdx_json.go b/cmd/syft/cli/options/format_spdx_json.go index ff843abff..002e996a3 100644 --- a/cmd/syft/cli/options/format_spdx_json.go +++ b/cmd/syft/cli/options/format_spdx_json.go @@ -32,8 +32,12 @@ func (o FormatSPDXJSON) formatEncoders() ([]sbom.FormatEncoder, error) { } func (o FormatSPDXJSON) buildConfig(v string) spdxjson.EncoderConfig { + var pretty bool + if o.Pretty != nil { + pretty = *o.Pretty + } return spdxjson.EncoderConfig{ Version: v, - Pretty: *o.Pretty, + Pretty: pretty, } } diff --git a/cmd/syft/cli/options/format_syft_json.go b/cmd/syft/cli/options/format_syft_json.go index 9446a492f..a2765ba87 100644 --- a/cmd/syft/cli/options/format_syft_json.go +++ b/cmd/syft/cli/options/format_syft_json.go @@ -22,8 +22,12 @@ func (o FormatSyftJSON) formatEncoders() ([]sbom.FormatEncoder, error) { } func (o FormatSyftJSON) buildConfig() syftjson.EncoderConfig { + var pretty bool + if o.Pretty != nil { + pretty = *o.Pretty + } return syftjson.EncoderConfig{ Legacy: o.Legacy, - Pretty: *o.Pretty, + Pretty: pretty, } } diff --git a/cmd/syft/cli/options/output.go b/cmd/syft/cli/options/output.go index 67a7c6acf..82431c6a6 100644 --- a/cmd/syft/cli/options/output.go +++ b/cmd/syft/cli/options/output.go @@ -30,6 +30,7 @@ var _ interface { type Output struct { AllowableOptions []string `yaml:"-" json:"-" mapstructure:"-"` AllowMultipleOutputs bool `yaml:"-" json:"-" mapstructure:"-"` + AllowToFile bool `yaml:"-" json:"-" mapstructure:"-"` Outputs []string `yaml:"output" json:"output" mapstructure:"output"` // -o, the format to use for output OutputFile `yaml:",inline" json:"" mapstructure:",squash"` Format `yaml:"format" json:"format" mapstructure:"format"` @@ -38,6 +39,7 @@ type Output struct { func DefaultOutput() Output { return Output{ AllowMultipleOutputs: true, + AllowToFile: true, Outputs: []string{string(table.ID)}, OutputFile: OutputFile{ Enabled: true, @@ -86,6 +88,14 @@ func (o Output) SBOMWriter() (sbom.Writer, error) { return nil, err } + if !o.AllowToFile { + for _, opt := range o.Outputs { + if strings.Contains(opt, "=") { + return nil, fmt.Errorf("file output is not allowed ('-o format=path' should be '-o format')") + } + } + } + return makeSBOMWriter(o.Outputs, o.File, encoders) } diff --git a/cmd/syft/cli/options/output_test.go b/cmd/syft/cli/options/output_test.go index f16dd33c6..68a32d56d 100644 --- a/cmd/syft/cli/options/output_test.go +++ b/cmd/syft/cli/options/output_test.go @@ -179,3 +179,25 @@ func Test_EncoderCollection_ByString_IDOnly_Defaults(t *testing.T) { }) } } + +func Test_OutputHonorsAllowFile(t *testing.T) { + o := DefaultOutput() + + t.Run("file is not allowed", func(t *testing.T) { + o.AllowToFile = false + o.Outputs = []string{"table=/tmp/somefile"} + + w, err := o.SBOMWriter() + assert.Nil(t, w) + assert.ErrorContains(t, err, "file output is not allowed") + }) + + t.Run("file is allowed", func(t *testing.T) { + o.AllowToFile = true + o.Outputs = []string{"table=/tmp/somefile"} + + w, err := o.SBOMWriter() + assert.NotNil(t, w) + assert.NoError(t, err) + }) +} diff --git a/test/integration/convert_test.go b/test/integration/convert_test.go index 43670c52e..4a9dcb3a0 100644 --- a/test/integration/convert_test.go +++ b/test/integration/convert_test.go @@ -78,10 +78,9 @@ func TestConvertCmd(t *testing.T) { }() opts := &commands.ConvertOptions{ - Output: options.Output{ - Outputs: []string{fmt.Sprintf("%s=%s", test.format.ID().String(), formatFile.Name())}, - }, + Output: options.DefaultOutput(), } + opts.Outputs = []string{fmt.Sprintf("%s=%s", test.format.ID().String(), formatFile.Name())} require.NoError(t, opts.PostLoad()) // stdout reduction of test noise