github.com/wolfi-dev/wolfictl@v0.16.11/pkg/cli/scan.go (about) 1 package cli 2 3 import ( 4 "context" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "io" 9 "net/http" 10 "os" 11 "path/filepath" 12 "runtime" 13 "sort" 14 "strings" 15 16 sbomSyft "github.com/anchore/syft/syft/sbom" 17 "github.com/chainguard-dev/clog" 18 "github.com/chainguard-dev/go-apk/pkg/apk" 19 "github.com/charmbracelet/lipgloss" 20 "github.com/samber/lo" 21 "github.com/savioxavier/termlink" 22 "github.com/spf13/cobra" 23 "github.com/wolfi-dev/wolfictl/pkg/buildlog" 24 "github.com/wolfi-dev/wolfictl/pkg/cli/styles" 25 "github.com/wolfi-dev/wolfictl/pkg/configs" 26 v2 "github.com/wolfi-dev/wolfictl/pkg/configs/advisory/v2" 27 rwos "github.com/wolfi-dev/wolfictl/pkg/configs/rwfs/os" 28 "github.com/wolfi-dev/wolfictl/pkg/index" 29 "github.com/wolfi-dev/wolfictl/pkg/sbom" 30 "github.com/wolfi-dev/wolfictl/pkg/scan" 31 "github.com/wolfi-dev/wolfictl/pkg/versions" 32 "golang.org/x/exp/slices" 33 "golang.org/x/sync/errgroup" 34 ) 35 36 const ( 37 outputFormatOutline = "outline" 38 outputFormatJSON = "json" 39 ) 40 41 var validOutputFormats = []string{outputFormatOutline, outputFormatJSON} 42 43 func cmdScan() *cobra.Command { 44 p := &scanParams{} 45 cmd := &cobra.Command{ 46 Use: "scan [ --sbom | --build-log | --remote ] [ --advisory-filter <type> --advisories-repo-dir <path> ] target...", 47 Short: "Scan a package for vulnerabilities", 48 Long: `This command scans one or more distro packages for vulnerabilities. 49 50 ## SCANNING 51 52 There are four ways to specify the package(s) to scan: 53 54 1. Specify the path to the APK file(s) to scan. 55 56 2. Specify the path to the APK SBOM file(s) to scan. (The SBOM is expected to 57 use the Syft JSON format and can be created with the "wolfictl sbom -o 58 syft-json ..." command.) 59 60 3. Specify the path to a Melange build log file (or to a directory that 61 contains a build log file named "packages.log"). The build log file will be 62 parsed to find the APK files to scan. 63 64 4. Specify the name(s) of package(s) in the Wolfi package repository. The 65 latest versions of the package(s) for all supported architectures will be 66 downloaded from the Wolfi package repository and scanned. 67 68 ## FILTERING 69 70 By default, the command will print all vulnerabilities found in the package(s) 71 to stdout. You can filter the vulnerabilities shown using existing local 72 advisory data. To do this, you must first clone the advisory data from the 73 advisories repository for the distro whose packages you are scanning. You 74 specify the path to the local advisories repository using the 75 --advisories-repo-dir flag for the repository. Then, you can use the 76 "--advisory-filter" flag to specify which set of advisories to use for 77 filtering. The following sets of advisories are available: 78 79 - "resolved": Only filter out vulnerabilities that have been resolved in the 80 distro. 81 82 - "all": Filter out all vulnerabilities that are referenced from any advisory 83 in the advisories repository. 84 85 - "concluded": Only filter out all vulnerabilities that have been fixed, or those 86 where no change is planned to fix the vulnerability. 87 88 ## AUTO-TRIAGING 89 90 Wolfictl now supports auto-triaging vulnerabilities found in Go binaries using 91 govulncheck. To enable this feature, use the "--govulncheck" flag. Note that 92 this feature is experimental and may not work in all cases. For more 93 information on the govulncheck utility, see 94 https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck. Using this feature does 95 not require you to install govulncheck on your system (the functionality 96 required is included in wolfictl as a library). 97 98 For vulnerabilities known to govulncheck, this feature annotates each 99 vulnerability with a "true positive" or "false positive" designation. The JSON 100 output mode shows more information about the govulncheck triage results than 101 the default outline output mode. 102 103 This feature does not filter out any results from the scan output. 104 105 This feature is only supported when scanning APKs, not when scanning SBOMs. 106 107 ## OUTPUT 108 109 When a scan finishes, the command will print the results to stdout. There are 110 two modes of output that can be specified with the --output (or "-o") flag: 111 112 - "outline": This is the default output mode. It prints the results in a 113 human-readable outline format. 114 115 - "json": This mode prints the results in JSON format. This mode is useful for 116 machine processing of the results. 117 118 The command will exit with a non-zero exit code if any errors occur during the 119 scan. 120 121 The command will also exit with a non-zero exit code if any vulnerabilities are 122 found and the --require-zero flag is specified. 123 124 `, 125 Example: ` 126 # Scan a single APK file 127 wolfictl scan /path/to/package.apk 128 129 # Scan multiple APK files 130 wolfictl scan /path/to/package1.apk /path/to/package2.apk 131 132 # Scan a single SBOM file 133 wolfictl scan /path/to/package.sbom --sbom 134 135 # Scan a directory containing a build log file 136 wolfictl scan /path/to/build/log/dir --build-log 137 138 # Scan a single package in the Wolfi package repository 139 wolfictl scan package-name --remote 140 141 # Scan multiple packages in the Wolfi package repository 142 wolfictl scan package1 package2 --remote 143 `, 144 Args: cobra.MinimumNArgs(1), 145 SilenceErrors: true, 146 RunE: func(cmd *cobra.Command, args []string) error { 147 logger := clog.NewLogger(newLogger(p.verbosity)) 148 ctx := clog.WithLogger(cmd.Context(), logger) 149 150 if p.outputFormat == "" { 151 p.outputFormat = outputFormatOutline 152 } 153 154 // Validate inputs 155 156 if !slices.Contains(validOutputFormats, p.outputFormat) { 157 return fmt.Errorf( 158 "invalid output format %q, must be one of [%s]", 159 p.outputFormat, 160 strings.Join(validOutputFormats, ", "), 161 ) 162 } 163 164 if p.packageBuildLogInput && p.sbomInput || 165 p.packageBuildLogInput && p.remoteScanning || 166 p.sbomInput && p.remoteScanning { 167 return errors.New("cannot specify more than one of [--build-log, --sbom, --remote]") 168 } 169 170 if p.triageWithGoVulnCheck && p.sbomInput { 171 return errors.New("cannot specify both -s/--sbom and --govulncheck (govulncheck needs access to actual Go binaries)") 172 } 173 174 if p.advisoryFilterSet != "" { 175 if !slices.Contains(scan.ValidAdvisoriesSets, p.advisoryFilterSet) { 176 return fmt.Errorf( 177 "invalid advisory filter set %q, must be one of [%s]", 178 p.advisoryFilterSet, 179 strings.Join(scan.ValidAdvisoriesSets, ", "), 180 ) 181 } 182 183 if p.advisoriesRepoDir == "" { 184 return errors.New("advisory-based filtering requested, but no advisories repo dir was provided") 185 } 186 187 logger.Info("scan results will be filtered using advisory data", "filterSet", p.advisoryFilterSet, "advisoriesRepoDir", p.advisoriesRepoDir) 188 } 189 190 var advisoryDocumentIndex *configs.Index[v2.Document] 191 192 if p.advisoriesRepoDir != "" { 193 if p.advisoryFilterSet == "" { 194 return errors.New("advisories repo dir provided, but no advisory filter set was specified (see -f/--advisory-filter)") 195 } 196 197 dir := p.advisoriesRepoDir 198 advisoryFsys := rwos.DirFS(dir) 199 index, err := v2.NewIndex(cmd.Context(), advisoryFsys) 200 if err != nil { 201 return fmt.Errorf("unable to index advisory configs for directory %q: %w", dir, err) 202 } 203 advisoryDocumentIndex = index 204 } 205 206 inputs, cleanup, err := p.resolveInputsToScan(ctx, args) 207 if err != nil { 208 return err 209 } 210 defer func() { 211 if err := cleanup(); err != nil { 212 logger.Error("failed to clean up", "error", err) 213 return 214 } 215 216 logger.Debug("cleaned up after scan") 217 }() 218 219 scans, inputPathsFailingRequireZero, err := scanEverything(ctx, p, inputs, advisoryDocumentIndex) 220 if err != nil { 221 return err 222 } 223 224 if p.outputFormat == outputFormatJSON { 225 enc := json.NewEncoder(os.Stdout) 226 err := enc.Encode(scans) 227 if err != nil { 228 return fmt.Errorf("failed to marshal scans to JSON: %w", err) 229 } 230 } 231 232 if len(inputPathsFailingRequireZero) > 0 { 233 return fmt.Errorf("vulnerabilities found in the following package(s):\n%s", strings.Join(inputPathsFailingRequireZero, "\n")) 234 } 235 236 return nil 237 }, 238 } 239 240 p.addFlagsTo(cmd) 241 return cmd 242 } 243 244 func scanEverything(ctx context.Context, p *scanParams, inputs []string, advisoryDocumentIndex *configs.Index[v2.Document]) ([]scan.Result, []string, error) { 245 // We're going to generate the SBOMs concurrently, then scan them sequentially. 246 var g errgroup.Group 247 g.SetLimit(runtime.GOMAXPROCS(0) + 1) 248 249 // done is a slice of pseudo-promises that get closed when sboms[i] and files[i] are ready to scan. 250 // We do this to keep a deterministic scan order, which maybe we don't actually care about. 251 done := make([]chan struct{}, len(inputs)) 252 for i := range inputs { 253 done[i] = make(chan struct{}) 254 } 255 256 sboms := make([]*sbomSyft.SBOM, len(inputs)) 257 files := make([]*os.File, len(inputs)) 258 scans := make([]scan.Result, len(inputs)) 259 errs := make([]error, len(inputs)) 260 261 var inputPathsFailingRequireZero []string 262 263 // Immediately start a goroutine, so we can initialize the vulnerability database. 264 // Once that's finished, we will start to pull sboms off of done as they become ready. 265 g.Go(func() error { 266 scanner, err := scan.NewScanner(p.localDBFilePath, p.useCPEMatching) 267 if err != nil { 268 return fmt.Errorf("failed to create scanner: %w", err) 269 } 270 271 for i, ch := range done { 272 select { 273 case <-ctx.Done(): 274 return ctx.Err() 275 case <-ch: 276 } 277 278 input := inputs[i] 279 280 if err := errs[i]; err != nil { 281 if p.outputFormat == outputFormatOutline { 282 fmt.Printf("❌ Skipping scan because SBOM generation failed for %q: %v\n", input, err) 283 continue 284 } 285 } 286 287 file := files[i] 288 apkSBOM := sboms[i] 289 290 if p.outputFormat == outputFormatOutline { 291 fmt.Printf("🔎 Scanning %q\n", input) 292 } 293 294 result, err := p.doScanCommandForSingleInput(ctx, scanner, file, apkSBOM, advisoryDocumentIndex) 295 if err != nil { 296 return fmt.Errorf("failed to scan %q: %w", input, err) 297 } 298 299 scans[i] = *result 300 301 if p.requireZeroFindings && len(result.Findings) > 0 { 302 // Accumulate the list of failures to be returned at the end, but we still want to complete all scans 303 inputPathsFailingRequireZero = append(inputPathsFailingRequireZero, inputs[i]) 304 } 305 } 306 307 return nil 308 }) 309 310 for i, input := range inputs { 311 i, input := i, input 312 313 g.Go(func() error { 314 f := func() error { 315 inputFile, err := resolveInputFileFromArg(input) 316 if err != nil { 317 return fmt.Errorf("failed to open input file: %w", err) 318 } 319 320 // Get the SBOM of the APK 321 apkSBOM, err := p.generateSBOM(ctx, inputFile) 322 if err != nil { 323 return fmt.Errorf("failed to generate SBOM: %w", err) 324 } 325 326 sboms[i] = apkSBOM 327 files[i] = inputFile 328 329 return nil 330 } 331 332 errs[i] = f() 333 334 // Signals to the other goroutine that inputs[i] is ready to scan. 335 close(done[i]) 336 337 return nil 338 }) 339 } 340 341 return scans, inputPathsFailingRequireZero, errors.Join(g.Wait(), errors.Join(errs...)) 342 } 343 344 type scanParams struct { 345 requireZeroFindings bool 346 localDBFilePath string 347 outputFormat string 348 sbomInput bool 349 packageBuildLogInput bool 350 distro string 351 advisoryFilterSet string 352 advisoriesRepoDir string 353 disableSBOMCache bool 354 triageWithGoVulnCheck bool 355 remoteScanning bool 356 useCPEMatching bool 357 verbosity int 358 } 359 360 func (p *scanParams) addFlagsTo(cmd *cobra.Command) { 361 cmd.Flags().BoolVar(&p.requireZeroFindings, "require-zero", false, "exit 1 if any vulnerabilities are found") 362 cmd.Flags().StringVar(&p.localDBFilePath, "local-file-grype-db", "", "import a local grype db file") 363 cmd.Flags().StringVarP(&p.outputFormat, "output", "o", "", fmt.Sprintf("output format (%s), defaults to %s", strings.Join(validOutputFormats, "|"), outputFormatOutline)) 364 cmd.Flags().BoolVarP(&p.sbomInput, "sbom", "s", false, "treat input(s) as SBOM(s) of APK(s) instead of as actual APK(s)") 365 cmd.Flags().BoolVar(&p.packageBuildLogInput, "build-log", false, "treat input as a package build log file (or a directory that contains a packages.log file)") 366 cmd.Flags().StringVar(&p.distro, "distro", "wolfi", "distro to use during vulnerability matching") 367 cmd.Flags().StringVarP(&p.advisoryFilterSet, "advisory-filter", "f", "", fmt.Sprintf("exclude vulnerability matches that are referenced from the specified set of advisories (%s)", strings.Join(scan.ValidAdvisoriesSets, "|"))) 368 addAdvisoriesDirFlag(&p.advisoriesRepoDir, cmd) 369 cmd.Flags().BoolVar(&p.disableSBOMCache, "disable-sbom-cache", false, "don't use the SBOM cache") 370 cmd.Flags().BoolVar(&p.triageWithGoVulnCheck, "govulncheck", false, "EXPERIMENTAL: triage vulnerabilities in Go binaries using govulncheck") 371 _ = cmd.Flags().MarkHidden("govulncheck") //nolint:errcheck 372 cmd.Flags().BoolVarP(&p.remoteScanning, "remote", "r", false, "treat input(s) as the name(s) of package(s) in the Wolfi package repository to download and scan the latest versions of") 373 cmd.Flags().BoolVar(&p.useCPEMatching, "use-cpes", false, "turn on all CPE matching in Grype") 374 addVerboseFlag(&p.verbosity, cmd) 375 } 376 377 func (p *scanParams) resolveInputsToScan(ctx context.Context, args []string) (inputs []string, cleanup func() error, err error) { 378 logger := clog.FromContext(ctx) 379 380 var cleanupFuncs []func() error 381 switch { 382 case p.packageBuildLogInput: 383 if len(args) != 1 { 384 return nil, nil, fmt.Errorf("must specify exactly one build log file (or a directory that contains a %q build log file)", buildlog.DefaultName) 385 } 386 387 var err error 388 inputs, err = resolveInputFilePathsFromBuildLog(args[0]) 389 if err != nil { 390 return nil, nil, fmt.Errorf("failed to resolve scan inputs from build log: %w", err) 391 } 392 logger.Debug("resolved inputs from build log", "inputs", strings.Join(inputs, ", ")) 393 394 case p.remoteScanning: 395 // For each input, download the APK from the Wolfi package repository and update `inputs` to point to the downloaded APKs 396 397 if p.outputFormat == outputFormatOutline { 398 fmt.Println("📡 Finding remote packages") 399 } 400 401 for _, arg := range args { 402 targetPaths, cleanup, err := resolveInputForRemoteTarget(ctx, arg) 403 if err != nil { 404 return nil, nil, fmt.Errorf("failed to resolve input %q for remote scanning: %w", arg, err) 405 } 406 inputs = append(inputs, targetPaths...) 407 cleanupFuncs = append(cleanupFuncs, cleanup) 408 } 409 410 default: 411 inputs = args 412 } 413 414 cleanup = func() error { 415 var errs []error 416 for _, f := range cleanupFuncs { 417 if f == nil { 418 continue 419 } 420 if err := f(); err != nil { 421 errs = append(errs, err) 422 } 423 } 424 return errors.Join(errs...) 425 } 426 427 return inputs, cleanup, nil 428 } 429 430 func (p *scanParams) doScanCommandForSingleInput( 431 ctx context.Context, 432 scanner *scan.Scanner, 433 inputFile *os.File, 434 apkSBOM *sbomSyft.SBOM, 435 advisoryDocumentIndex *configs.Index[v2.Document], 436 ) (*scan.Result, error) { 437 result, err := scanner.APKSBOM(ctx, apkSBOM) 438 if err != nil { 439 return nil, fmt.Errorf("failed to scan APK: %w", err) 440 } 441 442 // If requested, triage vulnerabilities in Go binaries using govulncheck 443 444 if p.triageWithGoVulnCheck { 445 triagedFindings, err := scan.Triage(ctx, *result, inputFile) 446 if err != nil { 447 return nil, fmt.Errorf("failed to triage vulnerability matches: %w", err) 448 } 449 result.Findings = triagedFindings 450 } 451 452 inputFile.Close() 453 454 // If requested, filter scan results using advisories 455 456 if set := p.advisoryFilterSet; set != "" { 457 findings, err := scan.FilterWithAdvisories(ctx, *result, advisoryDocumentIndex, set) 458 if err != nil { 459 return nil, fmt.Errorf("failed to filter scan results with advisories: %w", err) 460 } 461 462 result.Findings = findings 463 } 464 465 // Handle CLI options 466 467 findings := result.Findings 468 if p.outputFormat == outputFormatOutline { 469 // Print output immediately 470 471 if len(findings) == 0 { 472 fmt.Println("✅ No vulnerabilities found") 473 } else { 474 tree := newFindingsTree(findings) 475 fmt.Println(tree.render()) 476 } 477 } 478 479 return result, nil 480 } 481 482 func (p *scanParams) generateSBOM(ctx context.Context, f *os.File) (*sbomSyft.SBOM, error) { 483 if p.sbomInput { 484 return sbom.FromSyftJSON(f) 485 } 486 487 if p.disableSBOMCache { 488 return sbom.Generate(ctx, f.Name(), f, p.distro) 489 } 490 491 return sbom.CachedGenerate(ctx, f.Name(), f, p.distro) 492 } 493 494 // resolveInputFilePathsFromBuildLog takes the given path to a Melange build log 495 // file (or a directory that contains the build log as a "packages.log" file). 496 // Once it finds the build log, it parses it, and returns a slice of file paths 497 // to APKs to be scanned. Each APK path is created with the assumption that the 498 // APKs are located at "$BASE/packages/$ARCH/$PACKAGE-$VERSION.apk", where $BASE 499 // is the buildLogPath if it's a directory, or the directory containing the 500 // buildLogPath if it's a file. 501 func resolveInputFilePathsFromBuildLog(buildLogPath string) ([]string, error) { 502 pathToFileOrDirectory := filepath.Clean(buildLogPath) 503 504 info, err := os.Stat(pathToFileOrDirectory) 505 if err != nil { 506 return nil, fmt.Errorf("failed to stat build log input: %w", err) 507 } 508 509 var pathToFile, packagesBaseDir string 510 if info.IsDir() { 511 pathToFile = filepath.Join(pathToFileOrDirectory, buildlog.DefaultName) 512 packagesBaseDir = pathToFileOrDirectory 513 } else { 514 pathToFile = pathToFileOrDirectory 515 packagesBaseDir = filepath.Dir(pathToFile) 516 } 517 518 file, err := os.Open(pathToFile) 519 if err != nil { 520 return nil, fmt.Errorf("failed to open build log: %w", err) 521 } 522 defer file.Close() 523 524 buildLogEntries, err := buildlog.Parse(file) 525 if err != nil { 526 return nil, fmt.Errorf("failed to parse build log: %w", err) 527 } 528 529 scanInputs := make([]string, 0, len(buildLogEntries)) 530 for _, entry := range buildLogEntries { 531 apkName := fmt.Sprintf("%s-%s.apk", entry.Package, entry.FullVersion) 532 apkPath := filepath.Join(packagesBaseDir, "packages", entry.Arch, apkName) 533 scanInputs = append(scanInputs, apkPath) 534 } 535 536 return scanInputs, nil 537 } 538 539 // resolveInputFileFromArg figures out how to interpret the given input file path 540 // to find a file to scan. This input file could be either an APK or an SBOM. 541 // The objective of this function is to find the file to scan and return a file 542 // handle to it. 543 // 544 // In order, it will: 545 // 546 // 1. If the path is "-", read stdin into a temp file and return that. 547 // 548 // 2. If the path starts with "https://", download the remote file into a temp file and return that. 549 // 550 // 3. Otherwise, open the file at the given path and return that. 551 func resolveInputFileFromArg(inputFilePath string) (*os.File, error) { 552 switch { 553 case inputFilePath == "-": 554 // Read stdin into a temp file. 555 t, err := os.CreateTemp("", "wolfictl-scan-") 556 if err != nil { 557 return nil, fmt.Errorf("failed to create temp file for stdin: %w", err) 558 } 559 if _, err := io.Copy(t, os.Stdin); err != nil { 560 return nil, err 561 } 562 if err := t.Close(); err != nil { 563 return nil, err 564 } 565 566 return t, nil 567 568 case strings.HasPrefix(inputFilePath, "https://"): 569 // Fetch the remote URL into a temp file. 570 t, err := os.CreateTemp("", "wolfictl-scan-") 571 if err != nil { 572 return nil, fmt.Errorf("failed to create temp file for remote: %w", err) 573 } 574 resp, err := http.Get(inputFilePath) //nolint:gosec 575 if err != nil { 576 return nil, fmt.Errorf("failed to download from remote: %w", err) 577 } 578 defer resp.Body.Close() 579 if resp.StatusCode != http.StatusOK { 580 all, err := io.ReadAll(resp.Body) 581 if err != nil { 582 return nil, err 583 } 584 return nil, fmt.Errorf("failed to download from remote (%d): %s", resp.StatusCode, string(all)) 585 } 586 if _, err := io.Copy(t, resp.Body); err != nil { 587 return nil, err 588 } 589 if err := t.Close(); err != nil { 590 return nil, err 591 } 592 593 return t, nil 594 595 default: 596 inputFile, err := os.Open(inputFilePath) 597 if err != nil { 598 return nil, fmt.Errorf("failed to open input file: %w", err) 599 } 600 601 return inputFile, nil 602 } 603 } 604 605 // resolveInputForRemoteTarget takes the given input string, which is expected 606 // to be the name of a Wolfi package (or subpackage), and it queries the Wolfi 607 // APK repository to find the latest version of the package for each 608 // architecture. It then downloads each APK and returns a slice of file paths to 609 // the downloaded APKs. 610 // 611 // For example, given the input value "calico", this function will find the 612 // latest version of the package (e.g. "calico-3.26.3-r3.apk") and download it 613 // for each architecture. 614 func resolveInputForRemoteTarget(ctx context.Context, input string) (downloadedAPKFilePaths []string, cleanup func() error, err error) { 615 logger := clog.FromContext(ctx) 616 617 for _, arch := range []string{"x86_64", "aarch64"} { 618 const apkRepositoryURL = "https://packages.wolfi.dev/os" 619 apkindex, err := index.Index(arch, apkRepositoryURL) 620 if err != nil { 621 return nil, nil, fmt.Errorf("getting APKINDEX: %w", err) 622 } 623 624 nameMatches := lo.Filter(apkindex.Packages, func(pkg *apk.Package, _ int) bool { 625 return pkg != nil && pkg.Name == input 626 }) 627 628 if len(nameMatches) == 0 { 629 return nil, nil, fmt.Errorf("no Wolfi package found with name %q in arch %q", input, arch) 630 } 631 632 vers := lo.Map(nameMatches, func(pkg *apk.Package, _ int) string { 633 return pkg.Version 634 }) 635 636 sort.Sort(versions.ByLatestStrings(vers)) 637 latestVersion := vers[0] 638 639 var latestPkg *apk.Package 640 for _, pkg := range nameMatches { 641 if pkg.Version == latestVersion { 642 latestPkg = pkg 643 break 644 } 645 } 646 downloadURL := fmt.Sprintf("%s/%s/%s", apkRepositoryURL, arch, latestPkg.Filename()) 647 648 apkTempFileName := fmt.Sprintf("%s-%s-%s-*.apk", arch, input, latestVersion) 649 tmpFile, err := os.CreateTemp("", apkTempFileName) 650 if err != nil { 651 return nil, nil, fmt.Errorf("creating temp dir: %w", err) 652 } 653 apkTmpFilePath := tmpFile.Name() 654 655 req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) 656 if err != nil { 657 return nil, nil, fmt.Errorf("creating request for %q: %w", downloadURL, err) 658 } 659 660 logger.Debug("downloading APK", "url", downloadURL) 661 resp, err := http.DefaultClient.Do(req) 662 if err != nil { 663 return nil, nil, fmt.Errorf("downloading %q: %w", downloadURL, err) 664 } 665 666 if resp.StatusCode != http.StatusOK { 667 return nil, nil, fmt.Errorf("downloading %q (status: %d): %w", downloadURL, resp.StatusCode, err) 668 } 669 _, err = io.Copy(tmpFile, resp.Body) 670 if err != nil { 671 return nil, nil, fmt.Errorf("saving contents of %q to %q: %w", downloadURL, apkTmpFilePath, err) 672 } 673 resp.Body.Close() 674 tmpFile.Close() 675 676 logger.Info("downloaded APK", "path", apkTmpFilePath) 677 678 downloadedAPKFilePaths = append(downloadedAPKFilePaths, apkTmpFilePath) 679 } 680 681 cleanup = func() error { 682 var errs []error 683 for _, path := range downloadedAPKFilePaths { 684 if err := os.Remove(path); err != nil { 685 errs = append(errs, err) 686 } 687 } 688 if len(errs) > 0 { 689 return fmt.Errorf("failed to clean up downloaded APKs: %w", errors.Join(errs...)) 690 } 691 return nil 692 } 693 694 return downloadedAPKFilePaths, cleanup, nil 695 } 696 697 type findingsTree struct { 698 findingsByPackageByLocation map[string]map[string][]scan.Finding 699 packagesByID map[string]scan.Package 700 } 701 702 func newFindingsTree(findings []scan.Finding) *findingsTree { 703 tree := make(map[string]map[string][]scan.Finding) 704 packagesByID := make(map[string]scan.Package) 705 706 for i := range findings { 707 f := findings[i] 708 loc := f.Package.Location 709 packageID := f.Package.ID 710 packagesByID[packageID] = f.Package 711 712 if _, ok := tree[loc]; !ok { 713 tree[loc] = make(map[string][]scan.Finding) 714 } 715 716 tree[loc][packageID] = append(tree[loc][packageID], f) 717 } 718 719 return &findingsTree{ 720 findingsByPackageByLocation: tree, 721 packagesByID: packagesByID, 722 } 723 } 724 725 func (t findingsTree) render() string { 726 locations := lo.Keys(t.findingsByPackageByLocation) 727 sort.Strings(locations) 728 729 var lines []string 730 for i, location := range locations { 731 var treeStem, verticalLine string 732 if i == len(locations)-1 { 733 treeStem = "└── " 734 verticalLine = " " 735 } else { 736 treeStem = "├── " 737 verticalLine = "│" 738 } 739 740 line := treeStem + fmt.Sprintf("📄 %s", location) 741 lines = append(lines, line) 742 743 packageIDs := lo.Keys(t.findingsByPackageByLocation[location]) 744 packages := lo.Map(packageIDs, func(id string, _ int) scan.Package { 745 return t.packagesByID[id] 746 }) 747 748 sort.SliceStable(packages, func(i, j int) bool { 749 return packages[i].Name < packages[j].Name 750 }) 751 752 for _, pkg := range packages { 753 line := fmt.Sprintf( 754 "%s 📦 %s %s %s", 755 verticalLine, 756 pkg.Name, 757 pkg.Version, 758 styleSubtle.Render("("+pkg.Type+")"), 759 ) 760 lines = append(lines, line) 761 762 findings := t.findingsByPackageByLocation[location][pkg.ID] 763 sort.SliceStable(findings, func(i, j int) bool { 764 return findings[i].Vulnerability.ID < findings[j].Vulnerability.ID 765 }) 766 767 for i := range findings { 768 f := findings[i] 769 line := fmt.Sprintf( 770 "%s %s %s%s%s", 771 verticalLine, 772 renderSeverity(f.Vulnerability.Severity), 773 renderVulnerabilityID(f.Vulnerability), 774 renderFixedIn(f.Vulnerability), 775 renderTriaging(verticalLine, f.TriageAssessments), 776 ) 777 lines = append(lines, line) 778 } 779 } 780 781 lines = append(lines, verticalLine) 782 } 783 784 return strings.Join(lines, "\n") 785 } 786 787 func renderSeverity(severity string) string { 788 switch severity { 789 case "Negligible": 790 return styleNegligible.Render(severity) 791 case "Low": 792 return styleLow.Render(severity) 793 case "Medium": 794 return styleMedium.Render(severity) 795 case "High": 796 return styleHigh.Render(severity) 797 case "Critical": 798 return styleCritical.Render(severity) 799 default: 800 return severity 801 } 802 } 803 804 func renderVulnerabilityID(vuln scan.Vulnerability) string { 805 var cveID string 806 807 for _, alias := range vuln.Aliases { 808 if strings.HasPrefix(alias, "CVE-") { 809 cveID = alias 810 break 811 } 812 } 813 814 if cveID == "" { 815 return hyperlinkVulnerabilityID(vuln.ID) 816 } 817 818 return fmt.Sprintf( 819 "%s %s", 820 hyperlinkVulnerabilityID(cveID), 821 822 styleSubtle.Render(hyperlinkVulnerabilityID(vuln.ID)), 823 ) 824 } 825 826 var termSupportsHyperlinks = termlink.SupportsHyperlinks() 827 828 func hyperlinkVulnerabilityID(id string) string { 829 if !termSupportsHyperlinks { 830 return id 831 } 832 833 switch { 834 case strings.HasPrefix(id, "CVE-"): 835 return termlink.Link(id, fmt.Sprintf("https://nvd.nist.gov/vuln/detail/%s", id)) 836 837 case strings.HasPrefix(id, "GHSA-"): 838 return termlink.Link(id, fmt.Sprintf("https://github.com/advisories/%s", id)) 839 } 840 841 return id 842 } 843 844 func renderFixedIn(vuln scan.Vulnerability) string { 845 if vuln.FixedVersion == "" { 846 return "" 847 } 848 849 return fmt.Sprintf(" fixed in %s", vuln.FixedVersion) 850 } 851 852 func renderTriaging(verticalLine string, trs []scan.TriageAssessment) string { 853 if len(trs) == 0 { 854 return "" 855 } 856 857 // Only show one line per triage source 858 seen := make(map[string]struct{}) 859 var lines []string 860 for _, tr := range trs { 861 if _, ok := seen[tr.Source]; ok { 862 continue 863 } 864 seen[tr.Source] = struct{}{} 865 lines = append(lines, renderTriageAssessment(verticalLine, tr)) 866 } 867 868 return "\n" + strings.Join(lines, "\n") 869 } 870 871 func renderTriageAssessment(verticalLine string, tr scan.TriageAssessment) string { 872 label := styles.Bold().Render(fmt.Sprintf("%t positive", tr.TruePositive)) 873 return fmt.Sprintf("%s ⚖️ %s according to %s", verticalLine, label, tr.Source) 874 } 875 876 var ( 877 styleSubtle = lipgloss.NewStyle().Foreground(lipgloss.Color("#999999")) 878 879 styleNegligible = lipgloss.NewStyle().Foreground(lipgloss.Color("#999999")) 880 styleLow = lipgloss.NewStyle().Foreground(lipgloss.Color("#00ff00")) 881 styleMedium = lipgloss.NewStyle().Foreground(lipgloss.Color("#ffff00")) 882 styleHigh = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff9900")) 883 styleCritical = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000")) 884 )