github.com/wolfi-dev/wolfictl@v0.16.11/pkg/cli/sbom.go (about)

     1  package cli
     2  
     3  import (
     4  	"fmt"
     5  	"io"
     6  	"os"
     7  	"sort"
     8  	"strings"
     9  
    10  	"github.com/anchore/syft/syft/file"
    11  	"github.com/anchore/syft/syft/pkg"
    12  	sbomSyft "github.com/anchore/syft/syft/sbom"
    13  	"github.com/chainguard-dev/clog"
    14  	"github.com/samber/lo"
    15  	"github.com/spf13/cobra"
    16  	"github.com/wolfi-dev/wolfictl/pkg/sbom"
    17  	"golang.org/x/exp/slices"
    18  )
    19  
    20  const (
    21  	sbomFormatOutline  = "outline"
    22  	sbomFormatSyftJSON = "syft-json"
    23  )
    24  
    25  func cmdSBOM() *cobra.Command {
    26  	p := &sbomParams{}
    27  	cmd := &cobra.Command{
    28  		Use:           "sbom <path/to/package.apk>",
    29  		Short:         "Generate a software bill of materials (SBOM) for an APK file",
    30  		Hidden:        true,
    31  		SilenceErrors: true,
    32  		Args:          cobra.ExactArgs(1),
    33  		RunE: func(cmd *cobra.Command, args []string) error {
    34  			logger := clog.NewLogger(newLogger(p.verbosity))
    35  			ctx := clog.WithLogger(cmd.Context(), logger)
    36  
    37  			if !slices.Contains([]string{sbomFormatOutline, sbomFormatSyftJSON}, p.outputFormat) {
    38  				return fmt.Errorf("invalid output format %q, must be one of [%s]", p.outputFormat, strings.Join([]string{sbomFormatOutline, sbomFormatSyftJSON}, ", "))
    39  			}
    40  
    41  			// TODO: Bring input retrieval options in line with `wolfictl scan`.
    42  
    43  			apkFilePath := args[0]
    44  			apkFile, err := os.Open(apkFilePath)
    45  			if err != nil {
    46  				return fmt.Errorf("failed to open apk file: %w", err)
    47  			}
    48  
    49  			if p.outputFormat == outputFormatOutline {
    50  				fmt.Printf("🔎 Scanning %q\n", apkFilePath)
    51  			}
    52  
    53  			var s *sbomSyft.SBOM
    54  			if p.disableSBOMCache {
    55  				s, err = sbom.Generate(ctx, apkFilePath, apkFile, p.distro)
    56  			} else {
    57  				s, err = sbom.CachedGenerate(ctx, apkFilePath, apkFile, p.distro)
    58  			}
    59  			if err != nil {
    60  				return fmt.Errorf("failed to generate SBOM: %w", err)
    61  			}
    62  
    63  			switch p.outputFormat {
    64  			case sbomFormatOutline:
    65  				tree := newPackageTree(s.Artifacts.Packages.Sorted())
    66  				fmt.Println(tree.render())
    67  
    68  			case sbomFormatSyftJSON:
    69  				jsonReader, err := sbom.ToSyftJSON(s)
    70  				if err != nil {
    71  					return fmt.Errorf("failed to encode SBOM: %w", err)
    72  				}
    73  
    74  				_, err = io.Copy(os.Stdout, jsonReader)
    75  				if err != nil {
    76  					return fmt.Errorf("failed to write SBOM: %w", err)
    77  				}
    78  			}
    79  
    80  			return nil
    81  		},
    82  	}
    83  
    84  	p.addFlagsTo(cmd)
    85  	return cmd
    86  }
    87  
    88  type sbomParams struct {
    89  	outputFormat     string
    90  	distro           string
    91  	disableSBOMCache bool
    92  	verbosity        int
    93  }
    94  
    95  func (p *sbomParams) addFlagsTo(cmd *cobra.Command) {
    96  	cmd.Flags().StringVarP(&p.outputFormat, "output", "o", sbomFormatOutline, "output format (outline, syft-json)")
    97  	cmd.Flags().StringVar(&p.distro, "distro", "wolfi", "distro to report in SBOM")
    98  	cmd.Flags().BoolVar(&p.disableSBOMCache, "disable-sbom-cache", false, "don't use the SBOM cache")
    99  	addVerboseFlag(&p.verbosity, cmd)
   100  }
   101  
   102  type packageTree struct {
   103  	packagesByLocation map[string][]pkg.Package
   104  }
   105  
   106  func newPackageTree(packages []pkg.Package) *packageTree {
   107  	packagesByLocation := map[string][]pkg.Package{}
   108  	for i := range packages {
   109  		p := packages[i]
   110  		locs := lo.Map(p.Locations.ToSlice(), func(l file.Location, _ int) string {
   111  			return "/" + l.RealPath
   112  		})
   113  
   114  		location := strings.Join(locs, ", ")
   115  		packagesByLocation[location] = append(packagesByLocation[location], p)
   116  	}
   117  	return &packageTree{
   118  		packagesByLocation: packagesByLocation,
   119  	}
   120  }
   121  
   122  func (t *packageTree) render() string {
   123  	locations := lo.Keys(t.packagesByLocation)
   124  	sort.Strings(locations)
   125  
   126  	var lines []string
   127  	for i, location := range locations {
   128  		var treeStem, verticalLine string
   129  		if i == len(locations)-1 {
   130  			treeStem = "└── "
   131  			verticalLine = " "
   132  		} else {
   133  			treeStem = "├── "
   134  			verticalLine = "│"
   135  		}
   136  
   137  		line := treeStem + fmt.Sprintf("📄 %s", location)
   138  		lines = append(lines, line)
   139  
   140  		packages := t.packagesByLocation[location]
   141  
   142  		sort.SliceStable(packages, func(i, j int) bool {
   143  			return packages[i].Name < packages[j].Name
   144  		})
   145  
   146  		for i := range packages {
   147  			p := packages[i]
   148  			line := fmt.Sprintf(
   149  				"%s       📦 %s %s %s",
   150  				verticalLine,
   151  				p.Name,
   152  				p.Version,
   153  				styleSubtle.Render("("+string(p.Type)+")"),
   154  			)
   155  			lines = append(lines, line)
   156  		}
   157  
   158  		lines = append(lines, verticalLine)
   159  	}
   160  
   161  	return strings.Join(lines, "\n")
   162  }