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 }