github.com/CycloneDX/sbom-utility@v0.16.0/cmd/vulnerability.go (about)

     1  // SPDX-License-Identifier: Apache-2.0
     2  /*
     3   * Licensed to the Apache Software Foundation (ASF) under one or more
     4   * contributor license agreements.  See the NOTICE file distributed with
     5   * this work for additional information regarding copyright ownership.
     6   * The ASF licenses this file to You under the Apache License, Version 2.0
     7   * (the "License"); you may not use this file except in compliance with
     8   * the License.  You may obtain a copy of the License at
     9   *
    10   *     http://www.apache.org/licenses/LICENSE-2.0
    11   *
    12   * Unless required by applicable law or agreed to in writing, software
    13   * distributed under the License is distributed on an "AS IS" BASIS,
    14   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    15   * See the License for the specific language governing permissions and
    16   * limitations under the License.
    17   */
    18  
    19  package cmd
    20  
    21  import (
    22  	"encoding/csv"
    23  	"fmt"
    24  	"io"
    25  	"sort"
    26  	"strings"
    27  	"text/tabwriter"
    28  
    29  	"github.com/CycloneDX/sbom-utility/common"
    30  	"github.com/CycloneDX/sbom-utility/schema"
    31  	"github.com/CycloneDX/sbom-utility/utils"
    32  	"github.com/jwangsadinata/go-multimap"
    33  	"github.com/spf13/cobra"
    34  )
    35  
    36  const (
    37  	SUBCOMMAND_VULNERABILITY_LIST = "list"
    38  )
    39  
    40  const (
    41  	FLAG_VULN_SUMMARY = "summary"
    42  )
    43  
    44  var VALID_SUBCOMMANDS_VULNERABILITY = []string{SUBCOMMAND_VULNERABILITY_LIST}
    45  
    46  // data (filter) keys
    47  const (
    48  	VULN_DATA_KEY_ID                     = "id"                     // summary
    49  	VULN_DATA_KEY_BOM_REF                = "bom-ref"                // full (optional, internal reference)
    50  	VULN_DATA_KEY_CWES                   = "cwe-ids"                // full (Common Weakness Enumeration (CWE))
    51  	VULN_DATA_KEY_CVSS_SEVERITY          = "cvss-severity"          // summary (CVSS Severity, V3.1 ot v2.0)
    52  	VULN_DATA_KEY_SOURCE_NAME            = "source-name"            // summary
    53  	VULN_DATA_KEY_SOURCE_URL             = "source-url"             // full
    54  	VULN_DATA_KEY_PUBLISHED              = "published"              // summary
    55  	VULN_DATA_KEY_UPDATED                = "updated"                // full
    56  	VULN_DATA_KEY_CREATED                = "created"                // full
    57  	VULN_DATA_KEY_REJECTED               = "rejected"               // full
    58  	VULN_DATA_KEY_ANALYSIS_STATE         = "analysis-state"         // full
    59  	VULN_DATA_KEY_ANALYSIS_JUSTIFICATION = "analysis-justification" // full
    60  	VULN_DATA_KEY_DESC                   = "description"            // summary
    61  )
    62  
    63  // NOTE: columns will be output in order they are listed here:
    64  // NOTE: data marked as "summary" data is informed by the output from the NVD database service itself
    65  // this includes fields that have ISO 8601 date-time fields are truncated to show date only
    66  var VULNERABILITY_LIST_ROW_DATA = []ColumnFormatData{
    67  	*NewColumnFormatData(VULN_DATA_KEY_ID, REPORT_DO_NOT_TRUNCATE, REPORT_SUMMARY_DATA, false),
    68  	*NewColumnFormatData(VULN_DATA_KEY_BOM_REF, REPORT_DO_NOT_TRUNCATE, false, false),
    69  	*NewColumnFormatData(VULN_DATA_KEY_CWES, REPORT_DO_NOT_TRUNCATE, false, false),
    70  	*NewColumnFormatData(VULN_DATA_KEY_CVSS_SEVERITY, REPORT_DO_NOT_TRUNCATE, REPORT_SUMMARY_DATA, false),
    71  	*NewColumnFormatData(VULN_DATA_KEY_SOURCE_NAME, REPORT_DO_NOT_TRUNCATE, REPORT_SUMMARY_DATA, false),
    72  	*NewColumnFormatData(VULN_DATA_KEY_SOURCE_URL, REPORT_DO_NOT_TRUNCATE, false, false),
    73  	*NewColumnFormatData(VULN_DATA_KEY_PUBLISHED, REPORT_DO_NOT_TRUNCATE, REPORT_SUMMARY_DATA, false),
    74  	*NewColumnFormatData(VULN_DATA_KEY_UPDATED, REPORT_DO_NOT_TRUNCATE, false, false),
    75  	*NewColumnFormatData(VULN_DATA_KEY_CREATED, REPORT_DO_NOT_TRUNCATE, false, false),
    76  	*NewColumnFormatData(VULN_DATA_KEY_REJECTED, REPORT_DO_NOT_TRUNCATE, false, false),
    77  	*NewColumnFormatData(VULN_DATA_KEY_ANALYSIS_STATE, REPORT_DO_NOT_TRUNCATE, false, false),
    78  	*NewColumnFormatData(VULN_DATA_KEY_ANALYSIS_JUSTIFICATION, REPORT_DO_NOT_TRUNCATE, false, false),
    79  	*NewColumnFormatData(VULN_DATA_KEY_DESC, VULN_TRUNCATE_DESC_LEN, REPORT_SUMMARY_DATA, REPORT_REPLACE_LINE_FEEDS_TRUE),
    80  }
    81  
    82  // TODO make configurable via flag
    83  const VULN_TRUNCATE_DESC_LEN = 32
    84  
    85  // Command help formatting
    86  const (
    87  	FLAG_VULNERABILITY_OUTPUT_FORMAT_HELP = "format vulnerability output"
    88  	FLAG_VULN_SUMMARY_HELP                = "summarize vulnerability information when listing in supported formats"
    89  )
    90  
    91  var VULNERABILITY_LIST_SUPPORTED_FORMATS = MSG_SUPPORTED_OUTPUT_FORMATS_HELP +
    92  	strings.Join([]string{FORMAT_TEXT, FORMAT_CSV, FORMAT_MARKDOWN, FORMAT_JSON}, ", ")
    93  
    94  // Vuln. command informational messages
    95  const (
    96  	MSG_OUTPUT_NO_VULNERABILITIES_FOUND = "[WARN] no matching vulnerabilities found for query"
    97  )
    98  
    99  func NewCommandVulnerability() *cobra.Command {
   100  	var command = new(cobra.Command)
   101  	command.Use = CMD_USAGE_VULNERABILITY_LIST
   102  	command.Short = "Report on vulnerabilities found in the BOM input file"
   103  	command.Long = "Report on vulnerabilities found in the BOM input file"
   104  	command.Flags().StringVarP(&utils.GlobalFlags.PersistentFlags.OutputFormat, FLAG_FILE_OUTPUT_FORMAT, "", FORMAT_TEXT,
   105  		FLAG_VULNERABILITY_OUTPUT_FORMAT_HELP+VULNERABILITY_LIST_SUPPORTED_FORMATS)
   106  	command.Flags().StringP(FLAG_REPORT_WHERE, "", "", FLAG_REPORT_WHERE_HELP)
   107  	command.Flags().BoolVarP(
   108  		&utils.GlobalFlags.VulnerabilityFlags.Summary,
   109  		FLAG_VULN_SUMMARY, "", false,
   110  		FLAG_VULN_SUMMARY_HELP)
   111  	command.RunE = vulnerabilityCmdImpl
   112  	command.ValidArgs = VALID_SUBCOMMANDS_VULNERABILITY
   113  	command.PreRunE = func(cmd *cobra.Command, args []string) (err error) {
   114  
   115  		// TODO: pre-validate if --where keys are valid for this command
   116  
   117  		// the vuln. command requires at least 1 valid subcommand (argument)
   118  		getLogger().Tracef("args: %v\n", args)
   119  		if len(args) == 0 {
   120  			return getLogger().Errorf("Missing required argument(s).")
   121  		} else if len(args) > 1 {
   122  			return getLogger().Errorf("Too many arguments provided: %v", args)
   123  		}
   124  
   125  		// Make sure subcommand is known
   126  		if !preRunTestForSubcommand(VALID_SUBCOMMANDS_VULNERABILITY, args[0]) {
   127  			return getLogger().Errorf("Subcommand provided is not valid: `%v`", args[0])
   128  		}
   129  
   130  		// Test for required flags (parameters)
   131  		err = preRunTestForInputFile(args)
   132  
   133  		return
   134  	}
   135  	return command
   136  }
   137  
   138  // Cobra command callback
   139  func vulnerabilityCmdImpl(cmd *cobra.Command, args []string) (err error) {
   140  	getLogger().Enter(args)
   141  	defer getLogger().Exit()
   142  
   143  	// Create output writer
   144  	outputFilename := utils.GlobalFlags.PersistentFlags.OutputFile
   145  	outputFile, writer, err := createOutputFile(outputFilename)
   146  	getLogger().Tracef("outputFile: `%v`; writer: `%v`", outputFile, writer)
   147  
   148  	// use function closure to assure consistent error output based upon error type
   149  	defer func() {
   150  		// always close the output file
   151  		if outputFile != nil {
   152  			err = outputFile.Close()
   153  			getLogger().Infof("Closed output file: `%s`", outputFilename)
   154  		}
   155  	}()
   156  
   157  	// process filters supplied on the --where command flag
   158  	whereFilters, err := processWhereFlag(cmd)
   159  
   160  	if err != nil {
   161  		return
   162  	}
   163  
   164  	err = ListVulnerabilities(writer, utils.GlobalFlags.PersistentFlags, utils.GlobalFlags.VulnerabilityFlags, whereFilters)
   165  	return
   166  }
   167  
   168  // Assure all errors are logged
   169  func processVulnerabilityListResults(err error) {
   170  	if err != nil {
   171  		// No special processing at this time
   172  		getLogger().Error(err)
   173  	}
   174  }
   175  
   176  func sortVulnerabilities(entries []multimap.Entry) {
   177  	// Sort by Id, Created date (descending)
   178  	sort.Slice(entries, func(i, j int) bool {
   179  		vuln1 := (entries[i].Value).(schema.VulnerabilityInfo)
   180  		vuln2 := (entries[j].Value).(schema.VulnerabilityInfo)
   181  		if vuln1.Id != vuln2.Id {
   182  			return vuln1.Id > vuln2.Id
   183  		}
   184  		return vuln1.Created > vuln2.Created
   185  	})
   186  }
   187  
   188  // NOTE: vulnerability type data has already been validated
   189  func ListVulnerabilities(writer io.Writer, persistentFlags utils.PersistentCommandFlags, flags utils.VulnerabilityCommandFlags, whereFilters []common.WhereFilter) (err error) {
   190  	getLogger().Enter()
   191  	defer getLogger().Exit()
   192  
   193  	// use function closure to assure consistent error output based upon error type
   194  	defer func() {
   195  		if err != nil {
   196  			processVulnerabilityListResults(err)
   197  		}
   198  	}()
   199  
   200  	// Note: returns error if either file load or unmarshal to JSON map fails
   201  	var document *schema.BOM
   202  	document, err = LoadInputBOMFileAndDetectSchema()
   203  
   204  	if err != nil {
   205  		return
   206  	}
   207  
   208  	// Hash all vulnerabilities within input file
   209  	getLogger().Infof("Scanning document for vulnerabilities...")
   210  	err = loadDocumentVulnerabilities(document, whereFilters)
   211  
   212  	if err != nil {
   213  		return
   214  	}
   215  
   216  	format := persistentFlags.OutputFormat
   217  	getLogger().Infof("Outputting listing (`%s` format)...", format)
   218  	switch format {
   219  	case FORMAT_TEXT:
   220  		err = DisplayVulnListText(document, writer, flags)
   221  	case FORMAT_CSV:
   222  		err = DisplayVulnListCSV(document, writer, flags)
   223  	case FORMAT_MARKDOWN:
   224  		err = DisplayVulnListMarkdown(document, writer, flags)
   225  	case FORMAT_JSON:
   226  		err = DisplayVulnListJson(document, writer, flags)
   227  	default:
   228  		// Default to Text output for anything else (set as flag default)
   229  		getLogger().Warningf("Listing not supported for `%s` format; defaulting to `%s` format...",
   230  			format, FORMAT_JSON)
   231  		err = DisplayVulnListText(document, writer, flags)
   232  	}
   233  	return
   234  }
   235  
   236  func loadDocumentVulnerabilities(document *schema.BOM, whereFilters []common.WhereFilter) (err error) {
   237  	getLogger().Enter()
   238  	defer getLogger().Exit(err)
   239  
   240  	// At this time, fail SPDX format SBOMs as "unsupported" (for "any" format)
   241  	if !document.FormatInfo.IsCycloneDx() {
   242  		err = schema.NewUnsupportedFormatForCommandError(
   243  			document.FormatInfo.CanonicalName,
   244  			document.GetFilename(),
   245  			CMD_LICENSE, FORMAT_ANY)
   246  		return
   247  	}
   248  
   249  	// Before looking for license data, fully unmarshal the SBOM
   250  	// into named structures
   251  	if err = document.UnmarshalCycloneDXBOM(); err != nil {
   252  		return
   253  	}
   254  
   255  	// Hash all components found in the (root).components[] (+ "nested" components)
   256  	pVulnerabilities := document.GetCdxVulnerabilities()
   257  	if pVulnerabilities != nil && len(*pVulnerabilities) > 0 {
   258  		if err = document.HashmapVulnerabilities(*pVulnerabilities, whereFilters); err != nil {
   259  			return
   260  		}
   261  	}
   262  
   263  	return
   264  }
   265  
   266  // NOTE: This list is NOT de-duplicated
   267  // TODO: Add a --no-title flag to skip title output
   268  func DisplayVulnListText(bom *schema.BOM, writer io.Writer, flags utils.VulnerabilityCommandFlags) (err error) {
   269  	getLogger().Enter()
   270  	defer getLogger().Exit()
   271  
   272  	// initialize tabwriter
   273  	w := new(tabwriter.Writer)
   274  	defer w.Flush()
   275  
   276  	// min-width, tab-width, padding, pad-char, flags
   277  	w.Init(writer, 8, 2, 2, ' ', 0)
   278  
   279  	// create title row and underline row from slices of optional and compulsory titles
   280  	titles, underlines := prepareReportTitleData(VULNERABILITY_LIST_ROW_DATA, flags.Summary)
   281  
   282  	// Add tabs between column titles for the tabWRiter
   283  	fmt.Fprintf(w, "%s\n", strings.Join(titles, "\t"))
   284  	fmt.Fprintf(w, "%s\n", strings.Join(underlines, "\t"))
   285  
   286  	// Display a warning "missing" in the actual output and return (short-circuit)
   287  	entries := bom.VulnerabilityMap.Entries()
   288  
   289  	// Emit no license warning into output
   290  	if len(entries) == 0 {
   291  		fmt.Fprintf(w, "%s\n", MSG_OUTPUT_NO_VULNERABILITIES_FOUND)
   292  		return
   293  	}
   294  
   295  	// Sort vulnerabilities prior to outputting
   296  	sortVulnerabilities(entries)
   297  
   298  	// Emit row data
   299  	var line []string
   300  	for _, entry := range entries {
   301  		// TODO surface error data to top-level command
   302  		line, err = prepareReportLineData(
   303  			entry.Value.(schema.VulnerabilityInfo),
   304  			VULNERABILITY_LIST_ROW_DATA,
   305  			flags.Summary,
   306  		)
   307  		// Only emit line if no error
   308  		if err != nil {
   309  			return
   310  		}
   311  		fmt.Fprintf(w, "%s\n", strings.Join(line, "\t"))
   312  	}
   313  	return
   314  }
   315  
   316  // TODO: Add a --no-title flag to skip title output
   317  func DisplayVulnListCSV(bom *schema.BOM, writer io.Writer, flags utils.VulnerabilityCommandFlags) (err error) {
   318  	getLogger().Enter()
   319  	defer getLogger().Exit()
   320  
   321  	// initialize writer and prepare the list of entries (i.e., the "rows")
   322  	w := csv.NewWriter(writer)
   323  	defer w.Flush()
   324  
   325  	// Create title row data as []string
   326  	titles, _ := prepareReportTitleData(VULNERABILITY_LIST_ROW_DATA, flags.Summary)
   327  
   328  	if err = w.Write(titles); err != nil {
   329  		return getLogger().Errorf("error writing to output (%v): %s", titles, err)
   330  	}
   331  
   332  	// Display a warning "missing" in the actual output and return (short-circuit)
   333  	entries := bom.VulnerabilityMap.Entries()
   334  
   335  	// Emit no vuln. found warning into output
   336  	if len(entries) == 0 {
   337  		currentRow := []string{MSG_OUTPUT_NO_VULNERABILITIES_FOUND}
   338  		if err = w.Write(currentRow); err != nil {
   339  			// unable to emit an error message into output stream
   340  			return getLogger().Errorf("error writing to output (%v): %s", currentRow, err)
   341  		}
   342  		return fmt.Errorf(currentRow[0])
   343  	}
   344  
   345  	// Sort vulnerabilities prior to outputting
   346  	sortVulnerabilities(entries)
   347  
   348  	// Emit row data
   349  	var line []string
   350  	for _, entry := range entries {
   351  		line, err = prepareReportLineData(
   352  			entry.Value.(schema.VulnerabilityInfo),
   353  			VULNERABILITY_LIST_ROW_DATA,
   354  			flags.Summary,
   355  		)
   356  		// Only emit line if no error
   357  		if err != nil {
   358  			return
   359  		}
   360  		if err = w.Write(line); err != nil {
   361  			err = getLogger().Errorf("csv.Write: %w", err)
   362  		}
   363  	}
   364  	return
   365  }
   366  
   367  // TODO: Add a --no-title flag to skip title output
   368  func DisplayVulnListMarkdown(bom *schema.BOM, writer io.Writer, flags utils.VulnerabilityCommandFlags) (err error) {
   369  	getLogger().Enter()
   370  	defer getLogger().Exit()
   371  
   372  	// Create title row data as []string, include columns depending on value of Summary flag.
   373  	titles, _ := prepareReportTitleData(VULNERABILITY_LIST_ROW_DATA, flags.Summary)
   374  	titleRow := createMarkdownRow(titles)
   375  	fmt.Fprintf(writer, "%s\n", titleRow)
   376  
   377  	// create alignment row, include columns depending on value of Summary flag.
   378  	alignments := createMarkdownColumnAlignmentRow(VULNERABILITY_LIST_ROW_DATA, flags.Summary)
   379  	alignmentRow := createMarkdownRow(alignments)
   380  	fmt.Fprintf(writer, "%s\n", alignmentRow)
   381  
   382  	// Display a warning "missing" in the actual output and return (short-circuit)
   383  	entries := bom.VulnerabilityMap.Entries()
   384  
   385  	// Emit no vuln. found warning into output
   386  	if len(entries) == 0 {
   387  		fmt.Fprintf(writer, "%s\n", MSG_OUTPUT_NO_VULNERABILITIES_FOUND)
   388  		return fmt.Errorf(MSG_OUTPUT_NO_VULNERABILITIES_FOUND)
   389  	}
   390  
   391  	// Sort vulnerabilities prior to outputting
   392  	sortVulnerabilities(entries)
   393  
   394  	// Emit row data
   395  	var line []string
   396  	var lineRow string
   397  	for _, entry := range entries {
   398  		line, err = prepareReportLineData(
   399  			entry.Value.(schema.VulnerabilityInfo),
   400  			VULNERABILITY_LIST_ROW_DATA,
   401  			flags.Summary,
   402  		)
   403  		// Only emit line if no error
   404  		if err != nil {
   405  			return
   406  		}
   407  		lineRow = createMarkdownRow(line)
   408  		fmt.Fprintf(writer, "%s\n", lineRow)
   409  	}
   410  	return
   411  }
   412  
   413  // Output filtered list of vulnerabilities as JSON
   414  func DisplayVulnListJson(bom *schema.BOM, writer io.Writer, flags utils.VulnerabilityCommandFlags) (err error) {
   415  	getLogger().Enter()
   416  	defer getLogger().Exit()
   417  
   418  	var vulnInfo schema.VulnerabilityInfo
   419  	var vulnList []schema.CDXVulnerability
   420  
   421  	for _, key := range bom.VulnerabilityMap.KeySet() {
   422  		arrVulnInfo, _ := bom.VulnerabilityMap.Get(key)
   423  
   424  		for _, iInfo := range arrVulnInfo {
   425  			vulnInfo = iInfo.(schema.VulnerabilityInfo)
   426  			vulnList = append(vulnList, vulnInfo.Vulnerability)
   427  		}
   428  	}
   429  
   430  	// Note: JSON data files MUST ends in a newline as this is a POSIX standard
   431  	// which is already accounted for by the JSON encoder.
   432  	_, err = utils.WriteAnyAsEncodedJSONInt(writer, vulnList, utils.GlobalFlags.PersistentFlags.GetOutputIndentInt())
   433  	return
   434  }