From 2b7a9d0be32a71508739afd8c4efc38d7e027420 Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Tue, 29 Aug 2023 15:52:26 -0400 Subject: [PATCH] chore: update CLI to CLIO (#2001) Signed-off-by: Keith Zantow Signed-off-by: Alex Goodman Co-authored-by: Alex Goodman --- .goreleaser.yaml | 8 +- cmd/syft/cli/attest.go | 66 --- cmd/syft/cli/attest/attest.go | 261 ---------- cmd/syft/cli/cli.go | 81 ++++ cmd/syft/cli/commands.go | 167 ------- cmd/syft/cli/commands/attest.go | 258 ++++++++++ cmd/syft/cli/commands/convert.go | 95 ++++ cmd/syft/cli/commands/packages.go | 253 ++++++++++ cmd/syft/cli/commands/poweruser.go | 154 ++++++ cmd/syft/cli/commands/root.go | 27 ++ cmd/syft/cli/commands/update.go | 120 +++++ .../syft/cli/commands}/update_test.go | 9 +- cmd/syft/cli/convert.go | 58 --- cmd/syft/cli/convert/convert.go | 85 ---- cmd/syft/cli/eventloop/event_loop.go | 98 ---- cmd/syft/cli/eventloop/event_loop_test.go | 459 ------------------ cmd/syft/cli/eventloop/signals.go | 20 - cmd/syft/cli/eventloop/tasks.go | 53 +- cmd/syft/cli/options/attest.go | 25 +- cmd/syft/cli/options/catalog.go | 168 +++++++ cmd/syft/cli/options/config.go | 6 + cmd/syft/cli/options/file_classification.go | 17 + cmd/syft/cli/options/file_contents.go | 21 + cmd/syft/cli/options/file_metadata.go | 19 + cmd/syft/cli/options/fulcio.go | 49 -- .../config => cmd/syft/cli/options}/golang.go | 12 +- cmd/syft/cli/options/linux_kernel.go | 11 + cmd/syft/cli/options/oidc.go | 49 -- cmd/syft/cli/options/options.go | 11 - cmd/syft/cli/options/output.go | 95 ++++ cmd/syft/cli/options/packages.go | 118 ----- cmd/syft/cli/options/pkg.go | 23 + .../config => cmd/syft/cli/options}/python.go | 10 +- .../syft/cli/options}/registry.go | 39 +- .../syft/cli/options}/registry_test.go | 2 +- cmd/syft/cli/options/rekor.go | 33 -- cmd/syft/cli/options/root.go | 37 -- cmd/syft/cli/options/scope.go | 27 ++ cmd/syft/cli/options/secret.go | 25 + .../syft/cli/options}/secrets.go | 24 +- .../config => cmd/syft/cli/options}/source.go | 12 +- cmd/syft/cli/options/update_check.go | 11 + cmd/syft/cli/options/verbose.go | 18 - cmd/syft/cli/options/version.go | 17 - cmd/syft/cli/options/writer.go | 8 +- cmd/syft/cli/options/writer_test.go | 2 +- cmd/syft/cli/packages.go | 86 ---- cmd/syft/cli/packages/packages.go | 192 -------- cmd/syft/cli/poweruser.go | 51 -- cmd/syft/cli/poweruser/poweruser.go | 144 ------ cmd/syft/cli/version.go | 72 --- cmd/syft/internal/constants.go | 5 + cmd/syft/internal/ui/no_ui.go | 2 - cmd/syft/internal/ui/post_ui_event_writer.go | 10 +- .../internal/ui/post_ui_event_writer_test.go | 15 +- cmd/syft/internal/ui/ui.go | 23 +- cmd/syft/main.go | 32 +- go.mod | 20 +- go.sum | 15 +- internal/bus/helpers.go | 13 +- internal/config/application.go | 337 ------------- internal/config/application_test.go | 129 ----- internal/config/attest.go | 14 - internal/config/cataloger_options.go | 29 -- internal/config/development.go | 13 - internal/config/file_classification.go | 20 - internal/config/file_contents.go | 25 - internal/config/file_metadata.go | 22 - internal/config/linux_kernel.go | 11 - internal/config/logging.go | 34 -- internal/config/pkg.go | 24 - internal/config/test-fixtures/.syft.yaml | 7 - .../config-dir-test/.syft/config.yaml | 5 - .../config-home-test/config-file/.syft.yaml | 5 - .../config-home-test/syft/config.yaml | 5 - .../test-fixtures/config-wd-file/.syft.yaml | 5 - internal/constants.go | 3 - internal/log/log.go | 25 +- internal/redact/redact.go | 36 ++ internal/version/build.go | 54 --- internal/version/update.go | 73 --- syft/event/event.go | 7 +- syft/event/parsers/parsers.go | 24 +- .../formats/common/cyclonedxhelpers/format.go | 3 +- .../common/spdxhelpers/document_namespace.go | 10 +- .../spdxhelpers/document_namespace_test.go | 8 +- .../common/spdxhelpers/to_format_model.go | 4 +- syft/formats/github/encoder.go | 3 +- syft/formats/github/encoder_test.go | 3 + syft/formats/internal/testutils/snapshot.go | 18 +- syft/formats/spdxtagvalue/encoder_test.go | 2 +- syft/pkg/cataloger/rpm/parse_rpm_db.go | 3 +- test/cli/packages_cmd_test.go | 6 +- test/cli/trait_assertions_test.go | 48 +- test/integration/convert_test.go | 14 +- 95 files changed, 1679 insertions(+), 3191 deletions(-) delete mode 100644 cmd/syft/cli/attest.go delete mode 100644 cmd/syft/cli/attest/attest.go create mode 100644 cmd/syft/cli/cli.go delete mode 100644 cmd/syft/cli/commands.go create mode 100644 cmd/syft/cli/commands/attest.go create mode 100644 cmd/syft/cli/commands/convert.go create mode 100644 cmd/syft/cli/commands/packages.go create mode 100644 cmd/syft/cli/commands/poweruser.go create mode 100644 cmd/syft/cli/commands/root.go create mode 100644 cmd/syft/cli/commands/update.go rename {internal/version => cmd/syft/cli/commands}/update_test.go (95%) delete mode 100644 cmd/syft/cli/convert.go delete mode 100644 cmd/syft/cli/convert/convert.go delete mode 100644 cmd/syft/cli/eventloop/event_loop.go delete mode 100644 cmd/syft/cli/eventloop/event_loop_test.go delete mode 100644 cmd/syft/cli/eventloop/signals.go create mode 100644 cmd/syft/cli/options/catalog.go create mode 100644 cmd/syft/cli/options/config.go create mode 100644 cmd/syft/cli/options/file_classification.go create mode 100644 cmd/syft/cli/options/file_contents.go create mode 100644 cmd/syft/cli/options/file_metadata.go delete mode 100644 cmd/syft/cli/options/fulcio.go rename {internal/config => cmd/syft/cli/options}/golang.go (64%) create mode 100644 cmd/syft/cli/options/linux_kernel.go delete mode 100644 cmd/syft/cli/options/oidc.go delete mode 100644 cmd/syft/cli/options/options.go create mode 100644 cmd/syft/cli/options/output.go delete mode 100644 cmd/syft/cli/options/packages.go create mode 100644 cmd/syft/cli/options/pkg.go rename {internal/config => cmd/syft/cli/options}/python.go (50%) rename {internal/config => cmd/syft/cli/options}/registry.go (71%) rename {internal/config => cmd/syft/cli/options}/registry_test.go (99%) delete mode 100644 cmd/syft/cli/options/rekor.go delete mode 100644 cmd/syft/cli/options/root.go create mode 100644 cmd/syft/cli/options/scope.go create mode 100644 cmd/syft/cli/options/secret.go rename {internal/config => cmd/syft/cli/options}/secrets.go (52%) rename {internal/config => cmd/syft/cli/options}/source.go (67%) create mode 100644 cmd/syft/cli/options/update_check.go delete mode 100644 cmd/syft/cli/options/verbose.go delete mode 100644 cmd/syft/cli/options/version.go delete mode 100644 cmd/syft/cli/packages.go delete mode 100644 cmd/syft/cli/packages/packages.go delete mode 100644 cmd/syft/cli/poweruser.go delete mode 100644 cmd/syft/cli/poweruser/poweruser.go delete mode 100644 cmd/syft/cli/version.go create mode 100644 cmd/syft/internal/constants.go delete mode 100644 internal/config/application.go delete mode 100644 internal/config/application_test.go delete mode 100644 internal/config/attest.go delete mode 100644 internal/config/cataloger_options.go delete mode 100644 internal/config/development.go delete mode 100644 internal/config/file_classification.go delete mode 100644 internal/config/file_contents.go delete mode 100644 internal/config/file_metadata.go delete mode 100644 internal/config/linux_kernel.go delete mode 100644 internal/config/logging.go delete mode 100644 internal/config/pkg.go delete mode 100644 internal/config/test-fixtures/.syft.yaml delete mode 100644 internal/config/test-fixtures/config-dir-test/.syft/config.yaml delete mode 100644 internal/config/test-fixtures/config-home-test/config-file/.syft.yaml delete mode 100644 internal/config/test-fixtures/config-home-test/syft/config.yaml delete mode 100644 internal/config/test-fixtures/config-wd-file/.syft.yaml create mode 100644 internal/redact/redact.go delete mode 100644 internal/version/build.go delete mode 100644 internal/version/update.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index d4e05af8e84..f31f769df0f 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -24,10 +24,10 @@ builds: -w -s -extldflags '-static' - -X github.com/anchore/syft/internal/version.version={{.Version}} - -X github.com/anchore/syft/internal/version.gitCommit={{.Commit}} - -X github.com/anchore/syft/internal/version.buildDate={{.Date}} - -X github.com/anchore/syft/internal/version.gitDescription={{.Summary}} + -X main.version={{.Version}} + -X main.gitCommit={{.Commit}} + -X main.buildDate={{.Date}} + -X main.gitDescription={{.Summary}} - id: darwin-build dir: ./cmd/syft diff --git a/cmd/syft/cli/attest.go b/cmd/syft/cli/attest.go deleted file mode 100644 index 0234057fe8c..00000000000 --- a/cmd/syft/cli/attest.go +++ /dev/null @@ -1,66 +0,0 @@ -package cli - -import ( - "fmt" - "log" - - "github.com/spf13/cobra" - "github.com/spf13/viper" - - "github.com/anchore/syft/cmd/syft/cli/attest" - "github.com/anchore/syft/cmd/syft/cli/options" - "github.com/anchore/syft/internal" - "github.com/anchore/syft/internal/config" -) - -const ( - attestExample = ` {{.appName}} {{.command}} --output [FORMAT] alpine:latest defaults to using images from a Docker daemon. If Docker is not present, the image is pulled directly from the registry -` - attestSchemeHelp = "\n" + indent + schemeHelpHeader + "\n" + imageSchemeHelp - attestHelp = attestExample + attestSchemeHelp -) - -func Attest(v *viper.Viper, app *config.Application, ro *options.RootOptions, po *options.PackagesOptions, ao *options.AttestOptions) *cobra.Command { - cmd := &cobra.Command{ - Use: "attest --output [FORMAT] ", - Short: "Generate an SBOM as an attestation for the given [SOURCE] container image", - Long: "Generate a packaged-based Software Bill Of Materials (SBOM) from a container image as the predicate of an in-toto attestation that will be uploaded to the image registry", - Example: internal.Tprintf(attestHelp, map[string]interface{}{ - "appName": internal.ApplicationName, - "command": "attest", - }), - Args: func(cmd *cobra.Command, args []string) error { - if err := app.LoadAllValues(v, ro.Config); err != nil { - return fmt.Errorf("unable to load configuration: %w", err) - } - - newLogWrapper(app) - logApplicationConfig(app) - return validateArgs(cmd, args) - }, - SilenceUsage: true, - SilenceErrors: true, - RunE: func(cmd *cobra.Command, args []string) error { - if app.CheckForAppUpdate { - checkForApplicationUpdate() - // TODO: this is broke, the bus isn't available yet - } - - return attest.Run(cmd.Context(), app, args) - }, - } - - // syft attest is an enhancement of the packages command, so it should have the same flags - err := po.AddFlags(cmd, v) - if err != nil { - log.Fatal(err) - } - - // syft attest has its own options not included as part of the packages command - err = ao.AddFlags(cmd, v) - if err != nil { - log.Fatal(err) - } - - return cmd -} diff --git a/cmd/syft/cli/attest/attest.go b/cmd/syft/cli/attest/attest.go deleted file mode 100644 index 758af670635..00000000000 --- a/cmd/syft/cli/attest/attest.go +++ /dev/null @@ -1,261 +0,0 @@ -package attest - -import ( - "context" - "fmt" - "os" - "os/exec" - "strings" - - "github.com/wagoodman/go-partybus" - "github.com/wagoodman/go-progress" - "golang.org/x/exp/slices" - - "github.com/anchore/stereoscope" - "github.com/anchore/stereoscope/pkg/image" - "github.com/anchore/syft/cmd/syft/cli/eventloop" - "github.com/anchore/syft/cmd/syft/cli/options" - "github.com/anchore/syft/cmd/syft/cli/packages" - "github.com/anchore/syft/cmd/syft/internal/ui" - "github.com/anchore/syft/internal/bus" - "github.com/anchore/syft/internal/config" - "github.com/anchore/syft/internal/file" - "github.com/anchore/syft/internal/log" - "github.com/anchore/syft/syft" - "github.com/anchore/syft/syft/event" - "github.com/anchore/syft/syft/event/monitor" - "github.com/anchore/syft/syft/formats/syftjson" - "github.com/anchore/syft/syft/formats/table" - "github.com/anchore/syft/syft/sbom" - "github.com/anchore/syft/syft/source" -) - -func Run(_ context.Context, app *config.Application, args []string) error { - err := ValidateOutputOptions(app) - if err != nil { - return err - } - - // note: must be a container image - userInput := args[0] - - _, 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) - } - - eventBus := partybus.NewBus() - stereoscope.SetBus(eventBus) - syft.SetBus(eventBus) - subscription := eventBus.Subscribe() - - return eventloop.EventLoop( - execWorker(app, userInput), - eventloop.SetupSignals(), - subscription, - stereoscope.Cleanup, - ui.Select(options.IsVerbose(app), app.Quiet)..., - ) -} - -func buildSBOM(app *config.Application, userInput string, errs chan error) (*sbom.SBOM, error) { - cfg := source.DetectConfig{ - DefaultImageSource: app.DefaultImagePullSource, - } - detection, err := source.Detect(userInput, cfg) - if err != nil { - return nil, fmt.Errorf("could not deteremine source: %w", err) - } - - if detection.IsContainerImage() { - return nil, fmt.Errorf("attestations are only supported for oci images at this time") - } - - var platform *image.Platform - - if app.Platform != "" { - platform, err = image.NewPlatform(app.Platform) - if err != nil { - return nil, fmt.Errorf("invalid platform: %w", err) - } - } - - hashers, err := file.Hashers(app.Source.File.Digests...) - if err != nil { - return nil, fmt.Errorf("invalid hash: %w", err) - } - - src, err := detection.NewSource( - source.DetectionSourceConfig{ - Alias: source.Alias{ - Name: app.Source.Name, - Version: app.Source.Version, - }, - RegistryOptions: app.Registry.ToOptions(), - Platform: platform, - Exclude: source.ExcludeConfig{ - Paths: app.Exclusions, - }, - DigestAlgorithms: hashers, - BasePath: app.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 := packages.GenerateSBOM(src, errs, app) - if err != nil { - return nil, err - } - - if s == nil { - return nil, fmt.Errorf("no SBOM produced for %q", userInput) - } - - return s, nil -} - -//nolint:funlen -func execWorker(app *config.Application, userInput string) <-chan error { - errs := make(chan error) - go func() { - defer close(errs) - defer bus.Exit() - - s, err := buildSBOM(app, userInput, errs) - if err != nil { - errs <- fmt.Errorf("unable to build SBOM: %w", err) - return - } - - // note: ValidateOutputOptions ensures that there is no more than one output type - o := app.Outputs[0] - - f, err := os.CreateTemp("", o) - if err != nil { - errs <- fmt.Errorf("unable to create temp file: %w", err) - return - } - defer os.Remove(f.Name()) - - writer, err := options.MakeSBOMWriter(app.Outputs, f.Name(), app.OutputTemplatePath) - if err != nil { - errs <- fmt.Errorf("unable to create SBOM writer: %w", err) - return - } - - if err := writer.Write(*s); err != nil { - errs <- fmt.Errorf("unable to write SBOM to temp file: %w", err) - return - } - - // TODO: what other validation here besides binary name? - cmd := "cosign" - if !commandExists(cmd) { - errs <- fmt.Errorf("unable to find cosign in PATH; make sure you have it installed") - return - } - - // 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(o) { - case "cyclonedx-json": - predicateType = "cyclonedx" - case "spdx-tag-value", "spdx-tv": - predicateType = "spdx" - case "spdx-json", "json": - predicateType = "spdxjson" - default: - predicateType = "custom" - } - - args := []string{"attest", userInput, "--predicate", f.Name(), "--type", predicateType} - if app.Attest.Key != "" { - args = append(args, "--key", app.Attest.Key) - } - - execCmd := exec.Command(cmd, args...) - execCmd.Env = os.Environ() - if app.Attest.Key != "" { - execCmd.Env = append(execCmd.Env, fmt.Sprintf("COSIGN_PASSWORD=%s", app.Attest.Password)) - } else { - // no key provided, use cosign's keyless mode - execCmd.Env = append(execCmd.Env, "COSIGN_EXPERIMENTAL=1") - } - - log.WithFields("cmd", strings.Join(execCmd.Args, " ")).Trace("creating attestation") - - // bus adapter for ui to hook into stdout via an os pipe - r, w, err := os.Pipe() - if err != nil { - errs <- fmt.Errorf("unable to create os pipe: %w", err) - return - } - defer w.Close() - - mon := progress.NewManual(-1) - - bus.Publish( - partybus.Event{ - Type: event.AttestationStarted, - Source: monitor.GenericTask{ - Title: monitor.Title{ - Default: "Create attestation", - WhileRunning: "Creating attestation", - OnSuccess: "Created attestation", - }, - Context: "cosign", - }, - Value: &monitor.ShellProgress{ - Reader: r, - Progressable: mon, - }, - }, - ) - - execCmd.Stdout = w - execCmd.Stderr = w - - // attest the SBOM - err = execCmd.Run() - if err != nil { - mon.SetError(err) - errs <- fmt.Errorf("unable to attest SBOM: %w", err) - return - } - - mon.SetCompleted() - }() - return errs -} - -func ValidateOutputOptions(app *config.Application) error { - err := packages.ValidateOutputOptions(app) - if err != nil { - return err - } - - if len(app.Outputs) > 1 { - return fmt.Errorf("multiple SBOM format is not supported for attest at this time") - } - - // cannot use table as default output format when using template output - if slices.Contains(app.Outputs, table.ID.String()) { - app.Outputs = []string{syftjson.ID.String()} - } - - return nil -} - -func commandExists(cmd string) bool { - _, err := exec.LookPath(cmd) - return err == nil -} diff --git a/cmd/syft/cli/cli.go b/cmd/syft/cli/cli.go new file mode 100644 index 00000000000..187cf09c41c --- /dev/null +++ b/cmd/syft/cli/cli.go @@ -0,0 +1,81 @@ +package cli + +import ( + "os" + + cranecmd "github.com/google/go-containerregistry/cmd/crane/cmd" + + "github.com/anchore/clio" + "github.com/anchore/stereoscope" + "github.com/anchore/syft/cmd/syft/cli/commands" + handler "github.com/anchore/syft/cmd/syft/cli/ui" + "github.com/anchore/syft/cmd/syft/internal/ui" + "github.com/anchore/syft/internal/bus" + "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/internal/redact" +) + +// New constructs the `syft packages` command, aliases the root command to `syft packages`, +// and constructs the `syft power-user` command. It is also responsible for +// organizing flag usage and injecting the application config for each command. +// It also constructs the syft attest command and the syft version command. +// `RunE` is the earliest that the complete application configuration can be loaded. +func New(id clio.Identification) clio.Application { + clioCfg := clio.NewSetupConfig(id). + WithGlobalConfigFlag(). // add persistent -c for reading an application config from + WithGlobalLoggingFlags(). // add persistent -v and -q flags tied to the logging config + WithConfigInRootHelp(). // --help on the root command renders the full application config in the help text + WithUIConstructor( + // select a UI based on the logging configuration and state of stdin (if stdin is a tty) + func(cfg clio.Config) ([]clio.UI, error) { + noUI := ui.None(cfg.Log.Quiet) + if !cfg.Log.AllowUI(os.Stdin) { + return []clio.UI{noUI}, nil + } + + h := handler.New(handler.DefaultHandlerConfig()) + + return []clio.UI{ + ui.New(h, false, cfg.Log.Quiet), + noUI, + }, nil + }, + ). + WithInitializers( + func(state *clio.State) error { + // clio is setting up and providing the bus, redact store, and logger to the application. Once loaded, + // we can hoist them into the internal packages for global use. + stereoscope.SetBus(state.Bus) + bus.Set(state.Bus) + redact.Set(state.RedactStore) + log.Set(state.Logger) + stereoscope.SetLogger(state.Logger) + + return nil + }, + ). + WithPostRuns(func(state *clio.State, err error) { + stereoscope.Cleanup() + }) + + app := clio.New(*clioCfg) + + // since root is aliased as the packages cmd we need to construct this command first + // we also need the command to have information about the `root` options because of this alias + packagesCmd := commands.Packages(app) + + // rootCmd is currently an alias for the packages command + rootCmd := commands.Root(app, packagesCmd) + + // add sub-commands + rootCmd.AddCommand( + packagesCmd, + commands.PowerUser(app), + commands.Attest(app), + commands.Convert(app), + clio.VersionCommand(id), + cranecmd.NewCmdAuthLogin(id.Name), // syft login uses the same command as crane + ) + + return app +} diff --git a/cmd/syft/cli/commands.go b/cmd/syft/cli/commands.go deleted file mode 100644 index aa64d5a625e..00000000000 --- a/cmd/syft/cli/commands.go +++ /dev/null @@ -1,167 +0,0 @@ -package cli - -import ( - "fmt" - "strings" - - cranecmd "github.com/google/go-containerregistry/cmd/crane/cmd" - "github.com/gookit/color" - logrusUpstream "github.com/sirupsen/logrus" - "github.com/spf13/cobra" - "github.com/spf13/viper" - "github.com/wagoodman/go-partybus" - - "github.com/anchore/go-logger/adapter/logrus" - "github.com/anchore/stereoscope" - "github.com/anchore/syft/cmd/syft/cli/options" - "github.com/anchore/syft/internal" - "github.com/anchore/syft/internal/bus" - "github.com/anchore/syft/internal/config" - "github.com/anchore/syft/internal/log" - "github.com/anchore/syft/internal/version" - "github.com/anchore/syft/syft" - "github.com/anchore/syft/syft/event" -) - -const indent = " " - -// New constructs the `syft packages` command, aliases the root command to `syft packages`, -// and constructs the `syft power-user` command. It is also responsible for -// organizing flag usage and injecting the application config for each command. -// It also constructs the syft attest command and the syft version command. - -// Because of how the `cobra` library behaves, the application's configuration is initialized -// at this level. Values from the config should only be used after `app.LoadAllValues` has been called. -// Cobra does not have knowledge of the user provided flags until the `RunE` block of each command. -// `RunE` is the earliest that the complete application configuration can be loaded. -func New() (*cobra.Command, error) { - app := &config.Application{} - - // allow for nested options to be specified via environment variables - // e.g. pod.context = APPNAME_POD_CONTEXT - v := viper.NewWithOptions(viper.EnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))) - - // since root is aliased as the packages cmd we need to construct this command first - // we also need the command to have information about the `root` options because of this alias - ro := &options.RootOptions{} - po := &options.PackagesOptions{} - ao := &options.AttestOptions{} - packagesCmd := Packages(v, app, ro, po) - - // root options are also passed to the attestCmd so that a user provided config location can be discovered - poweruserCmd := PowerUser(v, app, ro) - convertCmd := Convert(v, app, ro, po) - attestCmd := Attest(v, app, ro, po, ao) - - // rootCmd is currently an alias for the packages command - rootCmd := &cobra.Command{ - Use: fmt.Sprintf("%s [SOURCE]", internal.ApplicationName), - Short: packagesCmd.Short, - Long: packagesCmd.Long, - Args: packagesCmd.Args, - Example: packagesCmd.Example, - SilenceUsage: true, - SilenceErrors: true, - RunE: packagesCmd.RunE, - Version: version.FromBuild().Version, - } - rootCmd.SetVersionTemplate(fmt.Sprintf("%s {{.Version}}\n", internal.ApplicationName)) - - // start adding flags to all the commands - err := ro.AddFlags(rootCmd, v) - if err != nil { - return nil, err - } - // package flags need to be decorated onto the rootCmd so that rootCmd can function as a packages alias - err = po.AddFlags(rootCmd, v) - if err != nil { - return nil, err - } - - // poweruser also uses the packagesCmd flags since it is a specialized version of the command - err = po.AddFlags(poweruserCmd, v) - if err != nil { - return nil, err - } - - // commands to add to root - cmds := []*cobra.Command{ - packagesCmd, - poweruserCmd, - convertCmd, - attestCmd, - Version(v, app), - cranecmd.NewCmdAuthLogin("syft"), // syft login uses the same command as crane - } - - // Add sub-commands. - for _, cmd := range cmds { - rootCmd.AddCommand(cmd) - } - - return rootCmd, err -} - -func validateArgs(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - // in the case that no arguments are given we want to show the help text and return with a non-0 return code. - if err := cmd.Help(); err != nil { - return fmt.Errorf("unable to display help: %w", err) - } - return fmt.Errorf("an image/directory argument is required") - } - - return cobra.MaximumNArgs(1)(cmd, args) -} - -func checkForApplicationUpdate() { - log.Debugf("checking if a new version of %s is available", internal.ApplicationName) - isAvailable, newVersion, err := version.IsUpdateAvailable() - if err != nil { - // this should never stop the application - log.Errorf(err.Error()) - } - if isAvailable { - log.Infof("new version of %s is available: %s (current version is %s)", internal.ApplicationName, newVersion, version.FromBuild().Version) - - bus.Publish(partybus.Event{ - Type: event.CLIAppUpdateAvailable, - Value: newVersion, - }) - } else { - log.Debugf("no new %s update available", internal.ApplicationName) - } -} - -func logApplicationConfig(app *config.Application) { - versionInfo := version.FromBuild() - log.Infof("%s version: %+v", internal.ApplicationName, versionInfo.Version) - log.Debugf("application config:\n%+v", color.Magenta.Sprint(app.String())) -} - -func newLogWrapper(app *config.Application) { - cfg := logrus.Config{ - EnableConsole: (app.Log.FileLocation == "" || app.Verbosity > 0) && !app.Quiet, - FileLocation: app.Log.FileLocation, - Level: app.Log.Level, - } - - if app.Log.Structured { - cfg.Formatter = &logrusUpstream.JSONFormatter{ - TimestampFormat: "2006-01-02 15:04:05", - DisableTimestamp: false, - DisableHTMLEscape: false, - PrettyPrint: false, - } - } - - logWrapper, err := logrus.New(cfg) - if err != nil { - // this is kinda circular, but we can't return an error... ¯\_(ツ)_/¯ - // I'm going to leave this here in case we one day have a different default logger other than the "discard" logger - log.Error("unable to initialize logger: %+v", err) - return - } - syft.SetLogger(logWrapper) - stereoscope.SetLogger(logWrapper.Nested("from-lib", "stereoscope")) -} diff --git a/cmd/syft/cli/commands/attest.go b/cmd/syft/cli/commands/attest.go new file mode 100644 index 00000000000..97ade2437c0 --- /dev/null +++ b/cmd/syft/cli/commands/attest.go @@ -0,0 +1,258 @@ +package commands + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/spf13/cobra" + "github.com/wagoodman/go-partybus" + "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/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/formats" + "github.com/anchore/syft/syft/formats/github" + "github.com/anchore/syft/syft/formats/syftjson" + "github.com/anchore/syft/syft/formats/table" + "github.com/anchore/syft/syft/formats/template" + "github.com/anchore/syft/syft/formats/text" + "github.com/anchore/syft/syft/sbom" + "github.com/anchore/syft/syft/source" +) + +const ( + attestExample = ` {{.appName}} {{.command}} --output [FORMAT] alpine:latest defaults to using images from a Docker daemon. If Docker is not present, the image is pulled directly from the registry +` + attestSchemeHelp = "\n " + schemeHelpHeader + "\n" + imageSchemeHelp + attestHelp = attestExample + attestSchemeHelp +) + +type attestOptions struct { + options.Config `yaml:",inline" mapstructure:",squash"` + options.SingleOutput `yaml:",inline" mapstructure:",squash"` + options.UpdateCheck `yaml:",inline" mapstructure:",squash"` + options.Catalog `yaml:",inline" mapstructure:",squash"` + options.Attest `yaml:",inline" mapstructure:",squash"` +} + +func Attest(app clio.Application) *cobra.Command { + id := app.ID() + + var allowableOutputs []string + for _, f := range formats.AllIDs() { + switch f { + case table.ID, text.ID, github.ID, template.ID: + continue + } + allowableOutputs = append(allowableOutputs, f.String()) + } + + opts := &attestOptions{ + UpdateCheck: options.DefaultUpdateCheck(), + SingleOutput: options.SingleOutput{ + AllowableOptions: allowableOutputs, + Output: syftjson.ID.String(), + }, + Catalog: options.DefaultCatalog(), + } + + return app.SetupCommand(&cobra.Command{ + Use: "attest --output [FORMAT] ", + Short: "Generate an SBOM as an attestation for the given [SOURCE] container image", + Long: "Generate a packaged-based Software Bill Of Materials (SBOM) from a container image as the predicate of an in-toto attestation that will be uploaded to the image registry", + Example: internal.Tprintf(attestHelp, map[string]interface{}{ + "appName": id.Name, + "command": "attest", + }), + Args: validatePackagesArgs, + PreRunE: applicationUpdateCheck(id, &opts.UpdateCheck), + RunE: func(cmd *cobra.Command, args []string) error { + return runAttest(id, opts, args[0]) + }, + }, opts) +} + +//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) + } + + o := opts.Output + + f, err := os.CreateTemp("", o) + if err != nil { + return fmt.Errorf("unable to create temp file: %w", err) + } + defer os.Remove(f.Name()) + + writer, err := opts.SBOMWriter(f.Name()) + if err != nil { + return fmt.Errorf("unable to create SBOM writer: %w", err) + } + + if err := writer.Write(*s); err != nil { + return fmt.Errorf("unable to write SBOM to temp 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") + } + + // 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(o) { + case "cyclonedx-json": + predicateType = "cyclonedx" + case "spdx-tag-value", "spdx-tv": + predicateType = "spdx" + case "spdx-json", "json": + predicateType = "spdxjson" + default: + predicateType = "custom" + } + + args := []string{"attest", userInput, "--predicate", f.Name(), "--type", predicateType} + if opts.Attest.Key != "" { + args = append(args, "--key", opts.Attest.Key.String()) + } + + 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") + } + + log.WithFields("cmd", strings.Join(execCmd.Args, " ")).Trace("creating attestation") + + // bus adapter for ui to hook into stdout via an os pipe + r, w, err := os.Pipe() + if err != nil { + return fmt.Errorf("unable to create os pipe: %w", err) + } + defer w.Close() + + mon := progress.NewManual(-1) + + bus.Publish( + partybus.Event{ + Type: event.AttestationStarted, + Source: monitor.GenericTask{ + Title: monitor.Title{ + Default: "Create attestation", + WhileRunning: "Creating attestation", + OnSuccess: "Created attestation", + }, + Context: "cosign", + }, + Value: &monitor.ShellProgress{ + Reader: r, + Progressable: mon, + }, + }, + ) + + execCmd.Stdout = w + execCmd.Stderr = w + + // attest the SBOM + err = execCmd.Run() + if err != nil { + mon.SetError(err) + return fmt.Errorf("unable to attest SBOM: %w", err) + } + + mon.SetCompleted() + + return nil +} + +func buildSBOM(id clio.Identification, opts *options.Catalog, userInput string) (*sbom.SBOM, error) { + cfg := source.DetectConfig{ + DefaultImageSource: opts.DefaultImagePullSource, + } + detection, err := source.Detect(userInput, cfg) + if err != nil { + return nil, fmt.Errorf("could not deteremine source: %w", 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) + } + } + + 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 { + return nil, err + } + + if s == nil { + return nil, fmt.Errorf("no SBOM produced for %q", userInput) + } + + return s, nil +} + +func commandExists(cmd string) bool { + _, err := exec.LookPath(cmd) + return err == nil +} diff --git a/cmd/syft/cli/commands/convert.go b/cmd/syft/cli/commands/convert.go new file mode 100644 index 00000000000..f42b7da629e --- /dev/null +++ b/cmd/syft/cli/commands/convert.go @@ -0,0 +1,95 @@ +package commands + +import ( + "fmt" + "io" + "os" + + "github.com/spf13/cobra" + + "github.com/anchore/clio" + "github.com/anchore/syft/cmd/syft/cli/options" + "github.com/anchore/syft/internal" + "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/syft/formats" +) + +const ( + convertExample = ` {{.appName}} {{.command}} img.syft.json -o spdx-json convert a syft SBOM to spdx-json, output goes to stdout + {{.appName}} {{.command}} img.syft.json -o cyclonedx-json=img.cdx.json convert a syft SBOM to CycloneDX, output is written to the file "img.cdx.json"" + {{.appName}} {{.command}} - -o spdx-json convert an SBOM from STDIN to spdx-json +` +) + +type ConvertOptions struct { + options.Config `yaml:",inline" mapstructure:",squash"` + options.MultiOutput `yaml:",inline" mapstructure:",squash"` + options.UpdateCheck `yaml:",inline" mapstructure:",squash"` +} + +//nolint:dupl +func Convert(app clio.Application) *cobra.Command { + id := app.ID() + + opts := &ConvertOptions{ + UpdateCheck: options.DefaultUpdateCheck(), + } + + return app.SetupCommand(&cobra.Command{ + Use: "convert [SOURCE-SBOM] -o [FORMAT]", + Short: "Convert between SBOM formats", + Long: "[Experimental] Convert SBOM files to, and from, SPDX, CycloneDX and Syft's format. For more info about data loss between formats see https://github.com/anchore/syft#format-conversion-experimental", + Example: internal.Tprintf(convertExample, map[string]interface{}{ + "appName": id.Name, + "command": "convert", + }), + Args: validateConvertArgs, + PreRunE: applicationUpdateCheck(id, &opts.UpdateCheck), + RunE: func(cmd *cobra.Command, args []string) error { + return RunConvert(opts, args[0]) + }, + }, opts) +} + +func validateConvertArgs(cmd *cobra.Command, args []string) error { + return validateArgs(cmd, args, "an SBOM argument is required") +} + +func RunConvert(opts *ConvertOptions, userInput string) error { + log.Warn("convert is an experimental feature, run `syft convert -h` for help") + + writer, err := opts.SBOMWriter() + if err != nil { + return err + } + + var reader io.ReadCloser + + if userInput == "-" { + reader = os.Stdin + } else { + f, err := os.Open(userInput) + if err != nil { + return fmt.Errorf("failed to open SBOM file: %w", err) + } + defer func() { + _ = f.Close() + }() + reader = f + } + + s, _, err := formats.Decode(reader) + if err != nil { + return fmt.Errorf("failed to decode SBOM: %w", err) + } + + if s == nil { + return fmt.Errorf("no SBOM produced") + } + + if err := writer.Write(*s); err != nil { + return fmt.Errorf("failed to write SBOM: %w", err) + } + + return nil +} diff --git a/cmd/syft/cli/commands/packages.go b/cmd/syft/cli/commands/packages.go new file mode 100644 index 00000000000..c3a67992f7f --- /dev/null +++ b/cmd/syft/cli/commands/packages.go @@ -0,0 +1,253 @@ +package commands + +import ( + "fmt" + + "github.com/hashicorp/go-multierror" + "github.com/spf13/cobra" + + "github.com/anchore/clio" + "github.com/anchore/stereoscope/pkg/image" + "github.com/anchore/syft/cmd/syft/cli/eventloop" + "github.com/anchore/syft/cmd/syft/cli/options" + "github.com/anchore/syft/internal" + "github.com/anchore/syft/internal/file" + "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/syft/artifact" + "github.com/anchore/syft/syft/formats/template" + "github.com/anchore/syft/syft/sbom" + "github.com/anchore/syft/syft/source" +) + +const ( + packagesExample = ` {{.appName}} {{.command}} alpine:latest a summary of discovered packages + {{.appName}} {{.command}} alpine:latest -o json show all possible cataloging details + {{.appName}} {{.command}} alpine:latest -o cyclonedx show a CycloneDX formatted SBOM + {{.appName}} {{.command}} alpine:latest -o cyclonedx-json show a CycloneDX JSON formatted SBOM + {{.appName}} {{.command}} alpine:latest -o spdx show a SPDX 2.3 Tag-Value formatted SBOM + {{.appName}} {{.command}} alpine:latest -o spdx@2.2 show a SPDX 2.2 Tag-Value formatted SBOM + {{.appName}} {{.command}} alpine:latest -o spdx-json show a SPDX 2.3 JSON formatted SBOM + {{.appName}} {{.command}} alpine:latest -o spdx-json@2.2 show a SPDX 2.2 JSON formatted SBOM + {{.appName}} {{.command}} alpine:latest -vv show verbose debug information + {{.appName}} {{.command}} alpine:latest -o template -t my_format.tmpl show a SBOM formatted according to given template file + + Supports the following image sources: + {{.appName}} {{.command}} yourrepo/yourimage:tag defaults to using images from a Docker daemon. If Docker is not present, the image is pulled directly from the registry. + {{.appName}} {{.command}} path/to/a/file/or/dir a Docker tar, OCI tar, OCI directory, SIF container, or generic filesystem directory +` + + schemeHelpHeader = "You can also explicitly specify the scheme to use:" + imageSchemeHelp = ` {{.appName}} {{.command}} docker:yourrepo/yourimage:tag explicitly use the Docker daemon + {{.appName}} {{.command}} podman:yourrepo/yourimage:tag explicitly use the Podman daemon + {{.appName}} {{.command}} registry:yourrepo/yourimage:tag pull image directly from a registry (no container runtime required) + {{.appName}} {{.command}} docker-archive:path/to/yourimage.tar use a tarball from disk for archives created from "docker save" + {{.appName}} {{.command}} oci-archive:path/to/yourimage.tar use a tarball from disk for OCI archives (from Skopeo or otherwise) + {{.appName}} {{.command}} oci-dir:path/to/yourimage read directly from a path on disk for OCI layout directories (from Skopeo or otherwise) + {{.appName}} {{.command}} singularity:path/to/yourimage.sif read directly from a Singularity Image Format (SIF) container on disk +` + nonImageSchemeHelp = ` {{.appName}} {{.command}} dir:path/to/yourproject read directly from a path on disk (any directory) + {{.appName}} {{.command}} file:path/to/yourproject/file read directly from a path on disk (any single file) +` + packagesSchemeHelp = "\n " + schemeHelpHeader + "\n" + imageSchemeHelp + nonImageSchemeHelp + + packagesHelp = packagesExample + packagesSchemeHelp +) + +type packagesOptions struct { + options.Config `yaml:",inline" mapstructure:",squash"` + options.MultiOutput `yaml:",inline" mapstructure:",squash"` + options.UpdateCheck `yaml:",inline" mapstructure:",squash"` + options.Catalog `yaml:",inline" mapstructure:",squash"` +} + +func defaultPackagesOptions() *packagesOptions { + return &packagesOptions{ + MultiOutput: options.DefaultOutput(), + UpdateCheck: options.DefaultUpdateCheck(), + Catalog: options.DefaultCatalog(), + } +} + +//nolint:dupl +func Packages(app clio.Application) *cobra.Command { + id := app.ID() + + opts := defaultPackagesOptions() + + return app.SetupCommand(&cobra.Command{ + Use: "packages [SOURCE]", + Short: "Generate a package SBOM", + Long: "Generate a packaged-based Software Bill Of Materials (SBOM) from container images and filesystems", + Example: internal.Tprintf(packagesHelp, map[string]interface{}{ + "appName": id.Name, + "command": "packages", + }), + Args: validatePackagesArgs, + PreRunE: applicationUpdateCheck(id, &opts.UpdateCheck), + RunE: func(cmd *cobra.Command, args []string) error { + return runPackages(id, opts, args[0]) + }, + }, opts) +} + +func validatePackagesArgs(cmd *cobra.Command, args []string) error { + return validateArgs(cmd, args, "an image/directory argument is required") +} + +func validateArgs(cmd *cobra.Command, args []string, error string) error { + if len(args) == 0 { + // in the case that no arguments are given we want to show the help text and return with a non-0 return code. + if err := cmd.Help(); err != nil { + return fmt.Errorf("unable to display help: %w", err) + } + return fmt.Errorf(error) + } + + return cobra.MaximumNArgs(1)(cmd, args) +} + +// nolint:funlen +func runPackages(id clio.Identification, opts *packagesOptions, userInput string) error { + err := validatePackageOutputOptions(&opts.MultiOutput) + if err != nil { + return err + } + + writer, err := opts.SBOMWriter() + if err != nil { + 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, + }, + ) + + if err != nil { + return fmt.Errorf("failed to construct source from user input %q: %w", userInput, err) + } + + defer func() { + if src != nil { + if err := src.Close(); err != nil { + log.Tracef("unable to close source: %+v", err) + } + } + }() + + s, err := generateSBOM(id, src, &opts.Catalog) + if err != nil { + return err + } + + if s == nil { + return fmt.Errorf("no SBOM produced for %q", userInput) + } + + if err := writer.Write(*s); err != nil { + return fmt.Errorf("failed to write SBOM: %w", err) + } + + return nil +} + +func generateSBOM(id clio.Identification, src source.Source, opts *options.Catalog) (*sbom.SBOM, error) { + tasks, err := eventloop.Tasks(opts) + if err != nil { + return nil, err + } + + s := sbom.SBOM{ + Source: src.Describe(), + Descriptor: sbom.Descriptor{ + Name: id.Name, + Version: id.Version, + Configuration: opts, + }, + } + + err = buildRelationships(&s, src, tasks) + + return &s, err +} + +func buildRelationships(s *sbom.SBOM, src source.Source, tasks []eventloop.Task) error { + var errs error + + var relationships []<-chan artifact.Relationship + for _, task := range tasks { + c := make(chan artifact.Relationship) + relationships = append(relationships, c) + go func(task eventloop.Task) { + err := eventloop.RunTask(task, &s.Artifacts, src, c) + if err != nil { + errs = multierror.Append(errs, err) + } + }(task) + } + + s.Relationships = append(s.Relationships, mergeRelationships(relationships...)...) + + return errs +} + +func mergeRelationships(cs ...<-chan artifact.Relationship) (relationships []artifact.Relationship) { + for _, c := range cs { + for n := range c { + relationships = append(relationships, n) + } + } + + return relationships +} + +func validatePackageOutputOptions(cfg *options.MultiOutput) error { + var usesTemplateOutput bool + for _, o := range cfg.Outputs { + if o == template.ID.String() { + usesTemplateOutput = true + break + } + } + + if usesTemplateOutput && cfg.OutputTemplatePath == "" { + return fmt.Errorf(`must specify path to template file when using "template" output format`) + } + + return nil +} diff --git a/cmd/syft/cli/commands/poweruser.go b/cmd/syft/cli/commands/poweruser.go new file mode 100644 index 00000000000..2c9ff185a1f --- /dev/null +++ b/cmd/syft/cli/commands/poweruser.go @@ -0,0 +1,154 @@ +package commands + +import ( + "fmt" + "os" + + "github.com/gookit/color" + "github.com/hashicorp/go-multierror" + "github.com/spf13/cobra" + + "github.com/anchore/clio" + "github.com/anchore/stereoscope/pkg/image" + "github.com/anchore/syft/cmd/syft/cli/eventloop" + "github.com/anchore/syft/cmd/syft/cli/options" + "github.com/anchore/syft/internal" + "github.com/anchore/syft/syft/artifact" + "github.com/anchore/syft/syft/formats/syftjson" + "github.com/anchore/syft/syft/sbom" + "github.com/anchore/syft/syft/source" +) + +const powerUserExample = ` {{.appName}} {{.command}} + DEPRECATED - THIS COMMAND WILL BE REMOVED in v1.0.0 + Only image sources are supported (e.g. docker: , podman: , docker-archive: , oci: , etc.), the directory source (dir:) is not supported, template outputs are not supported. + All behavior is controlled via application configuration and environment variables (see https://github.com/anchore/syft#configuration) +` + +type powerUserOptions struct { + options.Config `yaml:",inline" mapstructure:",squash"` + options.OutputFile `yaml:",inline" mapstructure:",squash"` + options.UpdateCheck `yaml:",inline" mapstructure:",squash"` + options.Catalog `yaml:",inline" mapstructure:",squash"` +} + +func PowerUser(app clio.Application) *cobra.Command { + id := app.ID() + + pkgs := options.DefaultCatalog() + pkgs.Secrets.Cataloger.Enabled = true + pkgs.FileMetadata.Cataloger.Enabled = true + pkgs.FileContents.Cataloger.Enabled = true + pkgs.FileClassification.Cataloger.Enabled = true + opts := &powerUserOptions{ + Catalog: pkgs, + } + + return app.SetupCommand(&cobra.Command{ + Use: "power-user [IMAGE]", + Short: "Run bulk operations on container images", + Example: internal.Tprintf(powerUserExample, map[string]interface{}{ + "appName": id.Name, + "command": "power-user", + }), + Args: validatePackagesArgs, + Hidden: true, + PreRunE: applicationUpdateCheck(id, &opts.UpdateCheck), + RunE: func(cmd *cobra.Command, args []string) error { + return runPowerUser(id, opts, args[0]) + }, + }, opts) +} + +//nolint:funlen +func runPowerUser(id clio.Identification, opts *powerUserOptions, userInput string) error { + writer, err := opts.SBOMWriter(syftjson.Format()) + if err != nil { + return err + } + defer func() { + // inform user at end of run that command will be removed + deprecated := color.Style{color.Red, color.OpBold}.Sprint("DEPRECATED: This command will be removed in v1.0.0") + fmt.Fprintln(os.Stderr, deprecated) + }() + + tasks, err := eventloop.Tasks(&opts.Catalog) + if err != nil { + 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) + } + } + + 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: nil, + BasePath: opts.BasePath, + }, + ) + + if src != nil { + defer src.Close() + } + if err != nil { + return fmt.Errorf("failed to construct source from user input %q: %w", userInput, err) + } + + s := sbom.SBOM{ + Source: src.Describe(), + Descriptor: sbom.Descriptor{ + Name: id.Name, + Version: id.Version, + Configuration: opts, + }, + } + + var errs error + var relationships []<-chan artifact.Relationship + for _, task := range tasks { + c := make(chan artifact.Relationship) + relationships = append(relationships, c) + + go func(task eventloop.Task) { + err := eventloop.RunTask(task, &s.Artifacts, src, c) + errs = multierror.Append(errs, err) + }(task) + } + + if errs != nil { + return errs + } + + s.Relationships = append(s.Relationships, mergeRelationships(relationships...)...) + + if err := writer.Write(s); err != nil { + return fmt.Errorf("failed to write sbom: %w", err) + } + + return nil +} diff --git a/cmd/syft/cli/commands/root.go b/cmd/syft/cli/commands/root.go new file mode 100644 index 00000000000..fb3234b50b9 --- /dev/null +++ b/cmd/syft/cli/commands/root.go @@ -0,0 +1,27 @@ +package commands + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/anchore/clio" +) + +func Root(app clio.Application, packagesCmd *cobra.Command) *cobra.Command { + id := app.ID() + + opts := defaultPackagesOptions() + + return app.SetupRootCommand(&cobra.Command{ + Use: fmt.Sprintf("%s [SOURCE]", app.ID().Name), + Short: packagesCmd.Short, + Long: packagesCmd.Long, + Args: packagesCmd.Args, + Example: packagesCmd.Example, + PreRunE: applicationUpdateCheck(id, &opts.UpdateCheck), + RunE: func(cmd *cobra.Command, args []string) error { + return runPackages(id, opts, args[0]) + }, + }, opts) +} diff --git a/cmd/syft/cli/commands/update.go b/cmd/syft/cli/commands/update.go new file mode 100644 index 00000000000..3ef1812e1c8 --- /dev/null +++ b/cmd/syft/cli/commands/update.go @@ -0,0 +1,120 @@ +package commands + +import ( + "fmt" + "io" + "net/http" + "strings" + + "github.com/spf13/cobra" + "github.com/wagoodman/go-partybus" + + "github.com/anchore/clio" + hashiVersion "github.com/anchore/go-version" + "github.com/anchore/syft/cmd/syft/cli/options" + "github.com/anchore/syft/cmd/syft/internal" + "github.com/anchore/syft/internal/bus" + "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/syft/event" + "github.com/anchore/syft/syft/event/parsers" +) + +var latestAppVersionURL = struct { + host string + path string +}{ + host: "https://toolbox-data.anchore.io", + path: "/syft/releases/latest/VERSION", +} + +func applicationUpdateCheck(id clio.Identification, check *options.UpdateCheck) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + if check.CheckForAppUpdate { + checkForApplicationUpdate(id) + } + return nil + } +} + +func checkForApplicationUpdate(id clio.Identification) { + log.Debugf("checking if a new version of %s is available", id.Name) + isAvailable, newVersion, err := isUpdateAvailable(id.Version) + if err != nil { + // this should never stop the application + log.Errorf(err.Error()) + } + if isAvailable { + log.Infof("new version of %s is available: %s (current version is %s)", id.Name, newVersion, id.Version) + + bus.Publish(partybus.Event{ + Type: event.CLIAppUpdateAvailable, + Value: parsers.UpdateCheck{ + New: newVersion, + Current: id.Version, + }, + }) + } else { + log.Debugf("no new %s update available", id.Name) + } +} + +// isUpdateAvailable indicates if there is a newer application version available, and if so, what the new version is. +func isUpdateAvailable(version string) (bool, string, error) { + if !isProductionBuild(version) { + // don't allow for non-production builds to check for a version. + return false, "", nil + } + + currentVersion, err := hashiVersion.NewVersion(version) + if err != nil { + return false, "", fmt.Errorf("failed to parse current application version: %w", err) + } + + latestVersion, err := fetchLatestApplicationVersion() + if err != nil { + return false, "", err + } + + if latestVersion.GreaterThan(currentVersion) { + return true, latestVersion.String(), nil + } + + return false, "", nil +} + +func isProductionBuild(version string) bool { + if strings.Contains(version, "SNAPSHOT") || strings.Contains(version, internal.NotProvided) { + return false + } + return true +} + +func fetchLatestApplicationVersion() (*hashiVersion.Version, error) { + req, err := http.NewRequest(http.MethodGet, latestAppVersionURL.host+latestAppVersionURL.path, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request for latest version: %w", err) + } + + client := http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch latest version: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d on fetching latest version: %s", resp.StatusCode, resp.Status) + } + + versionBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read latest version: %w", err) + } + + versionStr := strings.TrimSuffix(string(versionBytes), "\n") + if len(versionStr) > 50 { + return nil, fmt.Errorf("version too long: %q", versionStr[:50]) + } + + return hashiVersion.NewVersion(versionStr) +} diff --git a/internal/version/update_test.go b/cmd/syft/cli/commands/update_test.go similarity index 95% rename from internal/version/update_test.go rename to cmd/syft/cli/commands/update_test.go index c7975054032..96cd7de34fc 100644 --- a/internal/version/update_test.go +++ b/cmd/syft/cli/commands/update_test.go @@ -1,4 +1,4 @@ -package version +package commands import ( "net/http" @@ -6,6 +6,7 @@ import ( "testing" hashiVersion "github.com/anchore/go-version" + "github.com/anchore/syft/cmd/syft/internal" ) func TestIsUpdateAvailable(t *testing.T) { @@ -74,7 +75,7 @@ func TestIsUpdateAvailable(t *testing.T) { }, { name: "NoBuildVersion", - buildVersion: valueNotProvided, + buildVersion: internal.NotProvided, latestVersion: "1.0.0", code: 200, isAvailable: false, @@ -105,7 +106,7 @@ func TestIsUpdateAvailable(t *testing.T) { t.Run(test.name, func(t *testing.T) { // setup mocks // local... - version = test.buildVersion + version := test.buildVersion // remote... handler := http.NewServeMux() handler.HandleFunc(latestAppVersionURL.path, func(w http.ResponseWriter, r *http.Request) { @@ -116,7 +117,7 @@ func TestIsUpdateAvailable(t *testing.T) { latestAppVersionURL.host = mockSrv.URL defer mockSrv.Close() - isAvailable, newVersion, err := IsUpdateAvailable() + isAvailable, newVersion, err := isUpdateAvailable(version) if err != nil && !test.err { t.Fatalf("got error but expected none: %+v", err) } else if err == nil && test.err { diff --git a/cmd/syft/cli/convert.go b/cmd/syft/cli/convert.go deleted file mode 100644 index 16c24cac52a..00000000000 --- a/cmd/syft/cli/convert.go +++ /dev/null @@ -1,58 +0,0 @@ -package cli - -import ( - "fmt" - "log" - - "github.com/spf13/cobra" - "github.com/spf13/viper" - - "github.com/anchore/syft/cmd/syft/cli/convert" - "github.com/anchore/syft/cmd/syft/cli/options" - "github.com/anchore/syft/internal" - "github.com/anchore/syft/internal/config" -) - -const ( - convertExample = ` {{.appName}} {{.command}} img.syft.json -o spdx-json convert a syft SBOM to spdx-json, output goes to stdout - {{.appName}} {{.command}} img.syft.json -o cyclonedx-json=img.cdx.json convert a syft SBOM to CycloneDX, output is written to the file "img.cdx.json"" - {{.appName}} {{.command}} - -o spdx-json convert an SBOM from STDIN to spdx-json -` -) - -//nolint:dupl -func Convert(v *viper.Viper, app *config.Application, ro *options.RootOptions, po *options.PackagesOptions) *cobra.Command { - cmd := &cobra.Command{ - Use: "convert [SOURCE-SBOM] -o [FORMAT]", - Short: "Convert between SBOM formats", - Long: "[Experimental] Convert SBOM files to, and from, SPDX, CycloneDX and Syft's format. For more info about data loss between formats see https://github.com/anchore/syft#format-conversion-experimental", - Example: internal.Tprintf(convertExample, map[string]interface{}{ - "appName": internal.ApplicationName, - "command": "convert", - }), - Args: func(cmd *cobra.Command, args []string) error { - if err := app.LoadAllValues(v, ro.Config); err != nil { - return fmt.Errorf("invalid application config: %w", err) - } - newLogWrapper(app) - logApplicationConfig(app) - return validateArgs(cmd, args) - }, - SilenceUsage: true, - SilenceErrors: true, - RunE: func(cmd *cobra.Command, args []string) error { - if app.CheckForAppUpdate { - checkForApplicationUpdate() - // TODO: this is broke, the bus isn't available yet - } - return convert.Run(cmd.Context(), app, args) - }, - } - - err := po.AddFlags(cmd, v) - if err != nil { - log.Fatal(err) - } - - return cmd -} diff --git a/cmd/syft/cli/convert/convert.go b/cmd/syft/cli/convert/convert.go deleted file mode 100644 index 2f0dbcedbb8..00000000000 --- a/cmd/syft/cli/convert/convert.go +++ /dev/null @@ -1,85 +0,0 @@ -package convert - -import ( - "context" - "fmt" - "io" - "os" - - "github.com/wagoodman/go-partybus" - - "github.com/anchore/stereoscope" - "github.com/anchore/syft/cmd/syft/cli/eventloop" - "github.com/anchore/syft/cmd/syft/cli/options" - "github.com/anchore/syft/cmd/syft/internal/ui" - "github.com/anchore/syft/internal/bus" - "github.com/anchore/syft/internal/config" - "github.com/anchore/syft/internal/log" - "github.com/anchore/syft/syft" - "github.com/anchore/syft/syft/formats" - "github.com/anchore/syft/syft/sbom" -) - -func Run(_ context.Context, app *config.Application, args []string) error { - log.Warn("convert is an experimental feature, run `syft convert -h` for help") - - writer, err := options.MakeSBOMWriter(app.Outputs, app.File, app.OutputTemplatePath) - if err != nil { - return err - } - - // could be an image or a directory, with or without a scheme - userInput := args[0] - - var reader io.ReadCloser - - if userInput == "-" { - reader = os.Stdin - } else { - f, err := os.Open(userInput) - if err != nil { - return fmt.Errorf("failed to open SBOM file: %w", err) - } - defer func() { - _ = f.Close() - }() - reader = f - } - - eventBus := partybus.NewBus() - stereoscope.SetBus(eventBus) - syft.SetBus(eventBus) - subscription := eventBus.Subscribe() - - return eventloop.EventLoop( - execWorker(reader, writer), - eventloop.SetupSignals(), - subscription, - stereoscope.Cleanup, - ui.Select(options.IsVerbose(app), app.Quiet)..., - ) -} - -func execWorker(reader io.Reader, writer sbom.Writer) <-chan error { - errs := make(chan error) - go func() { - defer close(errs) - defer bus.Exit() - - s, _, err := formats.Decode(reader) - if err != nil { - errs <- fmt.Errorf("failed to decode SBOM: %w", err) - return - } - - if s == nil { - errs <- fmt.Errorf("no SBOM produced") - return - } - - if err := writer.Write(*s); err != nil { - errs <- fmt.Errorf("failed to write SBOM: %w", err) - } - }() - return errs -} diff --git a/cmd/syft/cli/eventloop/event_loop.go b/cmd/syft/cli/eventloop/event_loop.go deleted file mode 100644 index e7d008e71f5..00000000000 --- a/cmd/syft/cli/eventloop/event_loop.go +++ /dev/null @@ -1,98 +0,0 @@ -package eventloop - -import ( - "errors" - "fmt" - "os" - - "github.com/hashicorp/go-multierror" - "github.com/wagoodman/go-partybus" - - "github.com/anchore/clio" - "github.com/anchore/syft/internal/log" -) - -// EventLoop listens to worker errors (from execution path), worker events (from a partybus subscription), and -// signal interrupts. Is responsible for handling each event relative to a given UI an to coordinate eventing until -// an eventual graceful exit. -func EventLoop(workerErrs <-chan error, signals <-chan os.Signal, subscription *partybus.Subscription, cleanupFn func(), uxs ...clio.UI) error { - defer cleanupFn() - events := subscription.Events() - var err error - var ux clio.UI - - if ux, err = setupUI(subscription, uxs...); err != nil { - return err - } - - var retErr error - var forceTeardown bool - - for { - if workerErrs == nil && events == nil { - break - } - select { - case err, isOpen := <-workerErrs: - if !isOpen { - workerErrs = nil - continue - } - if err != nil { - // capture the error from the worker and unsubscribe to complete a graceful shutdown - retErr = multierror.Append(retErr, err) - _ = subscription.Unsubscribe() - // the worker has exited, we may have been mid-handling events for the UI which should now be - // ignored, in which case forcing a teardown of the UI irregardless of the state is required. - forceTeardown = true - } - case e, isOpen := <-events: - if !isOpen { - events = nil - continue - } - - if err := ux.Handle(e); err != nil { - if errors.Is(err, partybus.ErrUnsubscribe) { - events = nil - } else { - retErr = multierror.Append(retErr, err) - // TODO: should we unsubscribe? should we try to halt execution? or continue? - } - } - case <-signals: - // ignore further results from any event source and exit ASAP, but ensure that all cache is cleaned up. - // we ignore further errors since cleaning up the tmp directories will affect running catalogers that are - // reading/writing from/to their nested temp dirs. This is acceptable since we are bailing without result. - - // TODO: potential future improvement would be to pass context into workers with a cancel function that is - // to the event loop. In this way we can have a more controlled shutdown even at the most nested levels - // of processing. - events = nil - workerErrs = nil - forceTeardown = true - } - } - - if err := ux.Teardown(forceTeardown); err != nil { - retErr = multierror.Append(retErr, err) - } - - return retErr -} - -// setupUI takes one or more UIs that responds to events and takes a event bus unsubscribe function for use -// during teardown. With the given UIs, the first UI which the ui.Setup() function does not return an error -// will be utilized in execution. Providing a set of UIs allows for the caller to provide graceful fallbacks -// when there are environmental problem (e.g. unable to setup a TUI with the current TTY). -func setupUI(subscription *partybus.Subscription, uis ...clio.UI) (clio.UI, error) { - for _, ux := range uis { - if err := ux.Setup(subscription); err != nil { - log.Warnf("unable to setup given UI, falling back to alternative UI: %+v", err) - continue - } - - return ux, nil - } - return nil, fmt.Errorf("unable to setup any UI") -} diff --git a/cmd/syft/cli/eventloop/event_loop_test.go b/cmd/syft/cli/eventloop/event_loop_test.go deleted file mode 100644 index 495af90b8e7..00000000000 --- a/cmd/syft/cli/eventloop/event_loop_test.go +++ /dev/null @@ -1,459 +0,0 @@ -package eventloop - -import ( - "fmt" - "os" - "syscall" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/wagoodman/go-partybus" - - "github.com/anchore/clio" - "github.com/anchore/syft/syft/event" -) - -var _ clio.UI = (*uiMock)(nil) - -type uiMock struct { - t *testing.T - finalEvent partybus.Event - subscription partybus.Unsubscribable - mock.Mock -} - -func (u *uiMock) Setup(unsubscribe partybus.Unsubscribable) error { - u.t.Helper() - u.t.Logf("UI Setup called") - u.subscription = unsubscribe - return u.Called(unsubscribe.Unsubscribe).Error(0) -} - -func (u *uiMock) Handle(event partybus.Event) error { - u.t.Helper() - u.t.Logf("UI Handle called: %+v", event.Type) - if event == u.finalEvent { - assert.NoError(u.t, u.subscription.Unsubscribe()) - } - return u.Called(event).Error(0) -} - -func (u *uiMock) Teardown(_ bool) error { - u.t.Helper() - u.t.Logf("UI Teardown called") - return u.Called().Error(0) -} - -func Test_EventLoop_gracefulExit(t *testing.T) { - test := func(t *testing.T) { - - testBus := partybus.NewBus() - subscription := testBus.Subscribe() - t.Cleanup(testBus.Close) - - finalEvent := partybus.Event{ - Type: event.CLIExit, - } - - worker := func() <-chan error { - ret := make(chan error) - go func() { - t.Log("worker running") - // send an empty item (which is ignored) ensuring we've entered the select statement, - // then close (a partial shutdown). - ret <- nil - t.Log("worker sent nothing") - close(ret) - t.Log("worker closed") - // do the other half of the shutdown - testBus.Publish(finalEvent) - t.Log("worker published final event") - }() - return ret - } - - signaler := func() <-chan os.Signal { - return nil - } - - ux := &uiMock{ - t: t, - finalEvent: finalEvent, - } - - // ensure the mock sees at least the final event - ux.On("Handle", finalEvent).Return(nil) - // ensure the mock sees basic setup/teardown events - ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil) - ux.On("Teardown").Return(nil) - - var cleanupCalled bool - cleanupFn := func() { - t.Log("cleanup called") - cleanupCalled = true - } - - assert.NoError(t, - EventLoop( - worker(), - signaler(), - subscription, - cleanupFn, - ux, - ), - ) - - assert.True(t, cleanupCalled, "cleanup function not called") - ux.AssertExpectations(t) - } - - // if there is a bug, then there is a risk of the event loop never returning - testWithTimeout(t, 5*time.Second, test) -} - -func Test_EventLoop_workerError(t *testing.T) { - test := func(t *testing.T) { - - testBus := partybus.NewBus() - subscription := testBus.Subscribe() - t.Cleanup(testBus.Close) - - workerErr := fmt.Errorf("worker error") - - worker := func() <-chan error { - ret := make(chan error) - go func() { - t.Log("worker running") - // send an empty item (which is ignored) ensuring we've entered the select statement, - // then close (a partial shutdown). - ret <- nil - t.Log("worker sent nothing") - ret <- workerErr - t.Log("worker sent error") - close(ret) - t.Log("worker closed") - // note: NO final event is fired - }() - return ret - } - - signaler := func() <-chan os.Signal { - return nil - } - - ux := &uiMock{ - t: t, - } - - // ensure the mock sees basic setup/teardown events - ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil) - ux.On("Teardown").Return(nil) - - var cleanupCalled bool - cleanupFn := func() { - t.Log("cleanup called") - cleanupCalled = true - } - - // ensure we see an error returned - assert.ErrorIs(t, - EventLoop( - worker(), - signaler(), - subscription, - cleanupFn, - ux, - ), - workerErr, - "should have seen a worker error, but did not", - ) - - assert.True(t, cleanupCalled, "cleanup function not called") - ux.AssertExpectations(t) - } - - // if there is a bug, then there is a risk of the event loop never returning - testWithTimeout(t, 5*time.Second, test) -} - -func Test_EventLoop_unsubscribeError(t *testing.T) { - test := func(t *testing.T) { - - testBus := partybus.NewBus() - subscription := testBus.Subscribe() - t.Cleanup(testBus.Close) - - finalEvent := partybus.Event{ - Type: event.CLIExit, - } - - worker := func() <-chan error { - ret := make(chan error) - go func() { - t.Log("worker running") - // send an empty item (which is ignored) ensuring we've entered the select statement, - // then close (a partial shutdown). - ret <- nil - t.Log("worker sent nothing") - close(ret) - t.Log("worker closed") - // do the other half of the shutdown - testBus.Publish(finalEvent) - t.Log("worker published final event") - }() - return ret - } - - signaler := func() <-chan os.Signal { - return nil - } - - ux := &uiMock{ - t: t, - finalEvent: finalEvent, - } - - // ensure the mock sees at least the final event... note the unsubscribe error here - ux.On("Handle", finalEvent).Return(partybus.ErrUnsubscribe) - // ensure the mock sees basic setup/teardown events - ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil) - ux.On("Teardown").Return(nil) - - var cleanupCalled bool - cleanupFn := func() { - t.Log("cleanup called") - cleanupCalled = true - } - - // unsubscribe errors should be handled and ignored, not propagated. We are additionally asserting that - // this case is handled as a controlled shutdown (this test should not timeout) - assert.NoError(t, - EventLoop( - worker(), - signaler(), - subscription, - cleanupFn, - ux, - ), - ) - - assert.True(t, cleanupCalled, "cleanup function not called") - ux.AssertExpectations(t) - } - - // if there is a bug, then there is a risk of the event loop never returning - testWithTimeout(t, 5*time.Second, test) -} - -func Test_EventLoop_handlerError(t *testing.T) { - test := func(t *testing.T) { - - testBus := partybus.NewBus() - subscription := testBus.Subscribe() - t.Cleanup(testBus.Close) - - finalEvent := partybus.Event{ - Type: event.CLIExit, - Error: fmt.Errorf("an exit error occured"), - } - - worker := func() <-chan error { - ret := make(chan error) - go func() { - t.Log("worker running") - // send an empty item (which is ignored) ensuring we've entered the select statement, - // then close (a partial shutdown). - ret <- nil - t.Log("worker sent nothing") - close(ret) - t.Log("worker closed") - // do the other half of the shutdown - testBus.Publish(finalEvent) - t.Log("worker published final event") - }() - return ret - } - - signaler := func() <-chan os.Signal { - return nil - } - - ux := &uiMock{ - t: t, - finalEvent: finalEvent, - } - - // ensure the mock sees at least the final event... note the event error is propagated - ux.On("Handle", finalEvent).Return(finalEvent.Error) - // ensure the mock sees basic setup/teardown events - ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil) - ux.On("Teardown").Return(nil) - - var cleanupCalled bool - cleanupFn := func() { - t.Log("cleanup called") - cleanupCalled = true - } - - // handle errors SHOULD propagate the event loop. We are additionally asserting that this case is - // handled as a controlled shutdown (this test should not timeout) - assert.ErrorIs(t, - EventLoop( - worker(), - signaler(), - subscription, - cleanupFn, - ux, - ), - finalEvent.Error, - "should have seen a event error, but did not", - ) - - assert.True(t, cleanupCalled, "cleanup function not called") - ux.AssertExpectations(t) - } - - // if there is a bug, then there is a risk of the event loop never returning - testWithTimeout(t, 5*time.Second, test) -} - -func Test_EventLoop_signalsStopExecution(t *testing.T) { - test := func(t *testing.T) { - - testBus := partybus.NewBus() - subscription := testBus.Subscribe() - t.Cleanup(testBus.Close) - - worker := func() <-chan error { - // the worker will never return work and the event loop will always be waiting... - return make(chan error) - } - - signaler := func() <-chan os.Signal { - ret := make(chan os.Signal) - go func() { - ret <- syscall.SIGINT - // note: we do NOT close the channel to ensure the event loop does not depend on that behavior to exit - }() - return ret - } - - ux := &uiMock{ - t: t, - } - - // ensure the mock sees basic setup/teardown events - ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil) - ux.On("Teardown").Return(nil) - - var cleanupCalled bool - cleanupFn := func() { - t.Log("cleanup called") - cleanupCalled = true - } - - assert.NoError(t, - EventLoop( - worker(), - signaler(), - subscription, - cleanupFn, - ux, - ), - ) - - assert.True(t, cleanupCalled, "cleanup function not called") - ux.AssertExpectations(t) - } - - // if there is a bug, then there is a risk of the event loop never returning - testWithTimeout(t, 5*time.Second, test) -} - -func Test_EventLoop_uiTeardownError(t *testing.T) { - test := func(t *testing.T) { - - testBus := partybus.NewBus() - subscription := testBus.Subscribe() - t.Cleanup(testBus.Close) - - finalEvent := partybus.Event{ - Type: event.CLIExit, - } - - worker := func() <-chan error { - ret := make(chan error) - go func() { - t.Log("worker running") - // send an empty item (which is ignored) ensuring we've entered the select statement, - // then close (a partial shutdown). - ret <- nil - t.Log("worker sent nothing") - close(ret) - t.Log("worker closed") - // do the other half of the shutdown - testBus.Publish(finalEvent) - t.Log("worker published final event") - }() - return ret - } - - signaler := func() <-chan os.Signal { - return nil - } - - ux := &uiMock{ - t: t, - finalEvent: finalEvent, - } - - teardownError := fmt.Errorf("sorry, dave, the UI doesn't want to be torn down") - - // ensure the mock sees at least the final event... note the event error is propagated - ux.On("Handle", finalEvent).Return(nil) - // ensure the mock sees basic setup/teardown events - ux.On("Setup", mock.AnythingOfType("func() error")).Return(nil) - ux.On("Teardown").Return(teardownError) - - var cleanupCalled bool - cleanupFn := func() { - t.Log("cleanup called") - cleanupCalled = true - } - - // ensure we see an error returned - assert.ErrorIs(t, - EventLoop( - worker(), - signaler(), - subscription, - cleanupFn, - ux, - ), - teardownError, - "should have seen a UI teardown error, but did not", - ) - - assert.True(t, cleanupCalled, "cleanup function not called") - ux.AssertExpectations(t) - } - - // if there is a bug, then there is a risk of the event loop never returning - testWithTimeout(t, 5*time.Second, test) -} - -func testWithTimeout(t *testing.T, timeout time.Duration, test func(*testing.T)) { - done := make(chan bool) - go func() { - test(t) - done <- true - }() - - select { - case <-time.After(timeout): - t.Fatal("test timed out") - case <-done: - } -} diff --git a/cmd/syft/cli/eventloop/signals.go b/cmd/syft/cli/eventloop/signals.go deleted file mode 100644 index 72a97711e8d..00000000000 --- a/cmd/syft/cli/eventloop/signals.go +++ /dev/null @@ -1,20 +0,0 @@ -package eventloop - -import ( - "os" - "os/signal" - "syscall" -) - -func SetupSignals() <-chan os.Signal { - c := make(chan os.Signal, 1) // Note: A buffered channel is recommended for this; see https://golang.org/pkg/os/signal/#Notify - - interruptions := []os.Signal{ - syscall.SIGINT, - syscall.SIGTERM, - } - - signal.Notify(c, interruptions...) - - return c -} diff --git a/cmd/syft/cli/eventloop/tasks.go b/cmd/syft/cli/eventloop/tasks.go index b6121d0daa1..feabca57392 100644 --- a/cmd/syft/cli/eventloop/tasks.go +++ b/cmd/syft/cli/eventloop/tasks.go @@ -1,7 +1,7 @@ package eventloop import ( - "github.com/anchore/syft/internal/config" + "github.com/anchore/syft/cmd/syft/cli/options" "github.com/anchore/syft/internal/file" "github.com/anchore/syft/syft" "github.com/anchore/syft/syft/artifact" @@ -15,10 +15,10 @@ import ( type Task func(*sbom.Artifacts, source.Source) ([]artifact.Relationship, error) -func Tasks(app *config.Application) ([]Task, error) { +func Tasks(opts *options.Catalog) ([]Task, error) { var tasks []Task - generators := []func(app *config.Application) (Task, error){ + generators := []func(opts *options.Catalog) (Task, error){ generateCatalogPackagesTask, generateCatalogFileMetadataTask, generateCatalogFileDigestsTask, @@ -27,7 +27,7 @@ func Tasks(app *config.Application) ([]Task, error) { } for _, generator := range generators { - task, err := generator(app) + task, err := generator(opts) if err != nil { return nil, err } @@ -40,13 +40,13 @@ func Tasks(app *config.Application) ([]Task, error) { return tasks, nil } -func generateCatalogPackagesTask(app *config.Application) (Task, error) { - if !app.Package.Cataloger.Enabled { +func generateCatalogPackagesTask(opts *options.Catalog) (Task, error) { + if !opts.Package.Cataloger.Enabled { return nil, nil } task := func(results *sbom.Artifacts, src source.Source) ([]artifact.Relationship, error) { - packageCatalog, relationships, theDistro, err := syft.CatalogPackages(src, app.ToCatalogerConfig()) + packageCatalog, relationships, theDistro, err := syft.CatalogPackages(src, opts.ToCatalogerConfig()) results.Packages = packageCatalog results.LinuxDistribution = theDistro @@ -57,15 +57,15 @@ func generateCatalogPackagesTask(app *config.Application) (Task, error) { return task, nil } -func generateCatalogFileMetadataTask(app *config.Application) (Task, error) { - if !app.FileMetadata.Cataloger.Enabled { +func generateCatalogFileMetadataTask(opts *options.Catalog) (Task, error) { + if !opts.FileMetadata.Cataloger.Enabled { return nil, nil } metadataCataloger := filemetadata.NewCataloger() task := func(results *sbom.Artifacts, src source.Source) ([]artifact.Relationship, error) { - resolver, err := src.FileResolver(app.FileMetadata.Cataloger.ScopeOpt) + resolver, err := src.FileResolver(opts.FileMetadata.Cataloger.GetScope()) if err != nil { return nil, err } @@ -81,12 +81,12 @@ func generateCatalogFileMetadataTask(app *config.Application) (Task, error) { return task, nil } -func generateCatalogFileDigestsTask(app *config.Application) (Task, error) { - if !app.FileMetadata.Cataloger.Enabled { +func generateCatalogFileDigestsTask(opts *options.Catalog) (Task, error) { + if !opts.FileMetadata.Cataloger.Enabled { return nil, nil } - hashes, err := file.Hashers(app.FileMetadata.Digests...) + hashes, err := file.Hashers(opts.FileMetadata.Digests...) if err != nil { return nil, err } @@ -94,7 +94,7 @@ func generateCatalogFileDigestsTask(app *config.Application) (Task, error) { digestsCataloger := filedigest.NewCataloger(hashes) task := func(results *sbom.Artifacts, src source.Source) ([]artifact.Relationship, error) { - resolver, err := src.FileResolver(app.FileMetadata.Cataloger.ScopeOpt) + resolver, err := src.FileResolver(opts.FileMetadata.Cataloger.GetScope()) if err != nil { return nil, err } @@ -110,23 +110,23 @@ func generateCatalogFileDigestsTask(app *config.Application) (Task, error) { return task, nil } -func generateCatalogSecretsTask(app *config.Application) (Task, error) { - if !app.Secrets.Cataloger.Enabled { +func generateCatalogSecretsTask(opts *options.Catalog) (Task, error) { + if !opts.Secrets.Cataloger.Enabled { return nil, nil } - patterns, err := secrets.GenerateSearchPatterns(secrets.DefaultSecretsPatterns, app.Secrets.AdditionalPatterns, app.Secrets.ExcludePatternNames) + patterns, err := secrets.GenerateSearchPatterns(secrets.DefaultSecretsPatterns, opts.Secrets.AdditionalPatterns, opts.Secrets.ExcludePatternNames) if err != nil { return nil, err } - secretsCataloger, err := secrets.NewCataloger(patterns, app.Secrets.RevealValues, app.Secrets.SkipFilesAboveSize) //nolint:staticcheck + secretsCataloger, err := secrets.NewCataloger(patterns, opts.Secrets.RevealValues, opts.Secrets.SkipFilesAboveSize) //nolint:staticcheck if err != nil { return nil, err } task := func(results *sbom.Artifacts, src source.Source) ([]artifact.Relationship, error) { - resolver, err := src.FileResolver(app.Secrets.Cataloger.ScopeOpt) + resolver, err := src.FileResolver(opts.Secrets.Cataloger.GetScope()) if err != nil { return nil, err } @@ -142,18 +142,18 @@ func generateCatalogSecretsTask(app *config.Application) (Task, error) { return task, nil } -func generateCatalogContentsTask(app *config.Application) (Task, error) { - if !app.FileContents.Cataloger.Enabled { +func generateCatalogContentsTask(opts *options.Catalog) (Task, error) { + if !opts.FileContents.Cataloger.Enabled { return nil, nil } - contentsCataloger, err := filecontent.NewCataloger(app.FileContents.Globs, app.FileContents.SkipFilesAboveSize) //nolint:staticcheck + contentsCataloger, err := filecontent.NewCataloger(opts.FileContents.Globs, opts.FileContents.SkipFilesAboveSize) //nolint:staticcheck if err != nil { return nil, err } task := func(results *sbom.Artifacts, src source.Source) ([]artifact.Relationship, error) { - resolver, err := src.FileResolver(app.FileContents.Cataloger.ScopeOpt) + resolver, err := src.FileResolver(opts.FileContents.Cataloger.GetScope()) if err != nil { return nil, err } @@ -169,16 +169,17 @@ func generateCatalogContentsTask(app *config.Application) (Task, error) { return task, nil } -func RunTask(t Task, a *sbom.Artifacts, src source.Source, c chan<- artifact.Relationship, errs chan<- error) { +func RunTask(t Task, a *sbom.Artifacts, src source.Source, c chan<- artifact.Relationship) error { defer close(c) relationships, err := t(a, src) if err != nil { - errs <- err - return + return err } for _, relationship := range relationships { c <- relationship } + + return nil } diff --git a/cmd/syft/cli/options/attest.go b/cmd/syft/cli/options/attest.go index 60d72191aa6..8c0420386c7 100644 --- a/cmd/syft/cli/options/attest.go +++ b/cmd/syft/cli/options/attest.go @@ -1,26 +1,17 @@ package options import ( - "github.com/spf13/cobra" - "github.com/spf13/pflag" - "github.com/spf13/viper" + "github.com/anchore/clio" ) -type AttestOptions struct { - Key string +type Attest struct { + // IMPORTANT: do not show the attestation key/password in any YAML/JSON output (sensitive information) + Key secret `yaml:"key" json:"key" mapstructure:"key"` + Password secret `yaml:"password" json:"password" mapstructure:"password"` } -var _ Interface = (*AttestOptions)(nil) +var _ clio.FlagAdder = (*Attest)(nil) -func (o AttestOptions) AddFlags(cmd *cobra.Command, v *viper.Viper) error { - cmd.Flags().StringVarP(&o.Key, "key", "k", "", "the key to use for the attestation") - return bindAttestConfigOptions(cmd.Flags(), v) -} - -//nolint:revive -func bindAttestConfigOptions(flags *pflag.FlagSet, v *viper.Viper) error { - if err := v.BindPFlag("attest.key", flags.Lookup("key")); err != nil { - return err - } - return nil +func (o Attest) AddFlags(flags clio.FlagSet) { + flags.StringVarP((*string)(&o.Key), "key", "k", "the key to use for the attestation") } diff --git a/cmd/syft/cli/options/catalog.go b/cmd/syft/cli/options/catalog.go new file mode 100644 index 00000000000..50f4142d5ba --- /dev/null +++ b/cmd/syft/cli/options/catalog.go @@ -0,0 +1,168 @@ +package options + +import ( + "fmt" + "sort" + "strings" + + "github.com/iancoleman/strcase" + "github.com/mitchellh/go-homedir" + + "github.com/anchore/clio" + "github.com/anchore/fangs" + "github.com/anchore/syft/internal" + "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/syft/pkg/cataloger" + golangCataloger "github.com/anchore/syft/syft/pkg/cataloger/golang" + "github.com/anchore/syft/syft/pkg/cataloger/kernel" + pythonCataloger "github.com/anchore/syft/syft/pkg/cataloger/python" + "github.com/anchore/syft/syft/source" +) + +type Catalog struct { + Catalogers []string `yaml:"catalogers" json:"catalogers" mapstructure:"catalogers"` + Package pkg `yaml:"package" json:"package" mapstructure:"package"` + Golang golang `yaml:"golang" json:"golang" mapstructure:"golang"` + LinuxKernel linuxKernel `yaml:"linux-kernel" json:"linux-kernel" mapstructure:"linux-kernel"` + Python python `yaml:"python" json:"python" mapstructure:"python"` + FileMetadata fileMetadata `yaml:"file-metadata" json:"file-metadata" mapstructure:"file-metadata"` + FileClassification fileClassification `yaml:"file-classification" json:"file-classification" mapstructure:"file-classification"` + FileContents fileContents `yaml:"file-contents" json:"file-contents" mapstructure:"file-contents"` + Secrets secrets `yaml:"secrets" json:"secrets" mapstructure:"secrets"` + Registry registry `yaml:"registry" json:"registry" mapstructure:"registry"` + Exclusions []string `yaml:"exclude" json:"exclude" mapstructure:"exclude"` + Platform string `yaml:"platform" json:"platform" mapstructure:"platform"` + Name string `yaml:"name" json:"name" mapstructure:"name"` + Source sourceCfg `yaml:"source" json:"source" mapstructure:"source"` + Parallelism int `yaml:"parallelism" json:"parallelism" mapstructure:"parallelism"` // the number of catalog workers to run in parallel + DefaultImagePullSource string `yaml:"default-image-pull-source" json:"default-image-pull-source" mapstructure:"default-image-pull-source"` // specify default image pull source + BasePath string `yaml:"base-path" json:"base-path" mapstructure:"base-path"` // specify base path for all file paths + ExcludeBinaryOverlapByOwnership bool `yaml:"exclude-binary-overlap-by-ownership" json:"exclude-binary-overlap-by-ownership" mapstructure:"exclude-binary-overlap-by-ownership"` // exclude synthetic binary packages owned by os package files +} + +var _ interface { + clio.FlagAdder + clio.PostLoader +} = (*Catalog)(nil) + +func DefaultCatalog() Catalog { + return Catalog{ + Package: defaultPkg(), + LinuxKernel: defaultLinuxKernel(), + FileMetadata: defaultFileMetadata(), + FileClassification: defaultFileClassification(), + FileContents: defaultFileContents(), + Secrets: defaultSecrets(), + Source: defaultSourceCfg(), + Parallelism: 1, + ExcludeBinaryOverlapByOwnership: true, + } +} + +func (cfg *Catalog) AddFlags(flags clio.FlagSet) { + var validScopeValues []string + for _, scope := range source.AllScopes { + validScopeValues = append(validScopeValues, strcase.ToDelimited(string(scope), '-')) + } + flags.StringVarP(&cfg.Package.Cataloger.Scope, "scope", "s", + fmt.Sprintf("selection of layers to catalog, options=%v", validScopeValues)) + + flags.StringVarP(&cfg.Platform, "platform", "", + "an optional platform specifier for container image sources (e.g. 'linux/arm64', 'linux/arm64/v8', 'arm64', 'linux')") + + flags.StringArrayVarP(&cfg.Exclusions, "exclude", "", + "exclude paths from being scanned using a glob expression") + + flags.StringArrayVarP(&cfg.Catalogers, "catalogers", "", + "enable one or more package catalogers") + + flags.StringVarP(&cfg.Source.Name, "name", "", + "set the name of the target being analyzed") + + if pfp, ok := flags.(fangs.PFlagSetProvider); ok { + flagSet := pfp.PFlagSet() + flagSet.Lookup("name").Deprecated = "use: source-name" + } + + flags.StringVarP(&cfg.Source.Name, "source-name", "", + "set the name of the target being analyzed") + + flags.StringVarP(&cfg.Source.Version, "source-version", "", + "set the name of the target being analyzed") + + flags.StringVarP(&cfg.BasePath, "base-path", "", + "base directory for scanning, no links will be followed above this directory, and all paths will be reported relative to this directory") +} + +func (cfg *Catalog) PostLoad() error { + // parse options on this struct + var catalogers []string + for _, c := range cfg.Catalogers { + for _, f := range strings.Split(c, ",") { + catalogers = append(catalogers, strings.TrimSpace(f)) + } + } + sort.Strings(catalogers) + cfg.Catalogers = catalogers + + if err := checkDefaultSourceValues(cfg.DefaultImagePullSource); err != nil { + return err + } + + if cfg.Name != "" { + log.Warnf("name parameter is deprecated. please use: source-name. name will be removed in a future version") + if cfg.Source.Name == "" { + cfg.Source.Name = cfg.Name + } + } + + return nil +} + +func (cfg Catalog) ToCatalogerConfig() cataloger.Config { + return cataloger.Config{ + Search: cataloger.SearchConfig{ + IncludeIndexedArchives: cfg.Package.SearchIndexedArchives, + IncludeUnindexedArchives: cfg.Package.SearchUnindexedArchives, + Scope: cfg.Package.Cataloger.GetScope(), + }, + Catalogers: cfg.Catalogers, + Parallelism: cfg.Parallelism, + Golang: golangCataloger.NewGoCatalogerOpts(). + WithSearchLocalModCacheLicenses(cfg.Golang.SearchLocalModCacheLicenses). + WithLocalModCacheDir(cfg.Golang.LocalModCacheDir). + WithSearchRemoteLicenses(cfg.Golang.SearchRemoteLicenses). + WithProxy(cfg.Golang.Proxy). + WithNoProxy(cfg.Golang.NoProxy), + LinuxKernel: kernel.LinuxCatalogerConfig{ + CatalogModules: cfg.LinuxKernel.CatalogModules, + }, + Python: pythonCataloger.CatalogerConfig{ + GuessUnpinnedRequirements: cfg.Python.GuessUnpinnedRequirements, + }, + ExcludeBinaryOverlapByOwnership: cfg.ExcludeBinaryOverlapByOwnership, + } +} + +var validDefaultSourceValues = []string{"registry", "docker", "podman", ""} + +func checkDefaultSourceValues(source string) error { + validValues := internal.NewStringSet(validDefaultSourceValues...) + if !validValues.Contains(source) { + validValuesString := strings.Join(validDefaultSourceValues, ", ") + return fmt.Errorf("%s is not a valid default source; please use one of the following: %s''", source, validValuesString) + } + + return nil +} + +func expandFilePath(file string) (string, error) { + if file != "" { + expandedPath, err := homedir.Expand(file) + if err != nil { + return "", fmt.Errorf("unable to expand file path=%q: %w", file, err) + } + file = expandedPath + } + return file, nil +} diff --git a/cmd/syft/cli/options/config.go b/cmd/syft/cli/options/config.go new file mode 100644 index 00000000000..85aeb69ddd3 --- /dev/null +++ b/cmd/syft/cli/options/config.go @@ -0,0 +1,6 @@ +package options + +// Config holds a reference to the specific config file that was used to load application configuration +type Config struct { + ConfigFile string `yaml:"config" json:"config" mapstructure:"config"` +} diff --git a/cmd/syft/cli/options/file_classification.go b/cmd/syft/cli/options/file_classification.go new file mode 100644 index 00000000000..9f1abcdfb9f --- /dev/null +++ b/cmd/syft/cli/options/file_classification.go @@ -0,0 +1,17 @@ +package options + +import ( + "github.com/anchore/syft/syft/source" +) + +type fileClassification struct { + Cataloger scope `yaml:"cataloger" json:"cataloger" mapstructure:"cataloger"` +} + +func defaultFileClassification() fileClassification { + return fileClassification{ + Cataloger: scope{ + Scope: source.SquashedScope.String(), + }, + } +} diff --git a/cmd/syft/cli/options/file_contents.go b/cmd/syft/cli/options/file_contents.go new file mode 100644 index 00000000000..6dba465f5da --- /dev/null +++ b/cmd/syft/cli/options/file_contents.go @@ -0,0 +1,21 @@ +package options + +import ( + "github.com/anchore/syft/internal/file" + "github.com/anchore/syft/syft/source" +) + +type fileContents struct { + Cataloger scope `yaml:"cataloger" json:"cataloger" mapstructure:"cataloger"` + SkipFilesAboveSize int64 `yaml:"skip-files-above-size" json:"skip-files-above-size" mapstructure:"skip-files-above-size"` + Globs []string `yaml:"globs" json:"globs" mapstructure:"globs"` +} + +func defaultFileContents() fileContents { + return fileContents{ + Cataloger: scope{ + Scope: source.SquashedScope.String(), + }, + SkipFilesAboveSize: 1 * file.MB, + } +} diff --git a/cmd/syft/cli/options/file_metadata.go b/cmd/syft/cli/options/file_metadata.go new file mode 100644 index 00000000000..eb2335a24da --- /dev/null +++ b/cmd/syft/cli/options/file_metadata.go @@ -0,0 +1,19 @@ +package options + +import ( + "github.com/anchore/syft/syft/source" +) + +type fileMetadata struct { + Cataloger scope `yaml:"cataloger" json:"cataloger" mapstructure:"cataloger"` + Digests []string `yaml:"digests" json:"digests" mapstructure:"digests"` +} + +func defaultFileMetadata() fileMetadata { + return fileMetadata{ + Cataloger: scope{ + Scope: source.SquashedScope.String(), + }, + Digests: []string{"sha256"}, + } +} diff --git a/cmd/syft/cli/options/fulcio.go b/cmd/syft/cli/options/fulcio.go deleted file mode 100644 index b1dec9eb6c7..00000000000 --- a/cmd/syft/cli/options/fulcio.go +++ /dev/null @@ -1,49 +0,0 @@ -package options - -import ( - "github.com/spf13/cobra" - "github.com/spf13/pflag" - "github.com/spf13/viper" -) - -const defaultFulcioURL = "https://fulcio.sigstore.dev" - -// FulcioOptions is the wrapper for Fulcio related options. -type FulcioOptions struct { - URL string - IdentityToken string - InsecureSkipFulcioVerify bool -} - -var _ Interface = (*FulcioOptions)(nil) - -// AddFlags implements Interface -func (o *FulcioOptions) AddFlags(cmd *cobra.Command, v *viper.Viper) error { - // TODO: change this back to api.SigstorePublicServerURL after the v1 migration is complete. - cmd.Flags().StringVar(&o.URL, "fulcio-url", defaultFulcioURL, - "address of sigstore PKI server") - - cmd.Flags().StringVar(&o.IdentityToken, "identity-token", "", - "identity token to use for certificate from fulcio") - - cmd.Flags().BoolVar(&o.InsecureSkipFulcioVerify, "insecure-skip-verify", false, - "skip verifying fulcio certificat and the SCT (Signed Certificate Timestamp) (this should only be used for testing).") - return bindFulcioConfigOptions(cmd.Flags(), v) -} - -//nolint:revive -func bindFulcioConfigOptions(flags *pflag.FlagSet, v *viper.Viper) error { - if err := v.BindPFlag("attest.fulcio-url", flags.Lookup("fulcio-url")); err != nil { - return err - } - - if err := v.BindPFlag("attest.fulcio-identity-token", flags.Lookup("identity-token")); err != nil { - return err - } - - if err := v.BindPFlag("attest.insecure-skip-verify", flags.Lookup("insecure-skip-verify")); err != nil { - return err - } - - return nil -} diff --git a/internal/config/golang.go b/cmd/syft/cli/options/golang.go similarity index 64% rename from internal/config/golang.go rename to cmd/syft/cli/options/golang.go index 56ebbed081c..ff99f414fd3 100644 --- a/internal/config/golang.go +++ b/cmd/syft/cli/options/golang.go @@ -1,6 +1,4 @@ -package config - -import "github.com/spf13/viper" +package options type golang struct { SearchLocalModCacheLicenses bool `json:"search-local-mod-cache-licenses" yaml:"search-local-mod-cache-licenses" mapstructure:"search-local-mod-cache-licenses"` @@ -9,11 +7,3 @@ type golang struct { Proxy string `json:"proxy" yaml:"proxy" mapstructure:"proxy"` NoProxy string `json:"no-proxy" yaml:"no-proxy" mapstructure:"no-proxy"` } - -func (cfg golang) loadDefaultValues(v *viper.Viper) { - v.SetDefault("golang.search-local-mod-cache-licenses", false) - v.SetDefault("golang.local-mod-cache-dir", "") - v.SetDefault("golang.search-remote-licenses", false) - v.SetDefault("golang.proxy", "") - v.SetDefault("golang.no-proxy", "") -} diff --git a/cmd/syft/cli/options/linux_kernel.go b/cmd/syft/cli/options/linux_kernel.go new file mode 100644 index 00000000000..c56466abf81 --- /dev/null +++ b/cmd/syft/cli/options/linux_kernel.go @@ -0,0 +1,11 @@ +package options + +type linuxKernel struct { + CatalogModules bool `json:"catalog-modules" yaml:"catalog-modules" mapstructure:"catalog-modules"` +} + +func defaultLinuxKernel() linuxKernel { + return linuxKernel{ + CatalogModules: true, + } +} diff --git a/cmd/syft/cli/options/oidc.go b/cmd/syft/cli/options/oidc.go deleted file mode 100644 index 580ecc176a4..00000000000 --- a/cmd/syft/cli/options/oidc.go +++ /dev/null @@ -1,49 +0,0 @@ -package options - -import ( - "github.com/spf13/cobra" - "github.com/spf13/pflag" - "github.com/spf13/viper" -) - -const DefaultOIDCIssuerURL = "https://oauth2.sigstore.dev/auth" - -// OIDCOptions is the wrapper for OIDC related options. -type OIDCOptions struct { - Issuer string - ClientID string - RedirectURL string -} - -var _ Interface = (*OIDCOptions)(nil) - -// AddFlags implements Interface -func (o *OIDCOptions) AddFlags(cmd *cobra.Command, v *viper.Viper) error { - cmd.Flags().StringVar(&o.Issuer, "oidc-issuer", DefaultOIDCIssuerURL, - "OIDC provider to be used to issue ID token") - - cmd.Flags().StringVar(&o.ClientID, "oidc-client-id", "sigstore", - "OIDC client ID for application") - - cmd.Flags().StringVar(&o.RedirectURL, "oidc-redirect-url", "", - "OIDC redirect URL (Optional)") - - return bindOIDCConfigOptions(cmd.Flags(), v) -} - -//nolint:revive -func bindOIDCConfigOptions(flags *pflag.FlagSet, v *viper.Viper) error { - if err := v.BindPFlag("attest.oidc-issuer", flags.Lookup("oidc-issuer")); err != nil { - return err - } - - if err := v.BindPFlag("attest.oidc-client-id", flags.Lookup("oidc-client-id")); err != nil { - return err - } - - if err := v.BindPFlag("attest.oidc-redirect-url", flags.Lookup("oidc-redirect-url")); err != nil { - return err - } - - return nil -} diff --git a/cmd/syft/cli/options/options.go b/cmd/syft/cli/options/options.go deleted file mode 100644 index f8646bbb9c8..00000000000 --- a/cmd/syft/cli/options/options.go +++ /dev/null @@ -1,11 +0,0 @@ -package options - -import ( - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -type Interface interface { - // AddFlags adds this options' flags to the cobra command. - AddFlags(cmd *cobra.Command, v *viper.Viper) error -} diff --git a/cmd/syft/cli/options/output.go b/cmd/syft/cli/options/output.go new file mode 100644 index 00000000000..1c321fd476c --- /dev/null +++ b/cmd/syft/cli/options/output.go @@ -0,0 +1,95 @@ +package options + +import ( + "fmt" + + "golang.org/x/exp/slices" + + "github.com/anchore/clio" + "github.com/anchore/syft/syft/formats" + "github.com/anchore/syft/syft/formats/table" + "github.com/anchore/syft/syft/formats/template" + "github.com/anchore/syft/syft/sbom" +) + +// MultiOutput has the standard output options syft accepts: multiple -o, --file, --template +type MultiOutput struct { + Outputs []string `yaml:"output" json:"output" mapstructure:"output"` // -o, the format to use for output + OutputFile `yaml:",inline" json:"" mapstructure:",squash"` + OutputTemplatePath string `yaml:"output-template-path" json:"output-template-path" mapstructure:"output-template-path"` // -t template file to use for output +} + +var _ interface { + clio.FlagAdder +} = (*MultiOutput)(nil) + +func DefaultOutput() MultiOutput { + return MultiOutput{ + Outputs: []string{string(table.ID)}, + } +} + +func (o *MultiOutput) AddFlags(flags clio.FlagSet) { + flags.StringArrayVarP(&o.Outputs, "output", "o", + fmt.Sprintf("report output format, options=%v", formats.AllIDs())) + + flags.StringVarP(&o.OutputTemplatePath, "template", "t", + "specify the path to a Go template file") +} + +func (o *MultiOutput) SBOMWriter() (sbom.Writer, error) { + return makeSBOMWriter(o.Outputs, o.File, o.OutputTemplatePath) +} + +// SingleOutput allows only 1 output to be specified, with a user able to set what options are allowed by setting AllowableOptions +type SingleOutput struct { + AllowableOptions []string `yaml:"-" json:"-" mapstructure:"-"` + Output string `yaml:"output" json:"output" mapstructure:"output"` + OutputTemplatePath string `yaml:"output-template-path" json:"output-template-path" mapstructure:"output-template-path"` // -t template file to use for output +} + +var _ clio.FlagAdder = (*SingleOutput)(nil) + +func (o *SingleOutput) AddFlags(flags clio.FlagSet) { + flags.StringVarP(&o.Output, "output", "o", + fmt.Sprintf("report output format, options=%v", o.AllowableOptions)) + + if slices.Contains(o.AllowableOptions, template.ID.String()) { + flags.StringVarP(&o.OutputTemplatePath, "template", "t", + "specify the path to a Go template file") + } +} + +func (o *SingleOutput) SBOMWriter(file string) (sbom.Writer, error) { + return makeSBOMWriter([]string{o.Output}, file, o.OutputTemplatePath) +} + +// OutputFile is only the --file argument +type OutputFile struct { + File string `yaml:"file" json:"file" mapstructure:"file"` // --file, the file to write report output to +} + +var _ interface { + clio.FlagAdder + clio.PostLoader +} = (*OutputFile)(nil) + +func (o *OutputFile) AddFlags(flags clio.FlagSet) { + flags.StringVarP(&o.File, "file", "", + "file to write the default report output to (default is STDOUT)") +} + +func (o *OutputFile) PostLoad() error { + if o.File != "" { + file, err := expandFilePath(o.File) + if err != nil { + return err + } + o.File = file + } + return nil +} + +func (o *OutputFile) SBOMWriter(f sbom.Format) (sbom.Writer, error) { + return makeSBOMWriterForFormat(f, o.File) +} diff --git a/cmd/syft/cli/options/packages.go b/cmd/syft/cli/options/packages.go deleted file mode 100644 index 259a787811c..00000000000 --- a/cmd/syft/cli/options/packages.go +++ /dev/null @@ -1,118 +0,0 @@ -package options - -import ( - "fmt" - - "github.com/spf13/cobra" - "github.com/spf13/pflag" - "github.com/spf13/viper" - - "github.com/anchore/syft/syft/formats" - "github.com/anchore/syft/syft/formats/table" - "github.com/anchore/syft/syft/pkg/cataloger" - "github.com/anchore/syft/syft/source" -) - -type PackagesOptions struct { - Scope string - Output []string - OutputTemplatePath string - File string - Platform string - Exclude []string - Catalogers []string - SourceName string - SourceVersion string - BasePath string -} - -var _ Interface = (*PackagesOptions)(nil) - -func (o *PackagesOptions) AddFlags(cmd *cobra.Command, v *viper.Viper) error { - cmd.Flags().StringVarP(&o.Scope, "scope", "s", cataloger.DefaultSearchConfig().Scope.String(), - fmt.Sprintf("selection of layers to catalog, options=%v", source.AllScopes)) - - cmd.Flags().StringArrayVarP(&o.Output, "output", "o", []string{string(table.ID)}, - fmt.Sprintf("report output format, options=%v", formats.AllIDs())) - - cmd.Flags().StringVarP(&o.File, "file", "", "", - "file to write the default report output to (default is STDOUT)") - - cmd.Flags().StringVarP(&o.OutputTemplatePath, "template", "t", "", - "specify the path to a Go template file") - - cmd.Flags().StringVarP(&o.Platform, "platform", "", "", - "an optional platform specifier for container image sources (e.g. 'linux/arm64', 'linux/arm64/v8', 'arm64', 'linux')") - - cmd.Flags().StringArrayVarP(&o.Exclude, "exclude", "", nil, - "exclude paths from being scanned using a glob expression") - - cmd.Flags().StringArrayVarP(&o.Catalogers, "catalogers", "", nil, - "enable one or more package catalogers") - - cmd.Flags().StringVarP(&o.SourceName, "name", "", "", - "set the name of the target being analyzed") - cmd.Flags().Lookup("name").Deprecated = "use: source-name" - - cmd.Flags().StringVarP(&o.SourceName, "source-name", "", "", - "set the name of the target being analyzed") - - cmd.Flags().StringVarP(&o.SourceVersion, "source-version", "", "", - "set the name of the target being analyzed") - - cmd.Flags().StringVarP(&o.BasePath, "base-path", "", "", - "base directory for scanning, no links will be followed above this directory, and all paths will be reported relative to this directory") - - return bindPackageConfigOptions(cmd.Flags(), v) -} - -//nolint:revive -func bindPackageConfigOptions(flags *pflag.FlagSet, v *viper.Viper) error { - // Formatting & Input options ////////////////////////////////////////////// - - if err := v.BindPFlag("package.cataloger.scope", flags.Lookup("scope")); err != nil { - return err - } - - if err := v.BindPFlag("file", flags.Lookup("file")); err != nil { - return err - } - - if err := v.BindPFlag("exclude", flags.Lookup("exclude")); err != nil { - return err - } - - if err := v.BindPFlag("catalogers", flags.Lookup("catalogers")); err != nil { - return err - } - - if err := v.BindPFlag("name", flags.Lookup("name")); err != nil { - return err - } - - if err := v.BindPFlag("source.name", flags.Lookup("source-name")); err != nil { - return err - } - - if err := v.BindPFlag("source.version", flags.Lookup("source-version")); err != nil { - return err - } - - if err := v.BindPFlag("output", flags.Lookup("output")); err != nil { - return err - } - - if err := v.BindPFlag("output-template-path", flags.Lookup("template")); err != nil { - return err - } - - if err := v.BindPFlag("platform", flags.Lookup("platform")); err != nil { - return err - } - - if err := v.BindPFlag("base-path", flags.Lookup("base-path")); err != nil { - return err - } - - return nil -} diff --git a/cmd/syft/cli/options/pkg.go b/cmd/syft/cli/options/pkg.go new file mode 100644 index 00000000000..329dad9ed88 --- /dev/null +++ b/cmd/syft/cli/options/pkg.go @@ -0,0 +1,23 @@ +package options + +import ( + "github.com/anchore/syft/syft/pkg/cataloger" +) + +type pkg struct { + Cataloger scope `yaml:"cataloger" json:"cataloger" mapstructure:"cataloger"` + SearchUnindexedArchives bool `yaml:"search-unindexed-archives" json:"search-unindexed-archives" mapstructure:"search-unindexed-archives"` + SearchIndexedArchives bool `yaml:"search-indexed-archives" json:"search-indexed-archives" mapstructure:"search-indexed-archives"` +} + +func defaultPkg() pkg { + c := cataloger.DefaultSearchConfig() + return pkg{ + SearchIndexedArchives: c.IncludeIndexedArchives, + SearchUnindexedArchives: c.IncludeUnindexedArchives, + Cataloger: scope{ + Enabled: true, + Scope: c.Scope.String(), + }, + } +} diff --git a/internal/config/python.go b/cmd/syft/cli/options/python.go similarity index 50% rename from internal/config/python.go rename to cmd/syft/cli/options/python.go index d86da39be6a..0efab8713a2 100644 --- a/internal/config/python.go +++ b/cmd/syft/cli/options/python.go @@ -1,13 +1,5 @@ -package config - -import ( - "github.com/spf13/viper" -) +package options type python struct { GuessUnpinnedRequirements bool `json:"guess-unpinned-requirements" yaml:"guess-unpinned-requirements" mapstructure:"guess-unpinned-requirements"` } - -func (cfg python) loadDefaultValues(v *viper.Viper) { - v.SetDefault("python.guess-unpinned-requirements", false) -} diff --git a/internal/config/registry.go b/cmd/syft/cli/options/registry.go similarity index 71% rename from internal/config/registry.go rename to cmd/syft/cli/options/registry.go index 6b0e5dc9c6b..455ba93d51f 100644 --- a/internal/config/registry.go +++ b/cmd/syft/cli/options/registry.go @@ -1,21 +1,18 @@ -package config +package options import ( "os" - "github.com/spf13/viper" - + "github.com/anchore/clio" "github.com/anchore/stereoscope/pkg/image" ) type RegistryCredentials struct { Authority string `yaml:"authority" json:"authority" mapstructure:"authority"` - // IMPORTANT: do not show the username in any YAML/JSON output (sensitive information) - Username string `yaml:"-" json:"-" mapstructure:"username"` - // IMPORTANT: do not show the password in any YAML/JSON output (sensitive information) - Password string `yaml:"-" json:"-" mapstructure:"password"` - // IMPORTANT: do not show the token in any YAML/JSON output (sensitive information) - Token string `yaml:"-" json:"-" mapstructure:"token"` + // IMPORTANT: do not show any credential information, use secret type to automatically redact the values + Username secret `yaml:"username" json:"username" mapstructure:"username"` + Password secret `yaml:"password" json:"password" mapstructure:"password"` + Token secret `yaml:"token" json:"token" mapstructure:"token"` TLSCert string `yaml:"tls-cert,omitempty" json:"tls-cert,omitempty" mapstructure:"tls-cert"` TLSKey string `yaml:"tls-key,omitempty" json:"tls-key,omitempty" mapstructure:"tls-key"` @@ -28,15 +25,9 @@ type registry struct { CACert string `yaml:"ca-cert" json:"ca-cert" mapstructure:"ca-cert"` } -func (cfg registry) loadDefaultValues(v *viper.Viper) { - v.SetDefault("registry.insecure-skip-tls-verify", false) - v.SetDefault("registry.insecure-use-http", false) - v.SetDefault("registry.auth", []RegistryCredentials{}) - v.SetDefault("registry.ca-cert", "") -} +var _ clio.PostLoader = (*registry)(nil) -//nolint:unparam -func (cfg *registry) parseConfigValues() error { +func (cfg *registry) PostLoad() error { // there may be additional credentials provided by env var that should be appended to the set of credentials authority, username, password, token, tlsCert, tlsKey := os.Getenv("SYFT_REGISTRY_AUTH_AUTHORITY"), @@ -48,12 +39,14 @@ func (cfg *registry) parseConfigValues() error { if hasNonEmptyCredentials(username, password, token, tlsCert, tlsKey) { // note: we prepend the credentials such that the environment variables take precedence over on-disk configuration. + // since this PostLoad is called before the PostLoad on the Auth credentials list, + // all appropriate redactions will be added cfg.Auth = append([]RegistryCredentials{ { Authority: authority, - Username: username, - Password: password, - Token: token, + Username: secret(username), + Password: secret(password), + Token: secret(token), TLSCert: tlsCert, TLSKey: tlsKey, }, @@ -74,9 +67,9 @@ func (cfg *registry) ToOptions() *image.RegistryOptions { for i, a := range cfg.Auth { auth[i] = image.RegistryCredentials{ Authority: a.Authority, - Username: a.Username, - Password: a.Password, - Token: a.Token, + Username: a.Username.String(), + Password: a.Password.String(), + Token: a.Token.String(), ClientCert: a.TLSCert, ClientKey: a.TLSKey, } diff --git a/internal/config/registry_test.go b/cmd/syft/cli/options/registry_test.go similarity index 99% rename from internal/config/registry_test.go rename to cmd/syft/cli/options/registry_test.go index 034384e6161..4979fcbc281 100644 --- a/internal/config/registry_test.go +++ b/cmd/syft/cli/options/registry_test.go @@ -1,4 +1,4 @@ -package config +package options import ( "fmt" diff --git a/cmd/syft/cli/options/rekor.go b/cmd/syft/cli/options/rekor.go deleted file mode 100644 index 49539539c81..00000000000 --- a/cmd/syft/cli/options/rekor.go +++ /dev/null @@ -1,33 +0,0 @@ -package options - -import ( - "github.com/spf13/cobra" - "github.com/spf13/pflag" - "github.com/spf13/viper" -) - -const DefaultRekorURL = "https://rekor.sigstore.dev" - -// RekorOptions is the wrapper for Rekor related options. -type RekorOptions struct { - URL string -} - -var _ Interface = (*RekorOptions)(nil) - -// AddFlags implements Interface -func (o *RekorOptions) AddFlags(cmd *cobra.Command, v *viper.Viper) error { - cmd.Flags().StringVar(&o.URL, "rekor-url", DefaultRekorURL, - "address of rekor STL server") - return bindRekorConfigOptions(cmd.Flags(), v) -} - -//nolint:revive -func bindRekorConfigOptions(flags *pflag.FlagSet, v *viper.Viper) error { - // TODO: config re-design - if err := v.BindPFlag("attest.rekor-url", flags.Lookup("rekor-url")); err != nil { - return err - } - - return nil -} diff --git a/cmd/syft/cli/options/root.go b/cmd/syft/cli/options/root.go deleted file mode 100644 index 316fbff17e4..00000000000 --- a/cmd/syft/cli/options/root.go +++ /dev/null @@ -1,37 +0,0 @@ -package options - -import ( - "github.com/spf13/cobra" - "github.com/spf13/pflag" - "github.com/spf13/viper" -) - -type RootOptions struct { - Config string - Quiet bool - Verbose int -} - -var _ Interface = (*RootOptions)(nil) - -func (o *RootOptions) AddFlags(cmd *cobra.Command, v *viper.Viper) error { - cmd.PersistentFlags().StringVarP(&o.Config, "config", "c", "", "application config file") - cmd.PersistentFlags().CountVarP(&o.Verbose, "verbose", "v", "increase verbosity (-v = info, -vv = debug)") - cmd.PersistentFlags().BoolVarP(&o.Quiet, "quiet", "q", false, "suppress all logging output") - - return bindRootConfigOptions(cmd.PersistentFlags(), v) -} - -//nolint:revive -func bindRootConfigOptions(flags *pflag.FlagSet, v *viper.Viper) error { - if err := v.BindPFlag("config", flags.Lookup("config")); err != nil { - return err - } - if err := v.BindPFlag("verbosity", flags.Lookup("verbose")); err != nil { - return err - } - if err := v.BindPFlag("quiet", flags.Lookup("quiet")); err != nil { - return err - } - return nil -} diff --git a/cmd/syft/cli/options/scope.go b/cmd/syft/cli/options/scope.go new file mode 100644 index 00000000000..ae6efcffea7 --- /dev/null +++ b/cmd/syft/cli/options/scope.go @@ -0,0 +1,27 @@ +package options + +import ( + "fmt" + + "github.com/anchore/clio" + "github.com/anchore/syft/syft/source" +) + +type scope struct { + Enabled bool `yaml:"enabled" json:"enabled" mapstructure:"enabled"` + Scope string `yaml:"scope" json:"scope" mapstructure:"scope"` +} + +var _ clio.PostLoader = (*scope)(nil) + +func (opt *scope) PostLoad() error { + s := opt.GetScope() + if s == source.UnknownScope { + return fmt.Errorf("bad scope value %v", opt.Scope) + } + return nil +} + +func (opt scope) GetScope() source.Scope { + return source.ParseScope(opt.Scope) +} diff --git a/cmd/syft/cli/options/secret.go b/cmd/syft/cli/options/secret.go new file mode 100644 index 00000000000..3224dd80312 --- /dev/null +++ b/cmd/syft/cli/options/secret.go @@ -0,0 +1,25 @@ +package options + +import ( + "fmt" + + "github.com/anchore/clio" + "github.com/anchore/syft/internal/redact" +) + +type secret string + +var _ interface { + fmt.Stringer + clio.PostLoader +} = (*secret)(nil) + +// PostLoad needs to use a pointer receiver, even if it's not modifying the value +func (r *secret) PostLoad() error { + redact.Add(string(*r)) + return nil +} + +func (r secret) String() string { + return string(r) +} diff --git a/internal/config/secrets.go b/cmd/syft/cli/options/secrets.go similarity index 52% rename from internal/config/secrets.go rename to cmd/syft/cli/options/secrets.go index 0d0efc4f1fc..58693f6e681 100644 --- a/internal/config/secrets.go +++ b/cmd/syft/cli/options/secrets.go @@ -1,29 +1,23 @@ -package config +package options import ( - "github.com/spf13/viper" - "github.com/anchore/syft/internal/file" "github.com/anchore/syft/syft/source" ) type secrets struct { - Cataloger catalogerOptions `yaml:"cataloger" json:"cataloger" mapstructure:"cataloger"` + Cataloger scope `yaml:"cataloger" json:"cataloger" mapstructure:"cataloger"` AdditionalPatterns map[string]string `yaml:"additional-patterns" json:"additional-patterns" mapstructure:"additional-patterns"` ExcludePatternNames []string `yaml:"exclude-pattern-names" json:"exclude-pattern-names" mapstructure:"exclude-pattern-names"` RevealValues bool `yaml:"reveal-values" json:"reveal-values" mapstructure:"reveal-values"` SkipFilesAboveSize int64 `yaml:"skip-files-above-size" json:"skip-files-above-size" mapstructure:"skip-files-above-size"` } -func (cfg secrets) loadDefaultValues(v *viper.Viper) { - v.SetDefault("secrets.cataloger.enabled", catalogerEnabledDefault) - v.SetDefault("secrets.cataloger.scope", source.AllLayersScope) - v.SetDefault("secrets.reveal-values", false) - v.SetDefault("secrets.skip-files-above-size", 1*file.MB) - v.SetDefault("secrets.additional-patterns", map[string]string{}) - v.SetDefault("secrets.exclude-pattern-names", []string{}) -} - -func (cfg *secrets) parseConfigValues() error { - return cfg.Cataloger.parseConfigValues() +func defaultSecrets() secrets { + return secrets{ + Cataloger: scope{ + Scope: source.AllLayersScope.String(), + }, + SkipFilesAboveSize: 1 * file.MB, + } } diff --git a/internal/config/source.go b/cmd/syft/cli/options/source.go similarity index 67% rename from internal/config/source.go rename to cmd/syft/cli/options/source.go index 5346f994fa7..41e30199645 100644 --- a/internal/config/source.go +++ b/cmd/syft/cli/options/source.go @@ -1,6 +1,4 @@ -package config - -import "github.com/spf13/viper" +package options type sourceCfg struct { Name string `json:"name" yaml:"name" mapstructure:"name"` @@ -12,6 +10,10 @@ type fileSource struct { Digests []string `json:"digests" yaml:"digests" mapstructure:"digests"` } -func (cfg sourceCfg) loadDefaultValues(v *viper.Viper) { - v.SetDefault("source.file.digests", []string{"sha256"}) +func defaultSourceCfg() sourceCfg { + return sourceCfg{ + File: fileSource{ + Digests: []string{"sha256"}, + }, + } } diff --git a/cmd/syft/cli/options/update_check.go b/cmd/syft/cli/options/update_check.go new file mode 100644 index 00000000000..a05b5242061 --- /dev/null +++ b/cmd/syft/cli/options/update_check.go @@ -0,0 +1,11 @@ +package options + +type UpdateCheck struct { + CheckForAppUpdate bool `yaml:"check-for-app-update" json:"check-for-app-update" mapstructure:"check-for-app-update"` // whether to check for an application update on start up or not +} + +func DefaultUpdateCheck() UpdateCheck { + return UpdateCheck{ + CheckForAppUpdate: true, + } +} diff --git a/cmd/syft/cli/options/verbose.go b/cmd/syft/cli/options/verbose.go deleted file mode 100644 index aa53bb40ebe..00000000000 --- a/cmd/syft/cli/options/verbose.go +++ /dev/null @@ -1,18 +0,0 @@ -package options - -import ( - "github.com/anchore/syft/internal" - "github.com/anchore/syft/internal/config" - "github.com/anchore/syft/internal/log" -) - -func IsVerbose(app *config.Application) (result bool) { - isPipedInput, err := internal.IsPipedInput() - if err != nil { - // since we can't tell if there was piped input we assume that there could be to disable the ETUI - log.Warnf("unable to determine if there is piped input: %+v", err) - return true - } - // verbosity should consider if there is piped input (in which case we should not show the ETUI) - return app.Verbosity > 0 || isPipedInput -} diff --git a/cmd/syft/cli/options/version.go b/cmd/syft/cli/options/version.go deleted file mode 100644 index a3ac49cf607..00000000000 --- a/cmd/syft/cli/options/version.go +++ /dev/null @@ -1,17 +0,0 @@ -package options - -import ( - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -type VersionOptions struct { - Output string -} - -var _ Interface = (*VersionOptions)(nil) - -func (o *VersionOptions) AddFlags(cmd *cobra.Command, _ *viper.Viper) error { - cmd.Flags().StringVarP(&o.Output, "output", "o", "text", "format to show version information (available=[text, json])") - return nil -} diff --git a/cmd/syft/cli/options/writer.go b/cmd/syft/cli/options/writer.go index 1ea4ff1a205..0472451691b 100644 --- a/cmd/syft/cli/options/writer.go +++ b/cmd/syft/cli/options/writer.go @@ -26,9 +26,9 @@ var _ interface { sbom.Writer } = (*sbomStreamWriter)(nil) -// MakeSBOMWriter creates a sbom.Writer for output or returns an error. this will either return a valid writer +// makeSBOMWriter creates a sbom.Writer for output or returns an error. this will either return a valid writer // or an error but neither both and if there is no error, sbom.Writer.Close() should be called -func MakeSBOMWriter(outputs []string, defaultFile, templateFilePath string) (sbom.Writer, error) { +func makeSBOMWriter(outputs []string, defaultFile, templateFilePath string) (sbom.Writer, error) { outputOptions, err := parseSBOMOutputFlags(outputs, defaultFile, templateFilePath) if err != nil { return nil, err @@ -42,8 +42,8 @@ func MakeSBOMWriter(outputs []string, defaultFile, templateFilePath string) (sbo return writer, nil } -// MakeSBOMWriterForFormat creates a sbom.Writer for for the given format or returns an error. -func MakeSBOMWriterForFormat(format sbom.Format, path string) (sbom.Writer, error) { +// makeSBOMWriterForFormat creates a sbom.Writer for for the given format or returns an error. +func makeSBOMWriterForFormat(format sbom.Format, path string) (sbom.Writer, error) { writer, err := newSBOMMultiWriter(newSBOMWriterDescription(format, path)) if err != nil { return nil, err diff --git a/cmd/syft/cli/options/writer_test.go b/cmd/syft/cli/options/writer_test.go index 643d251cd7f..f71d2b9d39a 100644 --- a/cmd/syft/cli/options/writer_test.go +++ b/cmd/syft/cli/options/writer_test.go @@ -34,7 +34,7 @@ func Test_MakeSBOMWriter(t *testing.T) { } for _, tt := range tests { - _, err := MakeSBOMWriter(tt.outputs, "", "") + _, err := makeSBOMWriter(tt.outputs, "", "") tt.wantErr(t, err) } } diff --git a/cmd/syft/cli/packages.go b/cmd/syft/cli/packages.go deleted file mode 100644 index 88154b19912..00000000000 --- a/cmd/syft/cli/packages.go +++ /dev/null @@ -1,86 +0,0 @@ -package cli - -import ( - "fmt" - "log" - - "github.com/spf13/cobra" - "github.com/spf13/viper" - - "github.com/anchore/syft/cmd/syft/cli/options" - "github.com/anchore/syft/cmd/syft/cli/packages" - "github.com/anchore/syft/internal" - "github.com/anchore/syft/internal/config" -) - -const ( - packagesExample = ` {{.appName}} {{.command}} alpine:latest a summary of discovered packages - {{.appName}} {{.command}} alpine:latest -o json show all possible cataloging details - {{.appName}} {{.command}} alpine:latest -o cyclonedx show a CycloneDX formatted SBOM - {{.appName}} {{.command}} alpine:latest -o cyclonedx-json show a CycloneDX JSON formatted SBOM - {{.appName}} {{.command}} alpine:latest -o spdx show a SPDX 2.3 Tag-Value formatted SBOM - {{.appName}} {{.command}} alpine:latest -o spdx@2.2 show a SPDX 2.2 Tag-Value formatted SBOM - {{.appName}} {{.command}} alpine:latest -o spdx-json show a SPDX 2.3 JSON formatted SBOM - {{.appName}} {{.command}} alpine:latest -o spdx-json@2.2 show a SPDX 2.2 JSON formatted SBOM - {{.appName}} {{.command}} alpine:latest -vv show verbose debug information - {{.appName}} {{.command}} alpine:latest -o template -t my_format.tmpl show a SBOM formatted according to given template file - - Supports the following image sources: - {{.appName}} {{.command}} yourrepo/yourimage:tag defaults to using images from a Docker daemon. If Docker is not present, the image is pulled directly from the registry. - {{.appName}} {{.command}} path/to/a/file/or/dir a Docker tar, OCI tar, OCI directory, SIF container, or generic filesystem directory -` - - schemeHelpHeader = "You can also explicitly specify the scheme to use:" - imageSchemeHelp = ` {{.appName}} {{.command}} docker:yourrepo/yourimage:tag explicitly use the Docker daemon - {{.appName}} {{.command}} podman:yourrepo/yourimage:tag explicitly use the Podman daemon - {{.appName}} {{.command}} registry:yourrepo/yourimage:tag pull image directly from a registry (no container runtime required) - {{.appName}} {{.command}} docker-archive:path/to/yourimage.tar use a tarball from disk for archives created from "docker save" - {{.appName}} {{.command}} oci-archive:path/to/yourimage.tar use a tarball from disk for OCI archives (from Skopeo or otherwise) - {{.appName}} {{.command}} oci-dir:path/to/yourimage read directly from a path on disk for OCI layout directories (from Skopeo or otherwise) - {{.appName}} {{.command}} singularity:path/to/yourimage.sif read directly from a Singularity Image Format (SIF) container on disk -` - nonImageSchemeHelp = ` {{.appName}} {{.command}} dir:path/to/yourproject read directly from a path on disk (any directory) - {{.appName}} {{.command}} file:path/to/yourproject/file read directly from a path on disk (any single file) -` - packagesSchemeHelp = "\n" + indent + schemeHelpHeader + "\n" + imageSchemeHelp + nonImageSchemeHelp - - packagesHelp = packagesExample + packagesSchemeHelp -) - -//nolint:dupl -func Packages(v *viper.Viper, app *config.Application, ro *options.RootOptions, po *options.PackagesOptions) *cobra.Command { - cmd := &cobra.Command{ - Use: "packages [SOURCE]", - Short: "Generate a package SBOM", - Long: "Generate a packaged-based Software Bill Of Materials (SBOM) from container images and filesystems", - Example: internal.Tprintf(packagesHelp, map[string]interface{}{ - "appName": internal.ApplicationName, - "command": "packages", - }), - Args: func(cmd *cobra.Command, args []string) error { - if err := app.LoadAllValues(v, ro.Config); err != nil { - return fmt.Errorf("invalid application config: %w", err) - } - // configure logging for command - newLogWrapper(app) - logApplicationConfig(app) - return validateArgs(cmd, args) - }, - SilenceUsage: true, - SilenceErrors: true, - RunE: func(cmd *cobra.Command, args []string) error { - if app.CheckForAppUpdate { - // TODO: this is broke, the bus isn't available yet - checkForApplicationUpdate() - } - return packages.Run(cmd.Context(), app, args) - }, - } - - err := po.AddFlags(cmd, v) - if err != nil { - log.Fatal(err) - } - - return cmd -} diff --git a/cmd/syft/cli/packages/packages.go b/cmd/syft/cli/packages/packages.go deleted file mode 100644 index 0f87720fb8a..00000000000 --- a/cmd/syft/cli/packages/packages.go +++ /dev/null @@ -1,192 +0,0 @@ -package packages - -import ( - "context" - "fmt" - - "github.com/wagoodman/go-partybus" - - "github.com/anchore/stereoscope" - "github.com/anchore/stereoscope/pkg/image" - "github.com/anchore/syft/cmd/syft/cli/eventloop" - "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/config" - "github.com/anchore/syft/internal/file" - "github.com/anchore/syft/internal/log" - "github.com/anchore/syft/internal/version" - "github.com/anchore/syft/syft" - "github.com/anchore/syft/syft/artifact" - "github.com/anchore/syft/syft/formats/template" - "github.com/anchore/syft/syft/sbom" - "github.com/anchore/syft/syft/source" -) - -func Run(_ context.Context, app *config.Application, args []string) error { - err := ValidateOutputOptions(app) - if err != nil { - return err - } - - writer, err := options.MakeSBOMWriter(app.Outputs, app.File, app.OutputTemplatePath) - if err != nil { - return err - } - - // could be an image or a directory, with or without a scheme - userInput := args[0] - - eventBus := partybus.NewBus() - stereoscope.SetBus(eventBus) - syft.SetBus(eventBus) - subscription := eventBus.Subscribe() - - return eventloop.EventLoop( - execWorker(app, userInput, writer), - eventloop.SetupSignals(), - subscription, - stereoscope.Cleanup, - ui.Select(options.IsVerbose(app), app.Quiet)..., - ) -} - -// nolint:funlen -func execWorker(app *config.Application, userInput string, writer sbom.Writer) <-chan error { - errs := make(chan error) - go func() { - defer close(errs) - defer bus.Exit() - - detection, err := source.Detect( - userInput, - source.DetectConfig{ - DefaultImageSource: app.DefaultImagePullSource, - }, - ) - if err != nil { - errs <- fmt.Errorf("could not deteremine source: %w", err) - return - } - - var platform *image.Platform - - if app.Platform != "" { - platform, err = image.NewPlatform(app.Platform) - if err != nil { - errs <- fmt.Errorf("invalid platform: %w", err) - return - } - } - - hashers, err := file.Hashers(app.Source.File.Digests...) - if err != nil { - errs <- fmt.Errorf("invalid hash: %w", err) - return - } - - src, err := detection.NewSource( - source.DetectionSourceConfig{ - Alias: source.Alias{ - Name: app.Source.Name, - Version: app.Source.Version, - }, - RegistryOptions: app.Registry.ToOptions(), - Platform: platform, - Exclude: source.ExcludeConfig{ - Paths: app.Exclusions, - }, - DigestAlgorithms: hashers, - BasePath: app.BasePath, - }, - ) - - if err != nil { - errs <- fmt.Errorf("failed to construct source from user input %q: %w", userInput, err) - return - } - - defer func() { - if src != nil { - if err := src.Close(); err != nil { - log.Tracef("unable to close source: %+v", err) - } - } - }() - - s, err := GenerateSBOM(src, errs, app) - if err != nil { - errs <- err - return - } - - if s == nil { - errs <- fmt.Errorf("no SBOM produced for %q", userInput) - return - } - - if err := writer.Write(*s); err != nil { - errs <- fmt.Errorf("failed to write SBOM: %w", err) - return - } - }() - return errs -} - -func GenerateSBOM(src source.Source, errs chan error, app *config.Application) (*sbom.SBOM, error) { - tasks, err := eventloop.Tasks(app) - if err != nil { - return nil, err - } - - s := sbom.SBOM{ - Source: src.Describe(), - Descriptor: sbom.Descriptor{ - Name: internal.ApplicationName, - Version: version.FromBuild().Version, - Configuration: app, - }, - } - - buildRelationships(&s, src, tasks, errs) - - return &s, nil -} - -func buildRelationships(s *sbom.SBOM, src source.Source, tasks []eventloop.Task, errs chan error) { - var relationships []<-chan artifact.Relationship - for _, task := range tasks { - c := make(chan artifact.Relationship) - relationships = append(relationships, c) - go eventloop.RunTask(task, &s.Artifacts, src, c, errs) - } - - s.Relationships = append(s.Relationships, MergeRelationships(relationships...)...) -} - -func MergeRelationships(cs ...<-chan artifact.Relationship) (relationships []artifact.Relationship) { - for _, c := range cs { - for n := range c { - relationships = append(relationships, n) - } - } - - return relationships -} - -func ValidateOutputOptions(app *config.Application) error { - var usesTemplateOutput bool - for _, o := range app.Outputs { - if o == template.ID.String() { - usesTemplateOutput = true - break - } - } - - if usesTemplateOutput && app.OutputTemplatePath == "" { - return fmt.Errorf(`must specify path to template file when using "template" output format`) - } - - return nil -} diff --git a/cmd/syft/cli/poweruser.go b/cmd/syft/cli/poweruser.go deleted file mode 100644 index e3c935d9ea7..00000000000 --- a/cmd/syft/cli/poweruser.go +++ /dev/null @@ -1,51 +0,0 @@ -package cli - -import ( - "fmt" - - "github.com/spf13/cobra" - "github.com/spf13/viper" - - "github.com/anchore/syft/cmd/syft/cli/options" - "github.com/anchore/syft/cmd/syft/cli/poweruser" - "github.com/anchore/syft/internal" - "github.com/anchore/syft/internal/config" -) - -const powerUserExample = ` {{.appName}} {{.command}} - DEPRECATED - THIS COMMAND WILL BE REMOVED in v1.0.0 - Only image sources are supported (e.g. docker: , podman: , docker-archive: , oci: , etc.), the directory source (dir:) is not supported, template outputs are not supported. - All behavior is controlled via application configuration and environment variables (see https://github.com/anchore/syft#configuration) -` - -func PowerUser(v *viper.Viper, app *config.Application, ro *options.RootOptions) *cobra.Command { - cmd := &cobra.Command{ - Use: "power-user [IMAGE]", - Short: "Run bulk operations on container images", - Example: internal.Tprintf(powerUserExample, map[string]interface{}{ - "appName": internal.ApplicationName, - "command": "power-user", - }), - Args: func(cmd *cobra.Command, args []string) error { - if err := app.LoadAllValues(v, ro.Config); err != nil { - return fmt.Errorf("invalid application config: %w", err) - } - // configure logging for command - newLogWrapper(app) - logApplicationConfig(app) - return validateArgs(cmd, args) - }, - Hidden: true, - SilenceUsage: true, - SilenceErrors: true, - RunE: func(cmd *cobra.Command, args []string) error { - if app.CheckForAppUpdate { - checkForApplicationUpdate() - // TODO: this is broke, the bus isn't available yet - } - return poweruser.Run(cmd.Context(), app, args) - }, - } - - return cmd -} diff --git a/cmd/syft/cli/poweruser/poweruser.go b/cmd/syft/cli/poweruser/poweruser.go deleted file mode 100644 index cfc10e1bcc1..00000000000 --- a/cmd/syft/cli/poweruser/poweruser.go +++ /dev/null @@ -1,144 +0,0 @@ -package poweruser - -import ( - "context" - "fmt" - "os" - - "github.com/gookit/color" - "github.com/wagoodman/go-partybus" - - "github.com/anchore/stereoscope" - "github.com/anchore/stereoscope/pkg/image" - "github.com/anchore/syft/cmd/syft/cli/eventloop" - "github.com/anchore/syft/cmd/syft/cli/options" - "github.com/anchore/syft/cmd/syft/cli/packages" - "github.com/anchore/syft/cmd/syft/internal/ui" - "github.com/anchore/syft/internal" - "github.com/anchore/syft/internal/bus" - "github.com/anchore/syft/internal/config" - "github.com/anchore/syft/internal/version" - "github.com/anchore/syft/syft" - "github.com/anchore/syft/syft/artifact" - "github.com/anchore/syft/syft/formats/syftjson" - "github.com/anchore/syft/syft/sbom" - "github.com/anchore/syft/syft/source" -) - -func Run(_ context.Context, app *config.Application, args []string) error { - f := syftjson.Format() - writer, err := options.MakeSBOMWriterForFormat(f, app.File) - if err != nil { - return err - } - defer func() { - // inform user at end of run that command will be removed - deprecated := color.Style{color.Red, color.OpBold}.Sprint("DEPRECATED: This command will be removed in v1.0.0") - fmt.Fprintln(os.Stderr, deprecated) - }() - - userInput := args[0] - - eventBus := partybus.NewBus() - stereoscope.SetBus(eventBus) - syft.SetBus(eventBus) - subscription := eventBus.Subscribe() - - return eventloop.EventLoop( - execWorker(app, userInput, writer), - eventloop.SetupSignals(), - subscription, - stereoscope.Cleanup, - ui.Select(options.IsVerbose(app), app.Quiet)..., - ) -} - -//nolint:funlen -func execWorker(app *config.Application, userInput string, writer sbom.Writer) <-chan error { - errs := make(chan error) - go func() { - defer close(errs) - defer bus.Exit() - - app.Secrets.Cataloger.Enabled = true - app.FileMetadata.Cataloger.Enabled = true - app.FileContents.Cataloger.Enabled = true - app.FileClassification.Cataloger.Enabled = true - tasks, err := eventloop.Tasks(app) - if err != nil { - errs <- err - return - } - - detection, err := source.Detect( - userInput, - source.DetectConfig{ - DefaultImageSource: app.DefaultImagePullSource, - }, - ) - if err != nil { - errs <- fmt.Errorf("could not deteremine source: %w", err) - return - } - - var platform *image.Platform - - if app.Platform != "" { - platform, err = image.NewPlatform(app.Platform) - if err != nil { - errs <- fmt.Errorf("invalid platform: %w", err) - return - } - } - - src, err := detection.NewSource( - source.DetectionSourceConfig{ - Alias: source.Alias{ - Name: app.Source.Name, - Version: app.Source.Version, - }, - RegistryOptions: app.Registry.ToOptions(), - Platform: platform, - Exclude: source.ExcludeConfig{ - Paths: app.Exclusions, - }, - DigestAlgorithms: nil, - BasePath: app.BasePath, - }, - ) - - if src != nil { - defer src.Close() - } - if err != nil { - errs <- fmt.Errorf("failed to construct source from user input %q: %w", userInput, err) - return - } - - s := sbom.SBOM{ - Source: src.Describe(), - Descriptor: sbom.Descriptor{ - Name: internal.ApplicationName, - Version: version.FromBuild().Version, - Configuration: app, - }, - } - - var relationships []<-chan artifact.Relationship - for _, task := range tasks { - c := make(chan artifact.Relationship) - relationships = append(relationships, c) - - go eventloop.RunTask(task, &s.Artifacts, src, c, errs) - } - - s.Relationships = append(s.Relationships, packages.MergeRelationships(relationships...)...) - - if err := writer.Write(s); err != nil { - errs <- fmt.Errorf("failed to write sbom: %w", err) - return - } - }() - - return errs -} diff --git a/cmd/syft/cli/version.go b/cmd/syft/cli/version.go deleted file mode 100644 index 3235a813b36..00000000000 --- a/cmd/syft/cli/version.go +++ /dev/null @@ -1,72 +0,0 @@ -package cli - -import ( - "encoding/json" - "fmt" - "log" - "os" - - "github.com/spf13/cobra" - "github.com/spf13/viper" - - "github.com/anchore/syft/cmd/syft/cli/options" - "github.com/anchore/syft/internal" - "github.com/anchore/syft/internal/config" - "github.com/anchore/syft/internal/version" -) - -func Version(v *viper.Viper, _ *config.Application) *cobra.Command { - o := &options.VersionOptions{} - cmd := &cobra.Command{ - Use: "version", - Short: "show the version", - RunE: func(cmd *cobra.Command, args []string) error { - return printVersion(o.Output) - }, - } - - err := o.AddFlags(cmd, v) - if err != nil { - log.Fatal(err) - } - - return cmd -} - -func printVersion(output string) error { - versionInfo := version.FromBuild() - - switch output { - case "text": - fmt.Println("Application: ", internal.ApplicationName) - fmt.Println("Version: ", versionInfo.Version) - fmt.Println("JsonSchemaVersion: ", internal.JSONSchemaVersion) - fmt.Println("BuildDate: ", versionInfo.BuildDate) - fmt.Println("GitCommit: ", versionInfo.GitCommit) - fmt.Println("GitDescription: ", versionInfo.GitDescription) - fmt.Println("Platform: ", versionInfo.Platform) - fmt.Println("GoVersion: ", versionInfo.GoVersion) - fmt.Println("Compiler: ", versionInfo.Compiler) - - case "json": - enc := json.NewEncoder(os.Stdout) - enc.SetEscapeHTML(false) - enc.SetIndent("", " ") - err := enc.Encode(&struct { - version.Version - Application string `json:"application"` - }{ - Version: versionInfo, - Application: internal.ApplicationName, - }) - if err != nil { - fmt.Printf("failed to show version information: %+v\n", err) - os.Exit(1) - } - default: - fmt.Printf("unsupported output format: %s\n", output) - os.Exit(1) - } - - return nil -} diff --git a/cmd/syft/internal/constants.go b/cmd/syft/internal/constants.go new file mode 100644 index 00000000000..eedbdb0eeb2 --- /dev/null +++ b/cmd/syft/internal/constants.go @@ -0,0 +1,5 @@ +package internal + +const ( + NotProvided = "[not provided]" +) diff --git a/cmd/syft/internal/ui/no_ui.go b/cmd/syft/internal/ui/no_ui.go index 015ae821899..e8ad1324599 100644 --- a/cmd/syft/internal/ui/no_ui.go +++ b/cmd/syft/internal/ui/no_ui.go @@ -33,8 +33,6 @@ func (n *NoUI) Handle(e partybus.Event) error { case event.CLIReport, event.CLINotification: // keep these for when the UI is terminated to show to the screen (or perform other events) n.finalizeEvents = append(n.finalizeEvents, e) - case event.CLIExit: - return n.subscription.Unsubscribe() } return nil } diff --git a/cmd/syft/internal/ui/post_ui_event_writer.go b/cmd/syft/internal/ui/post_ui_event_writer.go index 22287d8ffdd..dcef5cb6c23 100644 --- a/cmd/syft/internal/ui/post_ui_event_writer.go +++ b/cmd/syft/internal/ui/post_ui_event_writer.go @@ -10,7 +10,6 @@ import ( "github.com/wagoodman/go-partybus" "github.com/anchore/syft/internal/log" - "github.com/anchore/syft/internal/version" "github.com/anchore/syft/syft/event" "github.com/anchore/syft/syft/event/parsers" ) @@ -119,13 +118,18 @@ func writeAppUpdate(writer io.Writer, events ...partybus.Event) error { style := lipgloss.NewStyle().Foreground(lipgloss.Color("13")).Italic(true) for _, e := range events { - newVersion, err := parsers.ParseCLIAppUpdateAvailable(e) + updateCheck, err := parsers.ParseCLIAppUpdateAvailable(e) if err != nil { log.WithFields("error", err).Warn("failed to parse app update notification") continue } - notice := fmt.Sprintf("A newer version of syft is available for download: %s (installed version is %s)", newVersion, version.FromBuild().Version) + if updateCheck.Current == updateCheck.New { + log.Tracef("update check event with identical versions: %s", updateCheck.Current) + continue + } + + notice := fmt.Sprintf("A newer version of syft is available for download: %s (installed version is %s)", updateCheck.New, updateCheck.Current) if _, err := fmt.Fprintln(writer, style.Render(notice)); err != nil { // don't let this be fatal diff --git a/cmd/syft/internal/ui/post_ui_event_writer_test.go b/cmd/syft/internal/ui/post_ui_event_writer_test.go index 83af4d2c528..45372e570db 100644 --- a/cmd/syft/internal/ui/post_ui_event_writer_test.go +++ b/cmd/syft/internal/ui/post_ui_event_writer_test.go @@ -9,6 +9,7 @@ import ( "github.com/wagoodman/go-partybus" "github.com/anchore/syft/syft/event" + "github.com/anchore/syft/syft/event/parsers" ) func Test_postUIEventWriter_write(t *testing.T) { @@ -34,8 +35,11 @@ func Test_postUIEventWriter_write(t *testing.T) { Value: "", }, { - Type: event.CLIAppUpdateAvailable, - Value: "v0.33.0", + Type: event.CLIAppUpdateAvailable, + Value: parsers.UpdateCheck{ + New: "v0.33.0", + Current: "[not provided]", + }, }, { Type: event.CLINotification, @@ -61,8 +65,11 @@ func Test_postUIEventWriter_write(t *testing.T) { Value: "", }, { - Type: event.CLIAppUpdateAvailable, - Value: "", + Type: event.CLIAppUpdateAvailable, + Value: parsers.UpdateCheck{ + New: "", + Current: "", + }, }, { Type: event.CLIReport, diff --git a/cmd/syft/internal/ui/ui.go b/cmd/syft/internal/ui/ui.go index 441470b7ce2..f66454f231b 100644 --- a/cmd/syft/internal/ui/ui.go +++ b/cmd/syft/internal/ui/ui.go @@ -49,31 +49,23 @@ func (m *UI) Setup(subscription partybus.Unsubscribable) error { } m.subscription = subscription - m.program = tea.NewProgram(m, tea.WithOutput(os.Stderr), tea.WithInput(os.Stdin)) + m.program = tea.NewProgram(m, tea.WithOutput(os.Stderr), tea.WithInput(os.Stdin), tea.WithoutSignalHandler()) m.running.Add(1) go func() { defer m.running.Done() if _, err := m.program.Run(); err != nil { log.Errorf("unable to start UI: %+v", err) - m.exit() + bus.ExitWithInterrupt() } }() return nil } -func (m *UI) exit() { - // stop the event loop - bus.Exit() -} - func (m *UI) Handle(e partybus.Event) error { if m.program != nil { m.program.Send(e) - if e.Type == event.CLIExit { - return m.subscription.Unsubscribe() - } } return nil } @@ -88,7 +80,9 @@ func (m *UI) Teardown(force bool) error { // string from the worker (outside of the UI after teardown). m.running.Wait() } else { - m.program.Kill() + // it may be tempting to use Kill() however it has been found that this can cause the terminal to be left in + // a bad state (where Ctrl+C and other control characters no longer works for future processes in that terminal). + m.program.Quit() } // TODO: allow for writing out the full log output to the screen (only a partial log is shown currently) @@ -107,7 +101,6 @@ func (m UI) RespondsTo() []partybus.EventType { return append([]partybus.EventType{ event.CLIReport, event.CLINotification, - event.CLIExit, event.CLIAppUpdateAvailable, }, m.handler.RespondsTo()...) } @@ -126,8 +119,10 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { + // today we treat esc and ctrl+c the same, but in the future when the syft worker has a graceful way to + // cancel in-flight work via a context, we can wire up esc to this path with bus.Exit() case "esc", "ctrl+c": - m.exit() + bus.ExitWithInterrupt() return m, tea.Quit } @@ -135,7 +130,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { log.WithFields("component", "ui").Tracef("event: %q", msg.Type) switch msg.Type { - case event.CLIReport, event.CLINotification, event.CLIExit, event.CLIAppUpdateAvailable: + case event.CLIReport, event.CLINotification, event.CLIAppUpdateAvailable: // keep these for when the UI is terminated to show to the screen (or perform other events) m.finalizeEvents = append(m.finalizeEvents, msg) diff --git a/cmd/syft/main.go b/cmd/syft/main.go index 1a4b9fb7ced..c9a13881a2b 100644 --- a/cmd/syft/main.go +++ b/cmd/syft/main.go @@ -1,20 +1,34 @@ package main import ( - "log" - _ "modernc.org/sqlite" + "github.com/anchore/clio" "github.com/anchore/syft/cmd/syft/cli" + "github.com/anchore/syft/cmd/syft/internal" +) + +// applicationName is the non-capitalized name of the application (do not change this) +const applicationName = "syft" + +// all variables here are provided as build-time arguments, with clear default values +var ( + version = internal.NotProvided + buildDate = internal.NotProvided + gitCommit = internal.NotProvided + gitDescription = internal.NotProvided ) func main() { - cli, err := cli.New() - if err != nil { - log.Fatalf("error during command construction: %v", err) - } + app := cli.New( + clio.Identification{ + Name: applicationName, + Version: version, + BuildDate: buildDate, + GitCommit: gitCommit, + GitDescription: gitDescription, + }, + ) - if err := cli.Execute(); err != nil { - log.Fatalf("error during command execution: %v", err) - } + app.Run() } diff --git a/go.mod b/go.mod index 100b1c7fd44..cb60f1ffd10 100644 --- a/go.mod +++ b/go.mod @@ -8,10 +8,10 @@ require ( github.com/Masterminds/sprig/v3 v3.2.3 github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d github.com/acobaugh/osrelease v0.1.0 - github.com/adrg/xdg v0.4.0 + github.com/adrg/xdg v0.4.0 // indirect github.com/anchore/bubbly v0.0.0-20230801194016-acdb4981b461 - github.com/anchore/clio v0.0.0-20230602170917-e747e60c4aa0 - github.com/anchore/go-logger v0.0.0-20230531193951-db5ae83e7dbe + github.com/anchore/clio v0.0.0-20230823172630-c42d666061af + github.com/anchore/go-logger v0.0.0-20230725134548-c21dafa1ec5a github.com/anchore/go-macholibre v0.0.0-20220308212642-53e6d0aaf6fb github.com/anchore/go-testutils v0.0.0-20200925183923-d5f45b0d3c04 github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b @@ -61,12 +61,12 @@ require ( // pinned to pull in 386 arch fix: https://github.com/scylladb/go-set/commit/cc7b2070d91ebf40d233207b633e28f5bd8f03a5 github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e github.com/sergi/go-diff v1.3.1 - github.com/sirupsen/logrus v1.9.3 + github.com/sirupsen/logrus v1.9.3 // indirect github.com/spdx/tools-golang v0.5.3 github.com/spf13/afero v1.9.5 github.com/spf13/cobra v1.7.0 - github.com/spf13/pflag v1.0.5 - github.com/spf13/viper v1.16.0 + github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/viper v1.16.0 // indirect github.com/stretchr/testify v1.8.4 github.com/vbatts/go-mtree v0.5.3 github.com/vifraa/gopom v1.0.0 @@ -79,7 +79,11 @@ require ( golang.org/x/mod v0.12.0 golang.org/x/net v0.14.0 golang.org/x/term v0.11.0 - gopkg.in/yaml.v2 v2.4.0 +) + +require ( + github.com/anchore/fangs v0.0.0-20230818131516-2186b10924fe + github.com/iancoleman/strcase v0.3.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.25.0 ) @@ -93,7 +97,6 @@ require ( github.com/Microsoft/go-winio v0.6.1 // indirect github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 // indirect github.com/acomagu/bufpipe v1.0.4 // indirect - github.com/anchore/fangs v0.0.0-20230531202914-48a718c6b4ba // indirect github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092 // indirect github.com/aquasecurity/go-version v0.0.0-20210121072130-637058cfe492 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect @@ -165,7 +168,6 @@ require ( github.com/skeema/knownhosts v1.2.0 // indirect github.com/spf13/cast v1.5.1 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect - github.com/stretchr/objx v0.5.0 // indirect github.com/subosito/gotenv v1.4.2 // indirect github.com/sylabs/sif/v2 v2.11.5 // indirect github.com/sylabs/squashfs v0.6.1 // indirect diff --git a/go.sum b/go.sum index 172e79e07a8..341d0747567 100644 --- a/go.sum +++ b/go.sum @@ -90,12 +90,12 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/anchore/bubbly v0.0.0-20230801194016-acdb4981b461 h1:xGu4/uMWucwWV0YV3fpFIQZ6KVfS/Wfhmma8t0s0vRo= github.com/anchore/bubbly v0.0.0-20230801194016-acdb4981b461/go.mod h1:Ger02eh5NpPm2IqkPAy396HU1KlK3BhOeCljDYXySSk= -github.com/anchore/clio v0.0.0-20230602170917-e747e60c4aa0 h1:g0UqRW60JDrf5fb40RUyIwwcfQ3nAJqGj4aUCVTwFE4= -github.com/anchore/clio v0.0.0-20230602170917-e747e60c4aa0/go.mod h1:0IQVIROfgRX4WZFMfgsbNZmMgLKqW/KgByyJDYvWiDE= -github.com/anchore/fangs v0.0.0-20230531202914-48a718c6b4ba h1:tJ186HK8e0Lf+hhNWX4fJrq14yj3mw8JQkkLhA0nFhE= -github.com/anchore/fangs v0.0.0-20230531202914-48a718c6b4ba/go.mod h1:E3zNHEz7mizIFGJhuX+Ga7AbCmEN5TfzVDxmOfj7XZw= -github.com/anchore/go-logger v0.0.0-20230531193951-db5ae83e7dbe h1:Df867YMmymdMG6z5IW8pR0/2CRpLIjYnaTXLp6j+s0k= -github.com/anchore/go-logger v0.0.0-20230531193951-db5ae83e7dbe/go.mod h1:ubLFmlsv8/DFUQrZwY5syT5/8Er3ugSr4rDFwHsE3hg= +github.com/anchore/clio v0.0.0-20230823172630-c42d666061af h1:dBVKZyMZeA0oZK0+aCCRoqxhxUvx/7xy/VEaLMMMnb0= +github.com/anchore/clio v0.0.0-20230823172630-c42d666061af/go.mod h1:XryJ3CIF1T7SbacQV+OPykfKKIbfXnBssYfpjy2peUg= +github.com/anchore/fangs v0.0.0-20230818131516-2186b10924fe h1:pVpLCGWdNeskAw7vGNdCAcGMezrNljHIqOc9HaOja5M= +github.com/anchore/fangs v0.0.0-20230818131516-2186b10924fe/go.mod h1:82EGoxZTfBXSW0/zollEP+Qs3wkiKmip5yBT5j+eZpY= +github.com/anchore/go-logger v0.0.0-20230725134548-c21dafa1ec5a h1:nJ2G8zWKASyVClGVgG7sfM5mwoZlZ2zYpIzN2OhjWkw= +github.com/anchore/go-logger v0.0.0-20230725134548-c21dafa1ec5a/go.mod h1:ubLFmlsv8/DFUQrZwY5syT5/8Er3ugSr4rDFwHsE3hg= github.com/anchore/go-macholibre v0.0.0-20220308212642-53e6d0aaf6fb h1:iDMnx6LIjtjZ46C0akqveX83WFzhpTD3eqOthawb5vU= github.com/anchore/go-macholibre v0.0.0-20220308212642-53e6d0aaf6fb/go.mod h1:DmTY2Mfcv38hsHbG78xMiTDdxFtkHpgYNVDPsF2TgHk= github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092 h1:aM1rlcoLz8y5B2r4tTLMiVTrMtpfY0O8EScKJxaSaEc= @@ -406,6 +406,8 @@ github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0 h1:i462o439ZjprVSFSZLZxcsoAe592sZB1rci2Z8j4wdk= github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0/go.mod h1:N0Wam8K1arqPXNWjMo21EXnBPOPp36vB07FNRdD2geA= github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= +github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= @@ -1226,7 +1228,6 @@ gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/bus/helpers.go b/internal/bus/helpers.go index 5efacfb358b..b2c883ebdaa 100644 --- a/internal/bus/helpers.go +++ b/internal/bus/helpers.go @@ -3,21 +3,24 @@ package bus import ( "github.com/wagoodman/go-partybus" - "github.com/anchore/syft/internal/log" + "github.com/anchore/clio" + "github.com/anchore/syft/internal/redact" "github.com/anchore/syft/syft/event" ) func Exit() { - Publish(partybus.Event{ - Type: event.CLIExit, - }) + Publish(clio.ExitEvent(false)) +} + +func ExitWithInterrupt() { + Publish(clio.ExitEvent(true)) } func Report(report string) { if len(report) == 0 { return } - report = log.Redactor.RedactString(report) + report = redact.Apply(report) Publish(partybus.Event{ Type: event.CLIReport, Value: report, diff --git a/internal/config/application.go b/internal/config/application.go deleted file mode 100644 index e7d6539fea4..00000000000 --- a/internal/config/application.go +++ /dev/null @@ -1,337 +0,0 @@ -package config - -import ( - "errors" - "fmt" - "os" - "path" - "reflect" - "sort" - "strings" - - "github.com/adrg/xdg" - "github.com/mitchellh/go-homedir" - "github.com/spf13/viper" - "gopkg.in/yaml.v2" - - "github.com/anchore/go-logger" - "github.com/anchore/syft/internal" - "github.com/anchore/syft/internal/log" - "github.com/anchore/syft/syft/pkg/cataloger" - golangCataloger "github.com/anchore/syft/syft/pkg/cataloger/golang" - "github.com/anchore/syft/syft/pkg/cataloger/kernel" - pythonCataloger "github.com/anchore/syft/syft/pkg/cataloger/python" -) - -var ( - ErrApplicationConfigNotFound = fmt.Errorf("application config not found") - catalogerEnabledDefault = false -) - -type defaultValueLoader interface { - loadDefaultValues(*viper.Viper) -} - -type parser interface { - parseConfigValues() error -} - -// Application is the main syft application configuration. -type Application struct { - // the location where the application config was read from (either from -c or discovered while loading); default .syft.yaml - ConfigPath string `yaml:"configPath,omitempty" json:"configPath" mapstructure:"config"` - Verbosity uint `yaml:"verbosity,omitempty" json:"verbosity" mapstructure:"verbosity"` - // -q, indicates to not show any status output to stderr (ETUI or logging UI) - Quiet bool `yaml:"quiet" json:"quiet" mapstructure:"quiet"` - Outputs []string `yaml:"output" json:"output" mapstructure:"output"` // -o, the format to use for output - OutputTemplatePath string `yaml:"output-template-path" json:"output-template-path" mapstructure:"output-template-path"` // -t template file to use for output - File string `yaml:"file" json:"file" mapstructure:"file"` // --file, the file to write report output to - CheckForAppUpdate bool `yaml:"check-for-app-update" json:"check-for-app-update" mapstructure:"check-for-app-update"` // whether to check for an application update on start up or not - Dev development `yaml:"dev" json:"dev" mapstructure:"dev"` - Log logging `yaml:"log" json:"log" mapstructure:"log"` // all logging-related options - Catalogers []string `yaml:"catalogers" json:"catalogers" mapstructure:"catalogers"` - Package pkg `yaml:"package" json:"package" mapstructure:"package"` - Golang golang `yaml:"golang" json:"golang" mapstructure:"golang"` - LinuxKernel linuxKernel `yaml:"linux-kernel" json:"linux-kernel" mapstructure:"linux-kernel"` - Python python `yaml:"python" json:"python" mapstructure:"python"` - Attest attest `yaml:"attest" json:"attest" mapstructure:"attest"` - FileMetadata FileMetadata `yaml:"file-metadata" json:"file-metadata" mapstructure:"file-metadata"` - FileClassification fileClassification `yaml:"file-classification" json:"file-classification" mapstructure:"file-classification"` - FileContents fileContents `yaml:"file-contents" json:"file-contents" mapstructure:"file-contents"` - Secrets secrets `yaml:"secrets" json:"secrets" mapstructure:"secrets"` - Registry registry `yaml:"registry" json:"registry" mapstructure:"registry"` - Exclusions []string `yaml:"exclude" json:"exclude" mapstructure:"exclude"` - Platform string `yaml:"platform" json:"platform" mapstructure:"platform"` - Name string `yaml:"name" json:"name" mapstructure:"name"` - Source sourceCfg `yaml:"source" json:"source" mapstructure:"source"` - Parallelism int `yaml:"parallelism" json:"parallelism" mapstructure:"parallelism"` // the number of catalog workers to run in parallel - DefaultImagePullSource string `yaml:"default-image-pull-source" json:"default-image-pull-source" mapstructure:"default-image-pull-source"` // specify default image pull source - BasePath string `yaml:"base-path" json:"base-path" mapstructure:"base-path"` // specify base path for all file paths - ExcludeBinaryOverlapByOwnership bool `yaml:"exclude-binary-overlap-by-ownership" json:"exclude-binary-overlap-by-ownership" mapstructure:"exclude-binary-overlap-by-ownership"` // exclude synthetic binary packages owned by os package files -} - -func (cfg Application) ToCatalogerConfig() cataloger.Config { - return cataloger.Config{ - Search: cataloger.SearchConfig{ - IncludeIndexedArchives: cfg.Package.SearchIndexedArchives, - IncludeUnindexedArchives: cfg.Package.SearchUnindexedArchives, - Scope: cfg.Package.Cataloger.ScopeOpt, - }, - Catalogers: cfg.Catalogers, - Parallelism: cfg.Parallelism, - ExcludeBinaryOverlapByOwnership: cfg.ExcludeBinaryOverlapByOwnership, - Golang: golangCataloger.NewGoCatalogerOpts(). - WithSearchLocalModCacheLicenses(cfg.Golang.SearchLocalModCacheLicenses). - WithLocalModCacheDir(cfg.Golang.LocalModCacheDir). - WithSearchRemoteLicenses(cfg.Golang.SearchRemoteLicenses). - WithProxy(cfg.Golang.Proxy). - WithNoProxy(cfg.Golang.NoProxy), - LinuxKernel: kernel.LinuxCatalogerConfig{ - CatalogModules: cfg.LinuxKernel.CatalogModules, - }, - Python: pythonCataloger.CatalogerConfig{ - GuessUnpinnedRequirements: cfg.Python.GuessUnpinnedRequirements, - }, - } -} - -func (cfg *Application) LoadAllValues(v *viper.Viper, configPath string) error { - // priority order: viper.Set, flag, env, config, kv, defaults - // flags have already been loaded into viper by command construction - - // check if user specified config; otherwise read all possible paths - if err := loadConfig(v, configPath); err != nil { - var notFound *viper.ConfigFileNotFoundError - if errors.As(err, ¬Found) { - log.Debugf("no config file found, using defaults") - } else { - return fmt.Errorf("unable to load config: %w", err) - } - } - - // load default config values into viper - loadDefaultValues(v) - - // load environment variables - v.SetEnvPrefix(internal.ApplicationName) - v.AllowEmptyEnv(true) - v.AutomaticEnv() - - // unmarshal fully populated viper object onto config - err := v.Unmarshal(cfg) - if err != nil { - return err - } - - // Convert all populated config options to their internal application values ex: scope string => scopeOpt source.Scope - return cfg.parseConfigValues() -} - -func (cfg *Application) parseConfigValues() error { - // parse options on this struct - var catalogers []string - for _, c := range cfg.Catalogers { - for _, f := range strings.Split(c, ",") { - catalogers = append(catalogers, strings.TrimSpace(f)) - } - } - sort.Strings(catalogers) - cfg.Catalogers = catalogers - - // parse application config options - for _, optionFn := range []func() error{ - cfg.parseLogLevelOption, - cfg.parseFile, - } { - if err := optionFn(); err != nil { - return err - } - } - - if err := checkDefaultSourceValues(cfg.DefaultImagePullSource); err != nil { - return err - } - - if cfg.Name != "" { - log.Warnf("name parameter is deprecated. please use: source-name. name will be removed in a future version") - if cfg.Source.Name == "" { - cfg.Source.Name = cfg.Name - } - } - - // check for valid default source options - // parse nested config options - // for each field in the configuration struct, see if the field implements the parser interface - // note: the app config is a pointer, so we need to grab the elements explicitly (to traverse the address) - value := reflect.ValueOf(cfg).Elem() - for i := 0; i < value.NumField(); i++ { - // note: since the interface method of parser is a pointer receiver we need to get the value of the field as a pointer. - if parsable, ok := value.Field(i).Addr().Interface().(parser); ok { - // the field implements parser, call it - if err := parsable.parseConfigValues(); err != nil { - return err - } - } - } - return nil -} - -func (cfg *Application) parseLogLevelOption() error { - switch { - case cfg.Quiet: - // TODO: this is bad: quiet option trumps all other logging options (such as to a file on disk) - // we should be able to quiet the console logging and leave file logging alone... - // ... this will be an enhancement for later - cfg.Log.Level = logger.DisabledLevel - - case cfg.Verbosity > 0: - cfg.Log.Level = logger.LevelFromVerbosity(int(cfg.Verbosity), logger.WarnLevel, logger.InfoLevel, logger.DebugLevel, logger.TraceLevel) - - case cfg.Log.Level != "": - var err error - cfg.Log.Level, err = logger.LevelFromString(string(cfg.Log.Level)) - if err != nil { - return err - } - - if logger.IsVerbose(cfg.Log.Level) { - cfg.Verbosity = 1 - } - default: - cfg.Log.Level = logger.WarnLevel - } - - return nil -} - -func (cfg *Application) parseFile() error { - if cfg.File != "" { - expandedPath, err := homedir.Expand(cfg.File) - if err != nil { - return fmt.Errorf("unable to expand file path=%q: %w", cfg.File, err) - } - cfg.File = expandedPath - } - return nil -} - -// init loads the default configuration values into the viper instance (before the config values are read and parsed). -func loadDefaultValues(v *viper.Viper) { - // set the default values for primitive fields in this struct - v.SetDefault("quiet", false) - v.SetDefault("check-for-app-update", true) - v.SetDefault("catalogers", nil) - v.SetDefault("parallelism", 1) - v.SetDefault("default-image-pull-source", "") - v.SetDefault("exclude-binary-overlap-by-ownership", true) - - // for each field in the configuration struct, see if the field implements the defaultValueLoader interface and invoke it if it does - value := reflect.ValueOf(Application{}) - for i := 0; i < value.NumField(); i++ { - // note: the defaultValueLoader method receiver is NOT a pointer receiver. - if loadable, ok := value.Field(i).Interface().(defaultValueLoader); ok { - // the field implements defaultValueLoader, call it - loadable.loadDefaultValues(v) - } - } -} - -func (cfg Application) String() string { - // yaml is pretty human friendly (at least when compared to json) - appaStr, err := yaml.Marshal(&cfg) - - if err != nil { - return err.Error() - } - - return string(appaStr) -} - -// nolint:funlen -func loadConfig(v *viper.Viper, configPath string) error { - var err error - // use explicitly the given user config - if configPath != "" { - v.SetConfigFile(configPath) - if err := v.ReadInConfig(); err != nil { - return fmt.Errorf("unable to read application config=%q : %w", configPath, err) - } - v.Set("config", v.ConfigFileUsed()) - // don't fall through to other options if the config path was explicitly provided - return nil - } - - // start searching for valid configs in order... - // 1. look for ..yaml (in the current directory) - confFilePath := "." + internal.ApplicationName - - // TODO: Remove this before v1.0.0 - // See syft #1634 - v.AddConfigPath(".") - v.SetConfigName(confFilePath) - - // check if config.yaml exists in the current directory - // DEPRECATED: this will be removed in v1.0.0 - if _, err := os.Stat("config.yaml"); err == nil { - log.Warn("DEPRECATED: ./config.yaml as a configuration file is deprecated and will be removed as an option in v1.0.0, please rename to .syft.yaml") - } - - if _, err := os.Stat(confFilePath + ".yaml"); err == nil { - if err = v.ReadInConfig(); err == nil { - v.Set("config", v.ConfigFileUsed()) - return nil - } else if !errors.As(err, &viper.ConfigFileNotFoundError{}) { - return fmt.Errorf("unable to parse config=%q: %w", v.ConfigFileUsed(), err) - } - } - - // 2. look for ./config.yaml (in the current directory) - v.AddConfigPath("." + internal.ApplicationName) - v.SetConfigName("config") - if err = v.ReadInConfig(); err == nil { - v.Set("config", v.ConfigFileUsed()) - return nil - } else if !errors.As(err, &viper.ConfigFileNotFoundError{}) { - return fmt.Errorf("unable to parse config=%q: %w", v.ConfigFileUsed(), err) - } - - // 3. look for ~/..yaml - home, err := homedir.Dir() - if err == nil { - v.AddConfigPath(home) - v.SetConfigName("." + internal.ApplicationName) - if err = v.ReadInConfig(); err == nil { - v.Set("config", v.ConfigFileUsed()) - return nil - } else if !errors.As(err, &viper.ConfigFileNotFoundError{}) { - return fmt.Errorf("unable to parse config=%q: %w", v.ConfigFileUsed(), err) - } - } - - // 4. look for /config.yaml in xdg locations (starting with xdg home config dir, then moving upwards) - v.SetConfigName("config") - configPath = path.Join(xdg.ConfigHome, internal.ApplicationName) - v.AddConfigPath(configPath) - for _, dir := range xdg.ConfigDirs { - v.AddConfigPath(path.Join(dir, internal.ApplicationName)) - } - if err = v.ReadInConfig(); err == nil { - v.Set("config", v.ConfigFileUsed()) - return nil - } else if !errors.As(err, &viper.ConfigFileNotFoundError{}) { - return fmt.Errorf("unable to parse config=%q: %w", v.ConfigFileUsed(), err) - } - return nil -} - -var validDefaultSourceValues = []string{"registry", "docker", "podman", ""} - -func checkDefaultSourceValues(source string) error { - validValues := internal.NewStringSet(validDefaultSourceValues...) - if !validValues.Contains(source) { - validValuesString := strings.Join(validDefaultSourceValues, ", ") - return fmt.Errorf("%s is not a valid default source; please use one of the following: %s''", source, validValuesString) - } - - return nil -} diff --git a/internal/config/application_test.go b/internal/config/application_test.go deleted file mode 100644 index eac0280dea6..00000000000 --- a/internal/config/application_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package config - -import ( - "fmt" - "os" - "path" - "testing" - - "github.com/adrg/xdg" - "github.com/mitchellh/go-homedir" - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TODO: set negative case when config.yaml is no longer a valid option -func TestApplicationConfig(t *testing.T) { - // disable homedir package cache for testing - originalCacheOpt := homedir.DisableCache - homedir.DisableCache = true - t.Cleanup(func() { - homedir.DisableCache = originalCacheOpt - }) - - // ensure we have no side effects for xdg package for future tests - originalXDG := os.Getenv("XDG_CONFIG_HOME") - t.Cleanup(func() { - // note: we're not using t.Setenv since the effect we're trying to eliminate is within the xdg package - require.NoError(t, os.Setenv("XDG_CONFIG_HOME", originalXDG)) - xdg.Reload() - }) - - // config is picked up at desired configuration paths - // VALID: .syft.yaml, .syft/config.yaml, ~/.syft.yaml, /syft/config.yaml - // DEPRECATED: config.yaml is currently supported by - tests := []struct { - name string - setup func(t *testing.T) string - assertions func(t *testing.T, app *Application) - cleanup func() - }{ - { - name: "explicit config", - setup: func(t *testing.T) string { - return "./test-fixtures/.syft.yaml" - }, // no-op for explicit config - assertions: func(t *testing.T, app *Application) { - assert.Equal(t, "test-explicit-config", app.File) - }, - }, - { - name: "current working directory named config", - setup: func(t *testing.T) string { - err := os.Chdir("./test-fixtures/config-wd-file") // change application cwd to test-fixtures - require.NoError(t, err) - return "" - }, - assertions: func(t *testing.T, app *Application) { - assert.Equal(t, "test-wd-named-config", app.File) - }, - }, - { - name: "current working directory syft dir config", - setup: func(t *testing.T) string { - err := os.Chdir("./test-fixtures/config-dir-test") // change application cwd to test-fixtures - require.NoError(t, err) - return "" - }, - assertions: func(t *testing.T, app *Application) { - assert.Equal(t, "test-dir-config", app.File) - }, - }, - { - name: "home directory file config", - setup: func(t *testing.T) string { - // Because Setenv affects the whole process, it cannot be used in parallel tests or - // tests with parallel ancestors: see separate XDG test for consequence of this - t.Setenv("HOME", "./test-fixtures/config-home-test/config-file") - return "" - }, - assertions: func(t *testing.T, app *Application) { - assert.Equal(t, "test-home-config", app.File) - }, - }, - { - name: "XDG file config", - setup: func(t *testing.T) string { - wd, err := os.Getwd() - require.NoError(t, err) - configDir := path.Join(wd, "./test-fixtures/config-home-test") // set HOME to testdata - // note: this explicitly has multiple XDG paths, make certain we use the first VALID one (not the first one) - t.Setenv("XDG_CONFIG_DIRS", fmt.Sprintf("/another/foo/bar:%s", configDir)) - xdg.Reload() - return "" - }, - assertions: func(t *testing.T, app *Application) { - assert.Equal(t, "test-home-XDG-config", app.File) - }, - cleanup: func() { - require.NoError(t, os.Unsetenv("XDG_CONFIG_DIRS")) - xdg.Reload() - }, - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - if test.cleanup != nil { - t.Cleanup(test.cleanup) - } - wd, err := os.Getwd() - require.NoError(t, err) - - defer os.Chdir(wd) // reset working directory after test - application := &Application{} - viperInstance := viper.New() - - // this will override home in case you are running this test locally and DO have a syft config - // in your home directory... now it will be ignored. Same for XDG_CONFIG_DIRS. - t.Setenv("HOME", "/foo/bar") - t.Setenv("XDG_CONFIG_DIRS", "/foo/bar") - xdg.Reload() - - configPath := test.setup(t) - err = application.LoadAllValues(viperInstance, configPath) - require.NoError(t, err) - test.assertions(t, application) - }) - } -} diff --git a/internal/config/attest.go b/internal/config/attest.go deleted file mode 100644 index 659c7d3ed7f..00000000000 --- a/internal/config/attest.go +++ /dev/null @@ -1,14 +0,0 @@ -package config - -import "github.com/spf13/viper" - -type attest struct { - // IMPORTANT: do not show the attestation key/password in any YAML/JSON output (sensitive information) - Key string `yaml:"-" json:"-" mapstructure:"key"` - Password string `yaml:"-" json:"-" mapstructure:"password"` -} - -func (cfg attest) loadDefaultValues(v *viper.Viper) { - v.SetDefault("attest.key", "") - v.SetDefault("attest.password", "") -} diff --git a/internal/config/cataloger_options.go b/internal/config/cataloger_options.go deleted file mode 100644 index 1fb2992b44c..00000000000 --- a/internal/config/cataloger_options.go +++ /dev/null @@ -1,29 +0,0 @@ -package config - -import ( - "fmt" - - "github.com/spf13/viper" - - "github.com/anchore/syft/syft/source" -) - -type catalogerOptions struct { - Enabled bool `yaml:"enabled" json:"enabled" mapstructure:"enabled"` - Scope string `yaml:"scope" json:"scope" mapstructure:"scope"` - ScopeOpt source.Scope `yaml:"-" json:"-"` -} - -func (cfg catalogerOptions) loadDefaultValues(v *viper.Viper) { - v.SetDefault("package.cataloger.enabled", true) -} - -func (cfg *catalogerOptions) parseConfigValues() error { - scopeOption := source.ParseScope(cfg.Scope) - if scopeOption == source.UnknownScope { - return fmt.Errorf("bad scope value %q", cfg.Scope) - } - cfg.ScopeOpt = scopeOption - - return nil -} diff --git a/internal/config/development.go b/internal/config/development.go deleted file mode 100644 index 4e1e8b01af8..00000000000 --- a/internal/config/development.go +++ /dev/null @@ -1,13 +0,0 @@ -package config - -import "github.com/spf13/viper" - -type development struct { - ProfileCPU bool `yaml:"profile-cpu" json:"profile-cpu" mapstructure:"profile-cpu"` - ProfileMem bool `yaml:"profile-mem" json:"profile-mem" mapstructure:"profile-mem"` -} - -func (cfg development) loadDefaultValues(v *viper.Viper) { - v.SetDefault("dev.profile-cpu", false) - v.SetDefault("dev.profile-mem", false) -} diff --git a/internal/config/file_classification.go b/internal/config/file_classification.go deleted file mode 100644 index d78812705b6..00000000000 --- a/internal/config/file_classification.go +++ /dev/null @@ -1,20 +0,0 @@ -package config - -import ( - "github.com/spf13/viper" - - "github.com/anchore/syft/syft/source" -) - -type fileClassification struct { - Cataloger catalogerOptions `yaml:"cataloger" json:"cataloger" mapstructure:"cataloger"` -} - -func (cfg fileClassification) loadDefaultValues(v *viper.Viper) { - v.SetDefault("file-classification.cataloger.enabled", catalogerEnabledDefault) - v.SetDefault("file-classification.cataloger.scope", source.SquashedScope) -} - -func (cfg *fileClassification) parseConfigValues() error { - return cfg.Cataloger.parseConfigValues() -} diff --git a/internal/config/file_contents.go b/internal/config/file_contents.go deleted file mode 100644 index 18a404f5b61..00000000000 --- a/internal/config/file_contents.go +++ /dev/null @@ -1,25 +0,0 @@ -package config - -import ( - "github.com/spf13/viper" - - "github.com/anchore/syft/internal/file" - "github.com/anchore/syft/syft/source" -) - -type fileContents struct { - Cataloger catalogerOptions `yaml:"cataloger" json:"cataloger" mapstructure:"cataloger"` - SkipFilesAboveSize int64 `yaml:"skip-files-above-size" json:"skip-files-above-size" mapstructure:"skip-files-above-size"` - Globs []string `yaml:"globs" json:"globs" mapstructure:"globs"` -} - -func (cfg fileContents) loadDefaultValues(v *viper.Viper) { - v.SetDefault("file-contents.cataloger.enabled", catalogerEnabledDefault) - v.SetDefault("file-contents.cataloger.scope", source.SquashedScope) - v.SetDefault("file-contents.skip-files-above-size", 1*file.MB) - v.SetDefault("file-contents.globs", []string{}) -} - -func (cfg *fileContents) parseConfigValues() error { - return cfg.Cataloger.parseConfigValues() -} diff --git a/internal/config/file_metadata.go b/internal/config/file_metadata.go deleted file mode 100644 index 5339f29d77f..00000000000 --- a/internal/config/file_metadata.go +++ /dev/null @@ -1,22 +0,0 @@ -package config - -import ( - "github.com/spf13/viper" - - "github.com/anchore/syft/syft/source" -) - -type FileMetadata struct { - Cataloger catalogerOptions `yaml:"cataloger" json:"cataloger" mapstructure:"cataloger"` - Digests []string `yaml:"digests" json:"digests" mapstructure:"digests"` -} - -func (cfg FileMetadata) loadDefaultValues(v *viper.Viper) { - v.SetDefault("file-metadata.cataloger.enabled", catalogerEnabledDefault) - v.SetDefault("file-metadata.cataloger.scope", source.SquashedScope) - v.SetDefault("file-metadata.digests", []string{"sha256"}) -} - -func (cfg *FileMetadata) parseConfigValues() error { - return cfg.Cataloger.parseConfigValues() -} diff --git a/internal/config/linux_kernel.go b/internal/config/linux_kernel.go deleted file mode 100644 index 1d0b38e9628..00000000000 --- a/internal/config/linux_kernel.go +++ /dev/null @@ -1,11 +0,0 @@ -package config - -import "github.com/spf13/viper" - -type linuxKernel struct { - CatalogModules bool `json:"catalog-modules" yaml:"catalog-modules" mapstructure:"catalog-modules"` -} - -func (cfg linuxKernel) loadDefaultValues(v *viper.Viper) { - v.SetDefault("linux-kernel.catalog-modules", true) -} diff --git a/internal/config/logging.go b/internal/config/logging.go deleted file mode 100644 index 8c0b43c9332..00000000000 --- a/internal/config/logging.go +++ /dev/null @@ -1,34 +0,0 @@ -package config - -import ( - "fmt" - - "github.com/mitchellh/go-homedir" - "github.com/spf13/viper" - - "github.com/anchore/go-logger" -) - -// logging contains all logging-related configuration options available to the user via the application config. -type logging struct { - Structured bool `yaml:"structured" json:"structured" mapstructure:"structured"` // show all log entries as JSON formatted strings - Level logger.Level `yaml:"level" json:"level" mapstructure:"level"` // the log level string hint - FileLocation string `yaml:"file" json:"file-location" mapstructure:"file"` // the file path to write logs to -} - -func (cfg *logging) parseConfigValues() error { - if cfg.FileLocation != "" { - expandedPath, err := homedir.Expand(cfg.FileLocation) - if err != nil { - return fmt.Errorf("unable to expand log file path=%q: %w", cfg.FileLocation, err) - } - cfg.FileLocation = expandedPath - } - return nil -} - -func (cfg logging) loadDefaultValues(v *viper.Viper) { - v.SetDefault("log.structured", false) - v.SetDefault("log.file", "") - v.SetDefault("log.level", string(logger.WarnLevel)) -} diff --git a/internal/config/pkg.go b/internal/config/pkg.go deleted file mode 100644 index 21e26a59bab..00000000000 --- a/internal/config/pkg.go +++ /dev/null @@ -1,24 +0,0 @@ -package config - -import ( - "github.com/spf13/viper" - - "github.com/anchore/syft/syft/pkg/cataloger" -) - -type pkg struct { - Cataloger catalogerOptions `yaml:"cataloger" json:"cataloger" mapstructure:"cataloger"` - SearchUnindexedArchives bool `yaml:"search-unindexed-archives" json:"search-unindexed-archives" mapstructure:"search-unindexed-archives"` - SearchIndexedArchives bool `yaml:"search-indexed-archives" json:"search-indexed-archives" mapstructure:"search-indexed-archives"` -} - -func (cfg pkg) loadDefaultValues(v *viper.Viper) { - cfg.Cataloger.loadDefaultValues(v) - c := cataloger.DefaultSearchConfig() - v.SetDefault("package.search-unindexed-archives", c.IncludeUnindexedArchives) - v.SetDefault("package.search-indexed-archives", c.IncludeIndexedArchives) -} - -func (cfg *pkg) parseConfigValues() error { - return cfg.Cataloger.parseConfigValues() -} diff --git a/internal/config/test-fixtures/.syft.yaml b/internal/config/test-fixtures/.syft.yaml deleted file mode 100644 index 9da17f95b54..00000000000 --- a/internal/config/test-fixtures/.syft.yaml +++ /dev/null @@ -1,7 +0,0 @@ -# same as --file; write output report to a file (default is to write to stdout) -file: "test-explicit-config" -package: - cataloger: - scope: "squashed" - - # same as --scope; limit the scope of the cataloger to only the specified types \ No newline at end of file diff --git a/internal/config/test-fixtures/config-dir-test/.syft/config.yaml b/internal/config/test-fixtures/config-dir-test/.syft/config.yaml deleted file mode 100644 index 0122d78dc01..00000000000 --- a/internal/config/test-fixtures/config-dir-test/.syft/config.yaml +++ /dev/null @@ -1,5 +0,0 @@ -# same as --file; write output report to a file (default is to write to stdout) -file: "test-dir-config" -package: - cataloger: - scope: "squashed" \ No newline at end of file diff --git a/internal/config/test-fixtures/config-home-test/config-file/.syft.yaml b/internal/config/test-fixtures/config-home-test/config-file/.syft.yaml deleted file mode 100644 index 04b886ba5f4..00000000000 --- a/internal/config/test-fixtures/config-home-test/config-file/.syft.yaml +++ /dev/null @@ -1,5 +0,0 @@ -# same as --file; write output report to a file (default is to write to stdout) -file: "test-home-config" -package: - cataloger: - scope: "squashed" \ No newline at end of file diff --git a/internal/config/test-fixtures/config-home-test/syft/config.yaml b/internal/config/test-fixtures/config-home-test/syft/config.yaml deleted file mode 100644 index 3c64be119ec..00000000000 --- a/internal/config/test-fixtures/config-home-test/syft/config.yaml +++ /dev/null @@ -1,5 +0,0 @@ -# same as --file; write output report to a file (default is to write to stdout) -file: "test-home-XDG-config" -package: - cataloger: - scope: "squashed" \ No newline at end of file diff --git a/internal/config/test-fixtures/config-wd-file/.syft.yaml b/internal/config/test-fixtures/config-wd-file/.syft.yaml deleted file mode 100644 index 6971cbee52f..00000000000 --- a/internal/config/test-fixtures/config-wd-file/.syft.yaml +++ /dev/null @@ -1,5 +0,0 @@ -# same as --file; write output report to a file (default is to write to stdout) -file: "test-wd-named-config" -package: - cataloger: - scope: "squashed" \ No newline at end of file diff --git a/internal/constants.go b/internal/constants.go index 259b2d77c8f..6903ef00d6a 100644 --- a/internal/constants.go +++ b/internal/constants.go @@ -1,9 +1,6 @@ package internal const ( - // ApplicationName is the non-capitalized name of the application (do not change this) - ApplicationName = "syft" - // JSONSchemaVersion is the current schema version output by the JSON encoder // This is roughly following the "SchemaVer" guidelines for versioning the JSON schema. Please see schema/json/README.md for details on how to increment. JSONSchemaVersion = "10.0.1" diff --git a/internal/log/log.go b/internal/log/log.go index 242c5f0b7bb..5c557ee0d1e 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -7,29 +7,28 @@ import ( "github.com/anchore/go-logger" "github.com/anchore/go-logger/adapter/discard" "github.com/anchore/go-logger/adapter/redact" + red "github.com/anchore/syft/internal/redact" ) -var ( - // log is the singleton used to facilitate logging internally within - log = discard.New() - - store = redact.NewStore() - - Redactor = store.(redact.Redactor) -) +// log is the singleton used to facilitate logging internally within +var log = discard.New() func Set(l logger.Logger) { - log = redact.New(l, store) + // though the application will automatically have a redaction logger, library consumers may not be doing this. + // for this reason we additionally ensure there is a redaction logger configured for any logger passed. The + // source of truth for redaction values is still in the internal redact package. If the passed logger is already + // redacted, then this is a no-op. + store := red.Get() + if store != nil { + l = redact.New(l, store) + } + log = l } func Get() logger.Logger { return log } -func Redact(values ...string) { - store.Add(values...) -} - // Errorf takes a formatted template string and template arguments for the error logging level. func Errorf(format string, args ...interface{}) { log.Errorf(format, args...) diff --git a/internal/redact/redact.go b/internal/redact/redact.go new file mode 100644 index 00000000000..3bb76e2694d --- /dev/null +++ b/internal/redact/redact.go @@ -0,0 +1,36 @@ +package redact + +import "github.com/anchore/go-logger/adapter/redact" + +var store redact.Store + +func Set(s redact.Store) { + if store != nil { + // if someone is trying to set a redaction store and we already have one then something is wrong. The store + // that we're replacing might already have values in it, so we should never replace it. + panic("replace existing redaction store (probably unintentional)") + } + store = s +} + +func Get() redact.Store { + return store +} + +func Add(vs ...string) { + if store == nil { + // if someone is trying to add values that should never be output and we don't have a store then something is wrong. + // we should never accidentally output values that should be redacted, thus we panic here. + panic("cannot add redactions without a store") + } + store.Add(vs...) +} + +func Apply(value string) string { + if store == nil { + // if someone is trying to add values that should never be output and we don't have a store then something is wrong. + // we should never accidentally output values that should be redacted, thus we panic here. + panic("cannot apply redactions without a store") + } + return store.RedactString(value) +} diff --git a/internal/version/build.go b/internal/version/build.go deleted file mode 100644 index a0d5fc1d2e3..00000000000 --- a/internal/version/build.go +++ /dev/null @@ -1,54 +0,0 @@ -/* -Package version contains all build time metadata (version, build time, git commit, etc). -*/ -package version - -import ( - "fmt" - "runtime" - "strings" - - "github.com/anchore/syft/internal" -) - -const valueNotProvided = "[not provided]" - -// all variables here are provided as build-time arguments, with clear default values -var version = valueNotProvided -var gitCommit = valueNotProvided -var gitDescription = valueNotProvided -var buildDate = valueNotProvided -var platform = fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH) - -// Version defines the application version details (generally from build information) -type Version struct { - Version string `json:"version"` // application semantic version - JSONSchemaVersion string `json:"jsonSchemaVersion"` // application semantic JSON schema version - GitCommit string `json:"gitCommit"` // git SHA at build-time - GitDescription string `json:"gitDescription"` // output of 'git describe --dirty --always --tags' - BuildDate string `json:"buildDate"` // date of the build - GoVersion string `json:"goVersion"` // go runtime version at build-time - Compiler string `json:"compiler"` // compiler used at build-time - Platform string `json:"platform"` // GOOS and GOARCH at build-time -} - -func (v Version) IsProductionBuild() bool { - if strings.Contains(v.Version, "SNAPSHOT") || strings.Contains(v.Version, valueNotProvided) { - return false - } - return true -} - -// FromBuild provides all version details -func FromBuild() Version { - return Version{ - Version: version, - JSONSchemaVersion: internal.JSONSchemaVersion, - GitCommit: gitCommit, - GitDescription: gitDescription, - BuildDate: buildDate, - GoVersion: runtime.Version(), - Compiler: runtime.Compiler, - Platform: platform, - } -} diff --git a/internal/version/update.go b/internal/version/update.go deleted file mode 100644 index 00141d14272..00000000000 --- a/internal/version/update.go +++ /dev/null @@ -1,73 +0,0 @@ -package version - -import ( - "fmt" - "io" - "net/http" - "strings" - - hashiVersion "github.com/anchore/go-version" - "github.com/anchore/syft/internal" -) - -var latestAppVersionURL = struct { - host string - path string -}{ - host: "https://toolbox-data.anchore.io", - path: fmt.Sprintf("/%s/releases/latest/VERSION", internal.ApplicationName), -} - -// IsUpdateAvailable indicates if there is a newer application version available, and if so, what the new version is. -func IsUpdateAvailable() (bool, string, error) { - currentBuildInfo := FromBuild() - if !currentBuildInfo.IsProductionBuild() { - // don't allow for non-production builds to check for a version. - return false, "", nil - } - currentVersion, err := hashiVersion.NewVersion(currentBuildInfo.Version) - if err != nil { - return false, "", fmt.Errorf("failed to parse current application version: %w", err) - } - - latestVersion, err := fetchLatestApplicationVersion() - if err != nil { - return false, "", err - } - - if latestVersion.GreaterThan(currentVersion) { - return true, latestVersion.String(), nil - } - - return false, "", nil -} - -func fetchLatestApplicationVersion() (*hashiVersion.Version, error) { - req, err := http.NewRequest(http.MethodGet, latestAppVersionURL.host+latestAppVersionURL.path, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request for latest version: %w", err) - } - - client := http.Client{} - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch latest version: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("HTTP %d on fetching latest version: %s", resp.StatusCode, resp.Status) - } - - versionBytes, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read latest version: %w", err) - } - - versionStr := strings.TrimSuffix(string(versionBytes), "\n") - if len(versionStr) > 50 { - return nil, fmt.Errorf("version too long: %q", versionStr[:50]) - } - - return hashiVersion.NewVersion(versionStr) -} diff --git a/syft/event/event.go b/syft/event/event.go index 4c2a29296b4..c095e9eefee 100644 --- a/syft/event/event.go +++ b/syft/event/event.go @@ -6,12 +6,10 @@ package event import ( "github.com/wagoodman/go-partybus" - - "github.com/anchore/syft/internal" ) const ( - typePrefix = internal.ApplicationName + typePrefix = "syft" cliTypePrefix = typePrefix + "-cli" // Events from the syft library @@ -48,7 +46,4 @@ const ( // CLINotification is a partybus event that occurs when auxiliary information is ready for presentation to stderr CLINotification partybus.EventType = cliTypePrefix + "-notification" - - // CLIExit is a partybus event that occurs when an analysis result is ready for final presentation - CLIExit partybus.EventType = cliTypePrefix + "-exit-event" ) diff --git a/syft/event/parsers/parsers.go b/syft/event/parsers/parsers.go index 2e6cbeca93b..c2a56e17c25 100644 --- a/syft/event/parsers/parsers.go +++ b/syft/event/parsers/parsers.go @@ -144,30 +144,22 @@ func ParseAttestationStartedEvent(e partybus.Event) (io.Reader, progress.Progres // CLI event types -func ParseCLIExit(e partybus.Event) (func() error, error) { - if err := checkEventType(e.Type, event.CLIExit); err != nil { - return nil, err - } - - fn, ok := e.Value.(func() error) - if !ok { - return nil, newPayloadErr(e.Type, "Value", e.Value) - } - - return fn, nil +type UpdateCheck struct { + New string + Current string } -func ParseCLIAppUpdateAvailable(e partybus.Event) (string, error) { +func ParseCLIAppUpdateAvailable(e partybus.Event) (*UpdateCheck, error) { if err := checkEventType(e.Type, event.CLIAppUpdateAvailable); err != nil { - return "", err + return nil, err } - newVersion, ok := e.Value.(string) + updateCheck, ok := e.Value.(UpdateCheck) if !ok { - return "", newPayloadErr(e.Type, "Value", e.Value) + return nil, newPayloadErr(e.Type, "Value", e.Value) } - return newVersion, nil + return &updateCheck, nil } func ParseCLIReport(e partybus.Event) (string, string, error) { diff --git a/syft/formats/common/cyclonedxhelpers/format.go b/syft/formats/common/cyclonedxhelpers/format.go index e36149e1280..1712e0f49c3 100644 --- a/syft/formats/common/cyclonedxhelpers/format.go +++ b/syft/formats/common/cyclonedxhelpers/format.go @@ -7,7 +7,6 @@ import ( "github.com/google/uuid" "golang.org/x/exp/slices" - "github.com/anchore/syft/internal" "github.com/anchore/syft/internal/log" "github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/cpe" @@ -24,7 +23,7 @@ func ToFormatModel(s sbom.SBOM) *cyclonedx.BOM { // https://github.com/CycloneDX/specification/blob/master/schema/bom-1.3-strict.schema.json#L36 // "pattern": "^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" cdxBOM.SerialNumber = uuid.New().URN() - cdxBOM.Metadata = toBomDescriptor(internal.ApplicationName, s.Descriptor.Version, s.Source) + cdxBOM.Metadata = toBomDescriptor(s.Descriptor.Name, s.Descriptor.Version, s.Source) packages := s.Artifacts.Packages.Sorted() components := make([]cyclonedx.Component, len(packages)) diff --git a/syft/formats/common/spdxhelpers/document_namespace.go b/syft/formats/common/spdxhelpers/document_namespace.go index 3b6d30b69eb..74801653da0 100644 --- a/syft/formats/common/spdxhelpers/document_namespace.go +++ b/syft/formats/common/spdxhelpers/document_namespace.go @@ -8,7 +8,7 @@ import ( "github.com/google/uuid" - "github.com/anchore/syft/internal" + "github.com/anchore/syft/syft/sbom" "github.com/anchore/syft/syft/source" ) @@ -18,12 +18,12 @@ const ( inputFile = "file" ) -func DocumentNameAndNamespace(src source.Description) (string, string) { +func DocumentNameAndNamespace(src source.Description, desc sbom.Descriptor) (string, string) { name := DocumentName(src) - return name, DocumentNamespace(name, src) + return name, DocumentNamespace(name, src, desc) } -func DocumentNamespace(name string, src source.Description) string { +func DocumentNamespace(name string, src source.Description, desc sbom.Descriptor) string { name = cleanName(name) input := "unknown-source-type" switch src.Metadata.(type) { @@ -44,7 +44,7 @@ func DocumentNamespace(name string, src source.Description) string { u := url.URL{ Scheme: "https", Host: "anchore.com", - Path: path.Join(internal.ApplicationName, identifier), + Path: path.Join(desc.Name, identifier), } return u.String() diff --git a/syft/formats/common/spdxhelpers/document_namespace_test.go b/syft/formats/common/spdxhelpers/document_namespace_test.go index 00bed35362e..9fa2ccb33e2 100644 --- a/syft/formats/common/spdxhelpers/document_namespace_test.go +++ b/syft/formats/common/spdxhelpers/document_namespace_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/anchore/syft/syft/internal/sourcemetadata" + "github.com/anchore/syft/syft/sbom" "github.com/anchore/syft/syft/source" ) @@ -55,9 +56,12 @@ func Test_documentNamespace(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - actual := DocumentNamespace(test.inputName, test.src) + actual := DocumentNamespace(test.inputName, test.src, sbom.Descriptor{ + Name: "syft", + }) + // note: since the namespace ends with a UUID we check the prefix - assert.True(t, strings.HasPrefix(actual, test.expected), fmt.Sprintf("actual namespace %q", actual)) + assert.True(t, strings.HasPrefix(actual, test.expected), fmt.Sprintf("expected prefix: '%s' got: '%s'", test.expected, actual)) // track each scheme tested (passed or not) tracker.Tested(t, test.src.Metadata) diff --git a/syft/formats/common/spdxhelpers/to_format_model.go b/syft/formats/common/spdxhelpers/to_format_model.go index 258c96f8f82..e15d78d4f9d 100644 --- a/syft/formats/common/spdxhelpers/to_format_model.go +++ b/syft/formats/common/spdxhelpers/to_format_model.go @@ -44,7 +44,7 @@ const ( // //nolint:funlen func ToFormatModel(s sbom.SBOM) *spdx.Document { - name, namespace := DocumentNameAndNamespace(s.Source) + name, namespace := DocumentNameAndNamespace(s.Source, s.Descriptor) packages := toPackages(s.Artifacts.Packages, s) @@ -136,7 +136,7 @@ func ToFormatModel(s sbom.SBOM) *spdx.Document { CreatorType: "Organization", }, { - Creator: internal.ApplicationName + "-" + s.Descriptor.Version, + Creator: s.Descriptor.Name + "-" + s.Descriptor.Version, CreatorType: "Tool", }, }, diff --git a/syft/formats/github/encoder.go b/syft/formats/github/encoder.go index 261ff6b184c..6f8ff5719e1 100644 --- a/syft/formats/github/encoder.go +++ b/syft/formats/github/encoder.go @@ -8,7 +8,6 @@ import ( "github.com/mholt/archiver/v3" "github.com/anchore/packageurl-go" - "github.com/anchore/syft/internal" "github.com/anchore/syft/internal/log" "github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/sbom" @@ -26,7 +25,7 @@ func toGithubModel(s *sbom.SBOM) DependencySnapshot { Version: 0, // TODO allow property input to specify the Job, Sha, and Ref Detector: DetectorMetadata{ - Name: internal.ApplicationName, + Name: s.Descriptor.Name, URL: "https://github.com/anchore/syft", Version: v, }, diff --git a/syft/formats/github/encoder_test.go b/syft/formats/github/encoder_test.go index 3325509ce5f..e027c7936a3 100644 --- a/syft/formats/github/encoder_test.go +++ b/syft/formats/github/encoder_test.go @@ -18,6 +18,9 @@ import ( func sbomFixture() sbom.SBOM { s := sbom.SBOM{ + Descriptor: sbom.Descriptor{ + Name: "syft", + }, Source: source.Description{ Metadata: source.StereoscopeImageSourceMetadata{ UserInput: "ubuntu:18.04", diff --git a/syft/formats/internal/testutils/snapshot.go b/syft/formats/internal/testutils/snapshot.go index 7eae36594ed..f06850b1df2 100644 --- a/syft/formats/internal/testutils/snapshot.go +++ b/syft/formats/internal/testutils/snapshot.go @@ -4,7 +4,7 @@ import ( "bytes" "testing" - "github.com/sergi/go-diff/diffmatchpatch" + "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -64,12 +64,16 @@ func AssertEncoderAgainstGoldenSnapshot(t *testing.T, cfg EncoderSnapshotTestCon if cfg.IsJSON { require.JSONEq(t, string(expected), string(actual)) - } else if !bytes.Equal(expected, actual) { - dmp := diffmatchpatch.New() - diffs := dmp.DiffMain(string(expected), string(actual), true) - t.Logf("len: %d\nexpected: %s", len(expected), expected) - t.Logf("len: %d\nactual: %s", len(actual), actual) - t.Errorf("mismatched output:\n%s", dmp.DiffPrettyText(diffs)) + } else { + requireEqual(t, expected, actual) + } +} + +func requireEqual(t *testing.T, expected any, actual any) { + if diff := cmp.Diff(expected, actual); diff != "" { + t.Logf("expected: %s", expected) + t.Logf("actual: %s", actual) + t.Fatalf("mismatched output: %s", diff) } } diff --git a/syft/formats/spdxtagvalue/encoder_test.go b/syft/formats/spdxtagvalue/encoder_test.go index baafe2b0223..e5fe4a5af2a 100644 --- a/syft/formats/spdxtagvalue/encoder_test.go +++ b/syft/formats/spdxtagvalue/encoder_test.go @@ -115,7 +115,7 @@ func redactor(values ...string) testutils.Redactor { `Created: .*`: "Created: redacted", // each SBOM reports a unique documentNamespace when generated, this is not useful for snapshot testing - `DocumentNamespace: https://anchore.com/syft/.*`: "DocumentNamespace: redacted", + `DocumentNamespace: https://anchore.com/.*`: "DocumentNamespace: redacted", // the license list will be updated periodically, the value here should not be directly tested in snapshot tests `LicenseListVersion: .*`: "LicenseListVersion: redacted", diff --git a/syft/pkg/cataloger/rpm/parse_rpm_db.go b/syft/pkg/cataloger/rpm/parse_rpm_db.go index 02106f62c35..1dee8c0f81a 100644 --- a/syft/pkg/cataloger/rpm/parse_rpm_db.go +++ b/syft/pkg/cataloger/rpm/parse_rpm_db.go @@ -7,7 +7,6 @@ import ( rpmdb "github.com/knqyf263/go-rpmdb/pkg" - "github.com/anchore/syft/internal" "github.com/anchore/syft/internal/log" "github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/file" @@ -18,7 +17,7 @@ import ( // parseRpmDb parses an "Packages" RPM DB and returns the Packages listed within it. func parseRpmDB(resolver file.Resolver, env *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) { - f, err := os.CreateTemp("", internal.ApplicationName+"-rpmdb") + f, err := os.CreateTemp("", "rpmdb") if err != nil { return nil, nil, fmt.Errorf("failed to create temp rpmdb file: %w", err) } diff --git a/test/cli/packages_cmd_test.go b/test/cli/packages_cmd_test.go index e9b87ae4894..52efac9202b 100644 --- a/test/cli/packages_cmd_test.go +++ b/test/cli/packages_cmd_test.go @@ -153,7 +153,7 @@ func TestPackagesCmdFlags(t *testing.T) { }, { name: "responds-to-package-cataloger-search-options", - args: []string{"packages", "-vv"}, + args: []string{"--help"}, env: map[string]string{ "SYFT_PACKAGE_SEARCH_UNINDEXED_ARCHIVES": "true", "SYFT_PACKAGE_SEARCH_INDEXED_ARCHIVES": "false", @@ -294,7 +294,7 @@ func TestRegistryAuth(t *testing.T) { args: args, env: map[string]string{ "SYFT_REGISTRY_AUTH_AUTHORITY": host, - "SYFT_REGISTRY_AUTH_TOKEN": "token", + "SYFT_REGISTRY_AUTH_TOKEN": "my-token", }, assertions: []traitAssertion{ assertInOutput("source=OciRegistry"), @@ -303,7 +303,7 @@ func TestRegistryAuth(t *testing.T) { }, }, { - name: "not enough info fallsback to keychain", + name: "not enough info fallback to keychain", args: args, env: map[string]string{ "SYFT_REGISTRY_AUTH_AUTHORITY": host, diff --git a/test/cli/trait_assertions_test.go b/test/cli/trait_assertions_test.go index 465c389d215..d441d021bf3 100644 --- a/test/cli/trait_assertions_test.go +++ b/test/cli/trait_assertions_test.go @@ -4,8 +4,6 @@ import ( "encoding/json" "fmt" "os" - "os/exec" - "path/filepath" "regexp" "sort" "strings" @@ -88,8 +86,10 @@ func assertNotInOutput(data string) traitAssertion { func assertInOutput(data string) traitAssertion { return func(tb testing.TB, stdout, stderr string, _ int) { tb.Helper() - if !strings.Contains(stripansi.Strip(stderr), data) && !strings.Contains(stripansi.Strip(stdout), data) { - tb.Errorf("data=%q was NOT found in any output, but should have been there", data) + stdout = stripansi.Strip(stdout) + stderr = stripansi.Strip(stderr) + if !strings.Contains(stdout, data) && !strings.Contains(stderr, data) { + tb.Errorf("data=%q was NOT found in any output, but should have been there\nSTDOUT:%s\nSTDERR:%s", data, stdout, stderr) } } } @@ -148,46 +148,6 @@ func assertSuccessfulReturnCode(tb testing.TB, _, _ string, rc int) { } } -func assertVerifyAttestation(coverageImage string) traitAssertion { - return func(tb testing.TB, stdout, _ string, _ int) { - tb.Helper() - cosignPath := filepath.Join(repoRoot(tb), ".tmp/cosign") - err := os.WriteFile("attestation.json", []byte(stdout), 0664) - if err != nil { - tb.Errorf("could not write attestation to disk") - } - defer os.Remove("attestation.json") - attachCmd := exec.Command( - cosignPath, - "attach", - "attestation", - "--attestation", - "attestation.json", - coverageImage, // TODO which remote image to use? - ) - - stdout, stderr, _ := runCommand(attachCmd, nil) - if attachCmd.ProcessState.ExitCode() != 0 { - tb.Log("STDOUT", stdout) - tb.Log("STDERR", stderr) - tb.Fatalf("could not attach image") - } - - verifyCmd := exec.Command( - cosignPath, - "verify-attestation", - coverageImage, // TODO which remote image to use? - ) - - stdout, stderr, _ = runCommand(verifyCmd, nil) - if attachCmd.ProcessState.ExitCode() != 0 { - tb.Log("STDOUT", stdout) - tb.Log("STDERR", stderr) - tb.Fatalf("could not verify attestation") - } - } -} - func assertFileExists(file string) traitAssertion { return func(tb testing.TB, _, _ string, _ int) { tb.Helper() diff --git a/test/integration/convert_test.go b/test/integration/convert_test.go index d20cab27bd3..f1c0eb3269d 100644 --- a/test/integration/convert_test.go +++ b/test/integration/convert_test.go @@ -1,15 +1,14 @@ package integration import ( - "context" "fmt" "os" "testing" "github.com/stretchr/testify/require" - "github.com/anchore/syft/cmd/syft/cli/convert" - "github.com/anchore/syft/internal/config" + "github.com/anchore/syft/cmd/syft/cli/commands" + "github.com/anchore/syft/cmd/syft/cli/options" "github.com/anchore/syft/syft/formats" "github.com/anchore/syft/syft/formats/cyclonedxjson" "github.com/anchore/syft/syft/formats/cyclonedxxml" @@ -72,9 +71,10 @@ func TestConvertCmd(t *testing.T) { _ = os.Remove(syftFile.Name()) }() - ctx := context.Background() - app := &config.Application{ - Outputs: []string{fmt.Sprintf("%s=%s", test.format.ID().String(), formatFile.Name())}, + opts := &commands.ConvertOptions{ + MultiOutput: options.MultiOutput{ + Outputs: []string{fmt.Sprintf("%s=%s", test.format.ID().String(), formatFile.Name())}, + }, } // stdout reduction of test noise @@ -84,7 +84,7 @@ func TestConvertCmd(t *testing.T) { os.Stdout = rescue }() - err = convert.Run(ctx, app, []string{syftFile.Name()}) + err = commands.RunConvert(opts, syftFile.Name()) require.NoError(t, err) contents, err := os.ReadFile(formatFile.Name()) require.NoError(t, err)