From cf844f83bb9af6d1dcba6786501cda078d19da7f Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Mon, 18 Dec 2023 17:39:58 -0500 Subject: [PATCH] [wip] add cataloger list command Signed-off-by: Alex Goodman --- cmd/syft/cli/cli.go | 1 + cmd/syft/cli/commands/attest.go | 2 +- cmd/syft/cli/commands/cataloger.go | 20 ++ cmd/syft/cli/commands/cataloger_list.go | 240 ++++++++++++++++++++++++ cmd/syft/cli/commands/packages.go | 229 +--------------------- go.mod | 2 + go.sum | 5 + 7 files changed, 270 insertions(+), 229 deletions(-) create mode 100644 cmd/syft/cli/commands/cataloger.go create mode 100644 cmd/syft/cli/commands/cataloger_list.go diff --git a/cmd/syft/cli/cli.go b/cmd/syft/cli/cli.go index 0928934d7f4..d99404fb589 100644 --- a/cmd/syft/cli/cli.go +++ b/cmd/syft/cli/cli.go @@ -85,6 +85,7 @@ func create(id clio.Identification, out io.Writer) (clio.Application, *cobra.Com // add sub-commands rootCmd.AddCommand( packagesCmd, + commands.Cataloger(app), commands.Attest(app), commands.Convert(app), clio.VersionCommand(id), diff --git a/cmd/syft/cli/commands/attest.go b/cmd/syft/cli/commands/attest.go index c3067933ef6..298b0ab3de9 100644 --- a/cmd/syft/cli/commands/attest.go +++ b/cmd/syft/cli/commands/attest.go @@ -231,7 +231,7 @@ func attestCommand(sbomFilepath string, opts *attestOptions, userInput string) ( } func predicateType(outputName string) string { - // Select Cosign predicate type based on defined output type + // select the Cosign predicate type based on defined output type // As orientation, check: https://github.com/sigstore/cosign/blob/main/pkg/cosign/attestation/attestation.go switch strings.ToLower(outputName) { case "cyclonedx-json": diff --git a/cmd/syft/cli/commands/cataloger.go b/cmd/syft/cli/commands/cataloger.go new file mode 100644 index 00000000000..0baf5b5231a --- /dev/null +++ b/cmd/syft/cli/commands/cataloger.go @@ -0,0 +1,20 @@ +package commands + +import ( + "github.com/spf13/cobra" + + "github.com/anchore/clio" +) + +func Cataloger(app clio.Application) *cobra.Command { + cmd := &cobra.Command{ + Use: "cataloger", + Short: "Show available catalogers and configuration", + } + + cmd.AddCommand( + CatalogerList(app), + ) + + return cmd +} diff --git a/cmd/syft/cli/commands/cataloger_list.go b/cmd/syft/cli/commands/cataloger_list.go new file mode 100644 index 00000000000..be371309721 --- /dev/null +++ b/cmd/syft/cli/commands/cataloger_list.go @@ -0,0 +1,240 @@ +package commands + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/scylladb/go-set/strset" + "github.com/spf13/cobra" + + "github.com/anchore/clio" + "github.com/anchore/syft/internal/bus" + "github.com/anchore/syft/internal/task" + "github.com/anchore/syft/syft/cataloging/pkgcataloging" +) + +type catalogerListOptions struct { + Output string `yaml:"output" json:"output" mapstructure:"output"` + DefaultCatalogers []string `yaml:"default-catalogers" json:"default-catalogers" mapstructure:"default-catalogers"` + SelectCatalogers []string `yaml:"select-catalogers" json:"select-catalogers" mapstructure:"select-catalogers"` + ShowHidden bool `yaml:"show-hidden" json:"show-hidden" mapstructure:"show-hidden"` +} + +func (o *catalogerListOptions) AddFlags(flags clio.FlagSet) { + flags.StringVarP(&o.Output, "output", "o", "format to output the cataloger list (available: table, json)") + + flags.StringArrayVarP(&o.DefaultCatalogers, "override-default-catalogers", "", "override the default catalogers with an expression") + + flags.StringArrayVarP(&o.SelectCatalogers, "select-catalogers", "", "select catalogers with an expression") + + flags.BoolVarP(&o.ShowHidden, "show-hidden", "s", "show catalogers that have been de-selected") +} + +func CatalogerList(app clio.Application) *cobra.Command { + opts := &catalogerListOptions{ + DefaultCatalogers: []string{"all"}, + } + + return app.SetupCommand(&cobra.Command{ + Use: "list [OPTIONS]", + Short: "List available catalogers", + RunE: func(cmd *cobra.Command, args []string) error { + return runCatalogerList(opts) + }, + }, opts) +} + +func runCatalogerList(opts *catalogerListOptions) error { + factories := task.DefaultPackageTaskFactories() + allTasks, err := factories.Tasks(task.DefaultCatalogingFactoryConfig()) + if err != nil { + return fmt.Errorf("unable to create cataloger tasks: %w", err) + } + + selectedTasks, selectionEvidence, err := task.Select(allTasks, + pkgcataloging.NewSelectionRequest(). + WithDefaults(opts.DefaultCatalogers...). + WithExpression(opts.SelectCatalogers...), + ) + if err != nil { + return fmt.Errorf("unable to select catalogers: %w", err) + } + + var report string + + switch opts.Output { + case "json": + report, err = renderCatalogerListJSON(selectedTasks, selectionEvidence, opts.SelectCatalogers) + case "table", "": + if opts.ShowHidden { + report = renderCatalogerListTable(allTasks, selectionEvidence, opts.SelectCatalogers) + } else { + report = renderCatalogerListTable(selectedTasks, selectionEvidence, opts.SelectCatalogers) + } + } + + if err != nil { + return fmt.Errorf("unable to render cataloger list: %w", err) + } + + bus.Report(report) + + return nil +} + +func renderCatalogerListJSON(tasks []task.Task, selection task.Selection, expressions []string) (string, error) { + type node struct { + Name string `json:"name"` + Tags []string `json:"tags"` + } + + names, tagsByName := extractTaskInfo(tasks) + + nodesByName := make(map[string]node) + + for name := range tagsByName { + tagsSelected := selection.TokensByTask[name].SelectedOn.List() + sort.Strings(tagsSelected) + + if tagsSelected == nil { + // ensure collections are not null + tagsSelected = []string{} + } + + nodesByName[name] = node{ + Name: name, + Tags: tagsSelected, + } + } + + type document struct { + Expressions []string `json:"expressions"` + Catalogers []node `json:"catalogers"` + } + + if expressions == nil { + // ensure collections are not null + expressions = []string{} + } + + doc := document{ + Expressions: expressions, + } + + for _, name := range names { + doc.Catalogers = append(doc.Catalogers, nodesByName[name]) + } + + by, err := json.Marshal(doc) + + return string(by), err +} + +func renderCatalogerListTable(tasks []task.Task, selection task.Selection, expressions []string) string { + t := table.NewWriter() + t.SetStyle(table.StyleLight) + t.AppendHeader(table.Row{"Cataloger", "Tags"}) + + names, tagsByName := extractTaskInfo(tasks) + + rowsByName := make(map[string]table.Row) + + for name, tags := range tagsByName { + rowsByName[name] = formatRow(name, tags, selection) + } + + for _, name := range names { + t.AppendRow(rowsByName[name]) + } + + report := t.Render() + + if len(expressions) > 0 { + header := "Selected by expressions:\n" + for _, expr := range expressions { + header += fmt.Sprintf(" - %q\n", expr) + } + report = header + report + } + + return report +} + +func formatRow(name string, tags []string, selection task.Selection) table.Row { + isIncluded := selection.Result.Has(name) + var selections *task.TokenSelection + if s, exists := selection.TokensByTask[name]; exists { + selections = &s + } + + var formattedTags []string + for _, tag := range tags { + formattedTags = append(formattedTags, formatToken(tag, selections, isIncluded)) + } + + var tagStr string + if isIncluded { + tagStr = strings.Join(formattedTags, ", ") + } else { + tagStr = strings.Join(formattedTags, grey.Render(", ")) + } + + // TODO: selection should keep warnings (non-selections) in struct + + return table.Row{ + formatToken(name, selections, isIncluded), + tagStr, + } +} + +var ( + green = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // hi green + grey = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // dark grey + red = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) // high red +) + +func formatToken(token string, selection *task.TokenSelection, included bool) string { + if included && selection != nil { + // format all tokens in selection in green + if selection.SelectedOn.Has(token) { + return green.Render(token) + } + + return token + } + + // format all tokens in selection in red, all others in grey + if selection != nil && selection.DeselectedOn.Has(token) { + return red.Render(token) + } + + return grey.Render(token) +} + +func extractTaskInfo(tasks []task.Task) ([]string, map[string][]string) { + tagsByName := make(map[string][]string) + var names []string + + for _, tsk := range tasks { + var tags []string + name := tsk.Name() + + if s, ok := tsk.(task.Selector); ok { + set := strset.New(s.Selectors()...) + set.Remove(name) + tags = set.List() + sort.Strings(tags) + } + + tagsByName[name] = tags + names = append(names, name) + } + + sort.Strings(names) + + return names, tagsByName +} diff --git a/cmd/syft/cli/commands/packages.go b/cmd/syft/cli/commands/packages.go index eaa4eca12b7..d427e209a22 100644 --- a/cmd/syft/cli/commands/packages.go +++ b/cmd/syft/cli/commands/packages.go @@ -1,11 +1,8 @@ package commands import ( - "errors" "fmt" - "strings" - "github.com/hashicorp/go-multierror" "github.com/spf13/cobra" "github.com/anchore/clio" @@ -16,7 +13,6 @@ import ( "github.com/anchore/syft/internal/bus" "github.com/anchore/syft/internal/file" "github.com/anchore/syft/internal/log" - "github.com/anchore/syft/internal/task" "github.com/anchore/syft/syft" "github.com/anchore/syft/syft/sbom" "github.com/anchore/syft/syft/source" @@ -207,228 +203,5 @@ func getSource(opts *options.Catalog, userInput string, filters ...func(*source. } func generateSBOM(id clio.Identification, src source.Source, opts *options.Catalog) (*sbom.SBOM, error) { - s, err := syft.CreateSBOM(src, opts.ToSBOMConfig(id)) - if err != nil { - expErrs, err := filterExpressionErrors(err, 0) - notifyExpressionErrors(expErrs) - return nil, err - } - return s, nil -} - -func filterExpressionErrors(err error, depth int) ([]task.ErrInvalidExpression, error) { - if err == nil { - return nil, nil - } - - expErrs, prunedErr := processErrors(err) - - // if we found any expression errors, they have now been removed. We want to add a single error - // back indicating that there were invalid expressions provided. The details of these errors - // now show up in the CLI output in a summarized form (over the bus and to the UI) - if depth == 0 && len(expErrs) > 0 { - prunedErr = multierror.Append(prunedErr, errors.New("invalid cataloger selection expression provided")) - } - - return expErrs, prunedErr -} - -// processErrors traverses and prunes custom errors, returning the pruned error chain and a list of pruned errors -func processErrors(err error) ([]task.ErrInvalidExpression, error) { - var prunedErrors []task.ErrInvalidExpression - - var processError func(err error) error - processError = func(err error) error { - if err == nil { - return nil - } - - // note: using errors.As will result in surprising behavior (since that will traverse the error chain, potentially - // skipping over nodes in a list of errors) - if cerr, ok := err.(task.ErrInvalidExpression); ok { - prunedErrors = append(prunedErrors, cerr) - return nil - } - - var multiErr *multierror.Error - if errors.As(err, &multiErr) { - var remainingErr error - for _, merr := range multiErr.Errors { - processedErr := processError(merr) - if processedErr != nil { - remainingErr = multierror.Append(remainingErr, processedErr) - } - } - return remainingErr - } - - // check the error chain to see if there are any expression errors - if errors.As(err, &task.ErrInvalidExpression{}) { - // this chain has an expression error somewhere, we need to reconstruct the chain without this error - var errs error - for { - if err == nil { - break - } - - if cerr, ok := err.(task.ErrInvalidExpression); ok { - // this is an expression error, we want to prune it from the chain - prunedErrors = append(prunedErrors, cerr) - break - } - - unwrappedErr := errors.Unwrap(err) - - if errs == nil { - errs = unwrappedErr - continue - } - errs = fmt.Errorf("%v: %w", errs, unwrappedErr) - } - } - - // keep the existing chain of errors - return err - } - - return prunedErrors, processError(err) -} - -func notifyExpressionErrors(expErrs []task.ErrInvalidExpression) { - helpText := expressionErrorsHelp(expErrs) - if helpText == "" { - return - } - - bus.Notify(helpText) -} - -func expressionErrorsHelp(expErrs []task.ErrInvalidExpression) string { - // enrich all errors found with CLI hints - if len(expErrs) == 0 { - return "" - } - - sb := strings.Builder{} - - plural := "" - if len(expErrs) > 1 { - plural = "s" - } - - sb.WriteString(fmt.Sprintf("Found %d invalid cataloger selection expression%s:\n\n", len(expErrs), plural)) - - for i, expErr := range expErrs { - help := expressionErrorHelp(expErr) - if help == "" { - continue - } - sb.WriteString(help) - if i != len(expErrs)-1 { - sb.WriteString("\n") - } - } - - return sb.String() -} - -const expressionHelpTemplate = " ❖ Given expression %q\n%s%s" - -func expressionErrorHelp(expErr task.ErrInvalidExpression) string { - if expErr.Err == nil { - return "" - } - - return fmt.Sprintf(expressionHelpTemplate, - getExpression(expErr), - indentMsg(getExplanation(expErr)), - indentMsg(getHintPhrase(expErr)), - ) -} - -func indentMsg(msg string) string { - if msg == "" { - return "" - } - - lines := strings.Split(msg, "\n") - for i, line := range lines { - lines[i] = " " + line - } - - return strings.Join(lines, "\n") + "\n" -} - -func getExpression(expErr task.ErrInvalidExpression) string { - flag := "--select-catalogers" - if expErr.Operation == task.SetOperation { - flag = "--override-default-catalogers" - } - return fmt.Sprintf("%s %s", flag, expErr.Expression) -} - -func getExplanation(expErr task.ErrInvalidExpression) string { - err := expErr.Err - if errors.Is(err, task.ErrUnknownNameOrTag) { - noun := "" - switch expErr.Operation { - case task.AddOperation: - noun = "name" - case task.SubSelectOperation: - noun = "tag" - default: - noun = "name or tag" - } - - return fmt.Sprintf("However, %q is not a recognized cataloger %s.", trimOperation(expErr.Expression), noun) - } - - if errors.Is(err, task.ErrNamesNotAllowed) { - if expErr.Operation == task.SubSelectOperation { - return "However, " + err.Error() + ".\nIt seems like you are intending to add a cataloger in addition to the default set." // nolint:goconst - } - return "However, " + err.Error() + "." // nolint:goconst - } - - if errors.Is(err, task.ErrTagsNotAllowed) { - return "However, " + err.Error() + ".\nAdding groups of catalogers may result in surprising behavior (create inaccurate SBOMs)." // nolint:goconst - } - - if errors.Is(err, task.ErrAllNotAllowed) { - return "However, you " + err.Error() + ".\nIt seems like you are intending to use all catalogers (which is not recommended)." - } - - if err != nil { - return "However, this is not valid: " + err.Error() - } - - return "" -} - -func getHintPhrase(expErr task.ErrInvalidExpression) string { - if errors.Is(expErr.Err, task.ErrUnknownNameOrTag) { - return "" - } - - switch expErr.Operation { - case task.AddOperation: - if errors.Is(expErr.Err, task.ErrTagsNotAllowed) { - return fmt.Sprintf("If you are certain this is what you want to do, use %q instead.", "--override-default-catalogers "+trimOperation(expErr.Expression)) - } - - case task.SubSelectOperation: - didYouMean := "... Did you mean %q instead?" - if errors.Is(expErr.Err, task.ErrNamesNotAllowed) { - return fmt.Sprintf(didYouMean, "--select-catalogers +"+expErr.Expression) - } - - if errors.Is(expErr.Err, task.ErrAllNotAllowed) { - return fmt.Sprintf(didYouMean, "--override-default-catalogers "+expErr.Expression) - } - } - return "" -} - -func trimOperation(x string) string { - return strings.TrimLeft(x, "+-") + return syft.CreateSBOM(src, opts.ToSBOMConfig(id)) } diff --git a/go.mod b/go.mod index b062ccc8ef5..48e41adbccc 100644 --- a/go.mod +++ b/go.mod @@ -76,6 +76,8 @@ require ( modernc.org/sqlite v1.28.0 ) +require github.com/jedib0t/go-pretty/v6 v6.4.9 + require ( dario.cat/mergo v1.0.0 // indirect github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect diff --git a/go.sum b/go.sum index e7b2b29de0d..c7def01ec0d 100644 --- a/go.sum +++ b/go.sum @@ -462,6 +462,8 @@ github.com/invopop/jsonschema v0.7.0 h1:2vgQcBz1n256N+FpX3Jq7Y17AjYt46Ig3zIWyy77 github.com/invopop/jsonschema v0.7.0/go.mod h1:O9uiLokuu0+MGFlyiaqtWxwqJm41/+8Nj0lD7A36YH0= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jedib0t/go-pretty/v6 v6.4.9 h1:vZ6bjGg2eBSrJn365qlxGcaWu09Id+LHtrfDWlB2Usc= +github.com/jedib0t/go-pretty/v6 v6.4.9/go.mod h1:Ndk3ase2CkQbXLLNf5QDHoYb6J9WtVfmHZu9n8rk2xs= github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= @@ -531,6 +533,7 @@ github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 h1:P8UmIzZ github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= @@ -627,6 +630,7 @@ github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/profile v1.6.0/go.mod h1:qBsxPvzyUincmltOk6iyRVxHYg4adc0OFOv72ZdLa18= github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= @@ -736,6 +740,7 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=