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 }