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