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  )