github.com/anchore/syft@v1.4.2-0.20240516191711-1bec1fc5d397/cmd/syft/internal/commands/scan.go (about)

     1  package commands
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"os"
     8  	"reflect"
     9  	"strings"
    10  
    11  	"github.com/hashicorp/go-multierror"
    12  	"github.com/spf13/cobra"
    13  	"gopkg.in/yaml.v3"
    14  
    15  	"github.com/anchore/clio"
    16  	"github.com/anchore/go-collections"
    17  	"github.com/anchore/stereoscope"
    18  	"github.com/anchore/stereoscope/pkg/image"
    19  	"github.com/anchore/syft/cmd/syft/internal/options"
    20  	"github.com/anchore/syft/cmd/syft/internal/ui"
    21  	"github.com/anchore/syft/internal"
    22  	"github.com/anchore/syft/internal/bus"
    23  	"github.com/anchore/syft/internal/file"
    24  	"github.com/anchore/syft/internal/log"
    25  	"github.com/anchore/syft/internal/task"
    26  	"github.com/anchore/syft/syft"
    27  	"github.com/anchore/syft/syft/sbom"
    28  	"github.com/anchore/syft/syft/source"
    29  	"github.com/anchore/syft/syft/source/sourceproviders"
    30  )
    31  
    32  const (
    33  	scanExample = `  {{.appName}} {{.command}} alpine:latest                                a summary of discovered packages
    34    {{.appName}} {{.command}} alpine:latest -o json                        show all possible cataloging details
    35    {{.appName}} {{.command}} alpine:latest -o cyclonedx                   show a CycloneDX formatted SBOM
    36    {{.appName}} {{.command}} alpine:latest -o cyclonedx-json              show a CycloneDX JSON formatted SBOM
    37    {{.appName}} {{.command}} alpine:latest -o spdx                        show a SPDX 2.3 Tag-Value formatted SBOM
    38    {{.appName}} {{.command}} alpine:latest -o spdx@2.2                    show a SPDX 2.2 Tag-Value formatted SBOM
    39    {{.appName}} {{.command}} alpine:latest -o spdx-json                   show a SPDX 2.3 JSON formatted SBOM
    40    {{.appName}} {{.command}} alpine:latest -o spdx-json@2.2               show a SPDX 2.2 JSON formatted SBOM
    41    {{.appName}} {{.command}} alpine:latest -vv                            show verbose debug information
    42    {{.appName}} {{.command}} alpine:latest -o template -t my_format.tmpl  show a SBOM formatted according to given template file
    43  
    44    Supports the following image sources:
    45      {{.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.
    46      {{.appName}} {{.command}} path/to/a/file/or/dir      a Docker tar, OCI tar, OCI directory, SIF container, or generic filesystem directory
    47  `
    48  
    49  	schemeHelpHeader = "You can also explicitly specify the scheme to use:"
    50  	imageSchemeHelp  = `    {{.appName}} {{.command}} docker:yourrepo/yourimage:tag            explicitly use the Docker daemon
    51      {{.appName}} {{.command}} podman:yourrepo/yourimage:tag            explicitly use the Podman daemon
    52      {{.appName}} {{.command}} registry:yourrepo/yourimage:tag          pull image directly from a registry (no container runtime required)
    53      {{.appName}} {{.command}} docker-archive:path/to/yourimage.tar     use a tarball from disk for archives created from "docker save"
    54      {{.appName}} {{.command}} oci-archive:path/to/yourimage.tar        use a tarball from disk for OCI archives (from Skopeo or otherwise)
    55      {{.appName}} {{.command}} oci-dir:path/to/yourimage                read directly from a path on disk for OCI layout directories (from Skopeo or otherwise)
    56      {{.appName}} {{.command}} singularity:path/to/yourimage.sif        read directly from a Singularity Image Format (SIF) container on disk
    57  `
    58  	nonImageSchemeHelp = `    {{.appName}} {{.command}} dir:path/to/yourproject                  read directly from a path on disk (any directory)
    59      {{.appName}} {{.command}} file:path/to/yourproject/file            read directly from a path on disk (any single file)
    60  `
    61  	scanSchemeHelp = "\n  " + schemeHelpHeader + "\n" + imageSchemeHelp + nonImageSchemeHelp
    62  
    63  	scanHelp = scanExample + scanSchemeHelp
    64  )
    65  
    66  type scanOptions struct {
    67  	options.Config      `yaml:",inline" mapstructure:",squash"`
    68  	options.Output      `yaml:",inline" mapstructure:",squash"`
    69  	options.UpdateCheck `yaml:",inline" mapstructure:",squash"`
    70  	options.Catalog     `yaml:",inline" mapstructure:",squash"`
    71  }
    72  
    73  func defaultScanOptions() *scanOptions {
    74  	return &scanOptions{
    75  		Output:      options.DefaultOutput(),
    76  		UpdateCheck: options.DefaultUpdateCheck(),
    77  		Catalog:     options.DefaultCatalog(),
    78  	}
    79  }
    80  
    81  //nolint:dupl
    82  func Scan(app clio.Application) *cobra.Command {
    83  	id := app.ID()
    84  
    85  	opts := defaultScanOptions()
    86  
    87  	return app.SetupCommand(&cobra.Command{
    88  		Use:   "scan [SOURCE]",
    89  		Short: "Generate an SBOM",
    90  		Long:  "Generate a packaged-based Software Bill Of Materials (SBOM) from container images and filesystems",
    91  		Example: internal.Tprintf(scanHelp, map[string]interface{}{
    92  			"appName": id.Name,
    93  			"command": "scan",
    94  		}),
    95  		Args:    validateScanArgs,
    96  		PreRunE: applicationUpdateCheck(id, &opts.UpdateCheck),
    97  		RunE: func(cmd *cobra.Command, args []string) error {
    98  			restoreStdout := ui.CaptureStdoutToTraceLog()
    99  			defer restoreStdout()
   100  
   101  			return runScan(cmd.Context(), id, opts, args[0])
   102  		},
   103  	}, opts)
   104  }
   105  
   106  func (o *scanOptions) PostLoad() error {
   107  	return o.validateLegacyOptionsNotUsed()
   108  }
   109  
   110  func (o *scanOptions) validateLegacyOptionsNotUsed() error {
   111  	if o.Config.ConfigFile == "" {
   112  		return nil
   113  	}
   114  
   115  	// check for legacy config file shapes that are no longer valid
   116  	type legacyConfig struct {
   117  		BasePath                        *string `yaml:"base-path" json:"base-path" mapstructure:"base-path"`
   118  		DefaultImagePullSource          *string `yaml:"default-image-pull-source" json:"default-image-pull-source" mapstructure:"default-image-pull-source"`
   119  		ExcludeBinaryOverlapByOwnership *bool   `yaml:"exclude-binary-overlap-by-ownership" json:"exclude-binary-overlap-by-ownership" mapstructure:"exclude-binary-overlap-by-ownership"`
   120  		File                            any     `yaml:"file" json:"file" mapstructure:"file"`
   121  	}
   122  
   123  	by, err := os.ReadFile(o.Config.ConfigFile)
   124  	if err != nil {
   125  		return fmt.Errorf("unable to read config file during validations %q: %w", o.Config.ConfigFile, err)
   126  	}
   127  
   128  	var legacy legacyConfig
   129  	if err := yaml.Unmarshal(by, &legacy); err != nil {
   130  		return fmt.Errorf("unable to parse config file during validations %q: %w", o.Config.ConfigFile, err)
   131  	}
   132  
   133  	if legacy.DefaultImagePullSource != nil {
   134  		return fmt.Errorf("the config file option 'default-image-pull-source' has been removed, please use 'source.image.default-pull-source' instead")
   135  	}
   136  
   137  	if legacy.ExcludeBinaryOverlapByOwnership != nil {
   138  		return fmt.Errorf("the config file option 'exclude-binary-overlap-by-ownership' has been removed, please use 'package.exclude-binary-overlap-by-ownership' instead")
   139  	}
   140  
   141  	if legacy.BasePath != nil {
   142  		return fmt.Errorf("the config file option 'base-path' has been removed, please use 'source.base-path' instead")
   143  	}
   144  
   145  	if legacy.File != nil && reflect.TypeOf(legacy.File).Kind() == reflect.String {
   146  		return fmt.Errorf("the config file option 'file' has been removed, please use 'outputs' instead")
   147  	}
   148  
   149  	return nil
   150  }
   151  
   152  func validateScanArgs(cmd *cobra.Command, args []string) error {
   153  	return validateArgs(cmd, args, "an image/directory argument is required")
   154  }
   155  
   156  func validateArgs(cmd *cobra.Command, args []string, error string) error {
   157  	if len(args) == 0 {
   158  		// in the case that no arguments are given we want to show the help text and return with a non-0 return code.
   159  		if err := cmd.Help(); err != nil {
   160  			return fmt.Errorf("unable to display help: %w", err)
   161  		}
   162  		return fmt.Errorf(error)
   163  	}
   164  
   165  	return cobra.MaximumNArgs(1)(cmd, args)
   166  }
   167  
   168  func runScan(ctx context.Context, id clio.Identification, opts *scanOptions, userInput string) error {
   169  	writer, err := opts.SBOMWriter()
   170  	if err != nil {
   171  		return err
   172  	}
   173  
   174  	sources := opts.From
   175  	if len(sources) == 0 {
   176  		// extract a scheme if it matches any provider tag; this is a holdover for compatibility, using the --from flag is recommended
   177  		explicitSource, newUserInput := stereoscope.ExtractSchemeSource(userInput, allSourceProviderTags()...)
   178  		if explicitSource != "" {
   179  			sources = append(sources, explicitSource)
   180  			userInput = newUserInput
   181  		}
   182  	}
   183  
   184  	src, err := getSource(ctx, &opts.Catalog, userInput, sources...)
   185  
   186  	if err != nil {
   187  		return err
   188  	}
   189  
   190  	defer func() {
   191  		if src != nil {
   192  			if err := src.Close(); err != nil {
   193  				log.Tracef("unable to close source: %+v", err)
   194  			}
   195  		}
   196  	}()
   197  
   198  	s, err := generateSBOM(ctx, id, src, &opts.Catalog)
   199  	if err != nil {
   200  		return err
   201  	}
   202  
   203  	if s == nil {
   204  		return fmt.Errorf("no SBOM produced for %q", userInput)
   205  	}
   206  
   207  	if err := writer.Write(*s); err != nil {
   208  		return fmt.Errorf("failed to write SBOM: %w", err)
   209  	}
   210  
   211  	return nil
   212  }
   213  
   214  func getSource(ctx context.Context, opts *options.Catalog, userInput string, sources ...string) (source.Source, error) {
   215  	cfg := syft.DefaultGetSourceConfig().
   216  		WithRegistryOptions(opts.Registry.ToOptions()).
   217  		WithAlias(source.Alias{
   218  			Name:    opts.Source.Name,
   219  			Version: opts.Source.Version,
   220  		}).
   221  		WithExcludeConfig(source.ExcludeConfig{
   222  			Paths: opts.Exclusions,
   223  		}).
   224  		WithBasePath(opts.Source.BasePath).
   225  		WithSources(sources...).
   226  		WithDefaultImagePullSource(opts.Source.Image.DefaultPullSource)
   227  
   228  	var err error
   229  	var platform *image.Platform
   230  
   231  	if opts.Platform != "" {
   232  		platform, err = image.NewPlatform(opts.Platform)
   233  		if err != nil {
   234  			return nil, fmt.Errorf("invalid platform: %w", err)
   235  		}
   236  		cfg = cfg.WithPlatform(platform)
   237  	}
   238  
   239  	if opts.Source.File.Digests != nil {
   240  		hashers, err := file.Hashers(opts.Source.File.Digests...)
   241  		if err != nil {
   242  			return nil, fmt.Errorf("invalid hash algorithm: %w", err)
   243  		}
   244  		cfg = cfg.WithDigestAlgorithms(hashers...)
   245  	}
   246  
   247  	src, err := syft.GetSource(ctx, userInput, cfg)
   248  	if err != nil {
   249  		return nil, fmt.Errorf("could not determine source: %w", err)
   250  	}
   251  
   252  	return src, nil
   253  }
   254  
   255  func generateSBOM(ctx context.Context, id clio.Identification, src source.Source, opts *options.Catalog) (*sbom.SBOM, error) {
   256  	s, err := syft.CreateSBOM(ctx, src, opts.ToSBOMConfig(id))
   257  	if err != nil {
   258  		expErrs := filterExpressionErrors(err)
   259  		notifyExpressionErrors(expErrs)
   260  		return nil, err
   261  	}
   262  	return s, nil
   263  }
   264  
   265  func filterExpressionErrors(err error) []task.ErrInvalidExpression {
   266  	if err == nil {
   267  		return nil
   268  	}
   269  
   270  	expErrs := processErrors(err)
   271  
   272  	return expErrs
   273  }
   274  
   275  // processErrors traverses error chains and multierror lists and returns all ErrInvalidExpression errors found
   276  func processErrors(err error) []task.ErrInvalidExpression {
   277  	var result []task.ErrInvalidExpression
   278  
   279  	var processError func(...error)
   280  	processError = func(errs ...error) {
   281  		for _, e := range errs {
   282  			// note: using errors.As will result in surprising behavior (since that will traverse the error chain,
   283  			// potentially skipping over nodes in a list of errors)
   284  			if cerr, ok := e.(task.ErrInvalidExpression); ok {
   285  				result = append(result, cerr)
   286  				continue
   287  			}
   288  			var multiErr *multierror.Error
   289  			if errors.As(e, &multiErr) {
   290  				processError(multiErr.Errors...)
   291  			}
   292  		}
   293  	}
   294  
   295  	processError(err)
   296  
   297  	return result
   298  }
   299  
   300  func notifyExpressionErrors(expErrs []task.ErrInvalidExpression) {
   301  	helpText := expressionErrorsHelp(expErrs)
   302  	if helpText == "" {
   303  		return
   304  	}
   305  
   306  	bus.Notify(helpText)
   307  }
   308  
   309  func expressionErrorsHelp(expErrs []task.ErrInvalidExpression) string {
   310  	// enrich all errors found with CLI hints
   311  	if len(expErrs) == 0 {
   312  		return ""
   313  	}
   314  
   315  	sb := strings.Builder{}
   316  
   317  	sb.WriteString("Suggestions:\n\n")
   318  
   319  	found := false
   320  	for i, expErr := range expErrs {
   321  		help := expressionSuggetions(expErr)
   322  		if help == "" {
   323  			continue
   324  		}
   325  		found = true
   326  		sb.WriteString(help)
   327  		if i != len(expErrs)-1 {
   328  			sb.WriteString("\n")
   329  		}
   330  	}
   331  
   332  	if !found {
   333  		return ""
   334  	}
   335  
   336  	return sb.String()
   337  }
   338  
   339  const expressionHelpTemplate = " ❖ Given expression %q\n%s%s"
   340  
   341  func expressionSuggetions(expErr task.ErrInvalidExpression) string {
   342  	if expErr.Err == nil {
   343  		return ""
   344  	}
   345  
   346  	hint := getHintPhrase(expErr)
   347  	if hint == "" {
   348  		return ""
   349  	}
   350  
   351  	return fmt.Sprintf(expressionHelpTemplate,
   352  		getExpression(expErr),
   353  		indentMsg(getExplanation(expErr)),
   354  		indentMsg(hint),
   355  	)
   356  }
   357  
   358  func indentMsg(msg string) string {
   359  	if msg == "" {
   360  		return ""
   361  	}
   362  
   363  	lines := strings.Split(msg, "\n")
   364  	for i, line := range lines {
   365  		lines[i] = "   " + line
   366  	}
   367  
   368  	return strings.Join(lines, "\n") + "\n"
   369  }
   370  
   371  func getExpression(expErr task.ErrInvalidExpression) string {
   372  	flag := "--select-catalogers"
   373  	if expErr.Operation == task.SetOperation {
   374  		flag = "--override-default-catalogers"
   375  	}
   376  	return fmt.Sprintf("%s %s", flag, expErr.Expression)
   377  }
   378  
   379  func getExplanation(expErr task.ErrInvalidExpression) string {
   380  	err := expErr.Err
   381  	if errors.Is(err, task.ErrUnknownNameOrTag) {
   382  		noun := ""
   383  		switch expErr.Operation {
   384  		case task.AddOperation:
   385  			noun = "name"
   386  		case task.SubSelectOperation:
   387  			noun = "tag"
   388  		default:
   389  			noun = "name or tag"
   390  		}
   391  
   392  		return fmt.Sprintf("However, %q is not a recognized cataloger %s.", trimOperation(expErr.Expression), noun)
   393  	}
   394  
   395  	if errors.Is(err, task.ErrNamesNotAllowed) {
   396  		if expErr.Operation == task.SubSelectOperation {
   397  			return "However, " + err.Error() + ".\nIt seems like you are intending to add a cataloger in addition to the default set." // nolint:goconst
   398  		}
   399  		return "However, " + err.Error() + "." // nolint:goconst
   400  	}
   401  
   402  	if errors.Is(err, task.ErrTagsNotAllowed) {
   403  		return "However, " + err.Error() + ".\nAdding groups of catalogers may result in surprising behavior (create inaccurate SBOMs)." // nolint:goconst
   404  	}
   405  
   406  	if errors.Is(err, task.ErrAllNotAllowed) {
   407  		return "However, you " + err.Error() + ".\nIt seems like you are intending to use all catalogers (which is not recommended)."
   408  	}
   409  
   410  	if err != nil {
   411  		return "However, this is not valid: " + err.Error()
   412  	}
   413  
   414  	return ""
   415  }
   416  
   417  func getHintPhrase(expErr task.ErrInvalidExpression) string {
   418  	if errors.Is(expErr.Err, task.ErrUnknownNameOrTag) {
   419  		return ""
   420  	}
   421  
   422  	switch expErr.Operation {
   423  	case task.AddOperation:
   424  		if errors.Is(expErr.Err, task.ErrTagsNotAllowed) {
   425  			return fmt.Sprintf("If you are certain this is what you want to do, use %q instead.", "--override-default-catalogers "+trimOperation(expErr.Expression))
   426  		}
   427  
   428  	case task.SubSelectOperation:
   429  		didYouMean := "... Did you mean %q instead?"
   430  		if errors.Is(expErr.Err, task.ErrNamesNotAllowed) {
   431  			return fmt.Sprintf(didYouMean, "--select-catalogers +"+expErr.Expression)
   432  		}
   433  
   434  		if errors.Is(expErr.Err, task.ErrAllNotAllowed) {
   435  			return fmt.Sprintf(didYouMean, "--override-default-catalogers "+expErr.Expression)
   436  		}
   437  	}
   438  	return ""
   439  }
   440  
   441  func trimOperation(x string) string {
   442  	return strings.TrimLeft(x, "+-")
   443  }
   444  
   445  func allSourceProviderTags() []string {
   446  	return collections.TaggedValueSet[source.Provider]{}.Join(sourceproviders.All("", nil)...).Tags()
   447  }