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 }