github.com/CycloneDX/sbom-utility@v0.16.0/cmd/license_list.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 "os" 26 "sort" 27 "strings" 28 "text/tabwriter" 29 30 "github.com/CycloneDX/sbom-utility/common" 31 "github.com/CycloneDX/sbom-utility/schema" 32 "github.com/CycloneDX/sbom-utility/utils" 33 "github.com/spf13/cobra" 34 ) 35 36 // Subcommand flags 37 // TODO: Support a new --sort <column> flag 38 const ( 39 FLAG_LICENSE_SUMMARY = "summary" 40 ) 41 42 // License list command flag help messages 43 const ( 44 FLAG_LICENSE_LIST_OUTPUT_FORMAT_HELP = "format output using the specified format type" 45 FLAG_LICENSE_LIST_SUMMARY_HELP = "summarize licenses and component references when listing in supported formats" 46 ) 47 48 // License list command informational messages 49 const ( 50 MSG_OUTPUT_NO_LICENSES_FOUND = "no licenses found in BOM document" 51 MSG_OUTPUT_NO_LICENSES_ONLY_NOASSERTION = "no valid licenses found in BOM document (only licenses marked NOASSERTION)" 52 ) 53 54 // filter keys 55 const ( 56 LICENSE_FILTER_KEY_USAGE_POLICY = "usage-policy" 57 LICENSE_FILTER_KEY_LICENSE_TYPE = "license-type" 58 LICENSE_FILTER_KEY_LICENSE = "license" 59 LICENSE_FILTER_KEY_RESOURCE_NAME = "resource-name" 60 LICENSE_FILTER_KEY_BOM_REF = "bom-ref" 61 LICENSE_FILTER_KEY_BOM_LOCATION = "bom-location" 62 ) 63 64 // var LICENSE_LIST_TITLES_LICENSE_CHOICE = []string{"License.Id", "License.Name", "License.Url", "Expression", "License.Text.ContentType", "License.Text.Encoding", "License.Text.Content"} 65 const ( 66 LICENSE_FILTER_KEY_LICENSE_ID = "license-id" 67 LICENSE_FILTER_KEY_LICENSE_NAME = "license-name" 68 LICENSE_FILTER_KEY_LICENSE_EXPRESSION = "license-expression" 69 LICENSE_FILTER_KEY_LICENSE_URL = "license-url" 70 LICENSE_FILTER_KEY_LICENSE_TEXT_ENCODING = "license-text-encoding" 71 LICENSE_FILTER_KEY_LICENSE_TEXT_CONTENT_TYPE = "license-text-content-type" 72 LICENSE_FILTER_KEY_LICENSE_TEXT_CONTENT = "license-text-content" 73 ) 74 75 var LICENSE_LIST_ROW_DATA = []ColumnFormatData{ 76 *NewColumnFormatData(LICENSE_FILTER_KEY_USAGE_POLICY, REPORT_DO_NOT_TRUNCATE, REPORT_SUMMARY_DATA, false), 77 *NewColumnFormatData(LICENSE_FILTER_KEY_LICENSE_TYPE, REPORT_DO_NOT_TRUNCATE, REPORT_SUMMARY_DATA, false), 78 *NewColumnFormatData(LICENSE_FILTER_KEY_LICENSE, REPORT_DO_NOT_TRUNCATE, REPORT_SUMMARY_DATA, false), 79 *NewColumnFormatData(LICENSE_FILTER_KEY_RESOURCE_NAME, REPORT_DO_NOT_TRUNCATE, REPORT_SUMMARY_DATA, false), 80 *NewColumnFormatData(LICENSE_FILTER_KEY_BOM_REF, REPORT_DO_NOT_TRUNCATE, REPORT_SUMMARY_DATA, false), 81 *NewColumnFormatData(LICENSE_FILTER_KEY_BOM_LOCATION, REPORT_DO_NOT_TRUNCATE, REPORT_SUMMARY_DATA, false), 82 *NewColumnFormatData(LICENSE_FILTER_KEY_LICENSE_ID, -1, false, false), 83 *NewColumnFormatData(LICENSE_FILTER_KEY_LICENSE_NAME, -1, false, false), 84 *NewColumnFormatData(LICENSE_FILTER_KEY_LICENSE_EXPRESSION, -1, false, false), 85 *NewColumnFormatData(LICENSE_FILTER_KEY_LICENSE_URL, -1, false, false), 86 *NewColumnFormatData(LICENSE_FILTER_KEY_LICENSE_TEXT_ENCODING, -1, false, false), 87 *NewColumnFormatData(LICENSE_FILTER_KEY_LICENSE_TEXT_CONTENT_TYPE, -1, false, false), 88 *NewColumnFormatData(LICENSE_FILTER_KEY_LICENSE_TEXT_CONTENT, 8, false, false), 89 } 90 91 // Command help formatting 92 var LICENSE_LIST_SUPPORTED_FORMATS = MSG_SUPPORTED_OUTPUT_FORMATS_HELP + 93 strings.Join([]string{FORMAT_JSON, FORMAT_CSV, FORMAT_MARKDOWN}, ", ") + 94 " (default: json)" 95 96 // WARNING: Cobra will not recognize a subcommand if its `command.Use` is not a single 97 // word string that matches one of the `command.ValidArgs` set on the parent command 98 func NewCommandList() *cobra.Command { 99 var command = new(cobra.Command) 100 command.Use = CMD_USAGE_LICENSE_LIST 101 command.Short = "List licenses found in the BOM input file" 102 command.Long = "List licenses and associated policies found in the BOM input file" 103 command.Flags().StringVarP(&utils.GlobalFlags.PersistentFlags.OutputFormat, FLAG_FILE_OUTPUT_FORMAT, "", "", 104 FLAG_LICENSE_LIST_OUTPUT_FORMAT_HELP+ 105 LICENSE_LIST_SUPPORTED_FORMATS) 106 command.Flags().BoolVarP( 107 &utils.GlobalFlags.LicenseFlags.Summary, 108 FLAG_LICENSE_SUMMARY, "", false, 109 FLAG_LICENSE_LIST_SUMMARY_HELP) 110 command.Flags().StringP(FLAG_REPORT_WHERE, "", "", FLAG_REPORT_WHERE_HELP) 111 command.RunE = listCmdImpl 112 command.PreRunE = func(cmd *cobra.Command, args []string) (err error) { 113 if len(args) != 0 { 114 return getLogger().Errorf("Too many arguments provided: %v", args) 115 } 116 117 // Test for required flags (parameters) 118 err = preRunTestForInputFile(args) 119 return 120 } 121 return (command) 122 } 123 124 // Assure all errors are logged 125 func processLicenseListResults(err error) { 126 if err != nil { 127 getLogger().Error(err) 128 } 129 } 130 131 func sortLicenseKeys(licenseKeys []interface{}) { 132 // Sort by license key (i.e., one of `id`, `name` or `expression`) 133 sort.Slice(licenseKeys, func(i, j int) bool { 134 return licenseKeys[i].(string) < licenseKeys[j].(string) 135 }) 136 } 137 138 // NOTE: parm. licenseKeys is actually a string slice 139 func checkLicenseListEmptyOrNoAssertionOnly(licenseKeys []interface{}) (empty bool) { 140 if len(licenseKeys) == 0 { 141 empty = true 142 getLogger().Warningf("%s\n", MSG_OUTPUT_NO_LICENSES_FOUND) 143 } else if len(licenseKeys) == 1 && licenseKeys[0].(string) == LICENSE_NO_ASSERTION { 144 empty = true 145 getLogger().Warningf("%s\n", MSG_OUTPUT_NO_LICENSES_ONLY_NOASSERTION) 146 } 147 return 148 } 149 150 // NOTE: The license command ONLY WORKS on CDX format 151 // NOTE: "list" commands need not validate (only unmarshal)... only report "none found" 152 // TODO: Perhaps make a --validate flag to allow optional validation prior to listing 153 func listCmdImpl(cmd *cobra.Command, args []string) (err error) { 154 getLogger().Enter(args) 155 defer getLogger().Exit() 156 157 // Create output writer 158 outputFilename := utils.GlobalFlags.PersistentFlags.OutputFile 159 outputFile, writer, err := createOutputFile(outputFilename) 160 161 // use function closure to assure consistent error output based upon error type 162 defer func() { 163 // always close the output file 164 if outputFile != nil { 165 err = outputFile.Close() 166 getLogger().Infof("Closed output file: `%s`", outputFilename) 167 } 168 }() 169 170 // process filters supplied on the --where command flag 171 whereFilters, err := processWhereFlag(cmd) 172 if err != nil { 173 return 174 } 175 176 // Use global license policy config. as loaded by initConfigurations() as 177 // using (optional) filename passed on command line OR the default, built-in config. 178 err = ListLicenses(writer, LicensePolicyConfig, 179 utils.GlobalFlags.PersistentFlags, utils.GlobalFlags.LicenseFlags, 180 whereFilters) 181 182 return 183 } 184 185 func ListLicenses(writer io.Writer, policyConfig *schema.LicensePolicyConfig, 186 persistentFlags utils.PersistentCommandFlags, licenseFlags utils.LicenseCommandFlags, 187 whereFilters []common.WhereFilter) (err error) { 188 getLogger().Enter() 189 defer getLogger().Exit() 190 191 // use function closure to assure consistent error output based upon error type 192 defer func() { 193 if err != nil { 194 processLicenseListResults(err) 195 } 196 }() 197 198 // Note: returns error if either file load or unmarshal to JSON map fails 199 var document *schema.BOM 200 document, err = LoadInputBOMFileAndDetectSchema() 201 202 if err != nil { 203 return 204 } 205 206 // Find an hash all licenses within input BOM file 207 getLogger().Infof("Scanning document for licenses...") 208 err = loadDocumentLicenses(document, policyConfig, whereFilters, licenseFlags) 209 210 if err != nil { 211 return 212 } 213 214 format := persistentFlags.OutputFormat 215 getLogger().Infof("Outputting listing (`%s` format)...", format) 216 switch format { 217 case FORMAT_JSON: 218 err = DisplayLicenseListJson(document, writer, licenseFlags) 219 case FORMAT_CSV: 220 err = DisplayLicenseListCSV(document, writer, licenseFlags) 221 case FORMAT_MARKDOWN: 222 err = DisplayLicenseListMarkdown(document, writer, licenseFlags) 223 case FORMAT_TEXT: 224 err = DisplayLicenseListText(document, writer, licenseFlags) 225 default: 226 // Default to JSON output for anything else 227 getLogger().Warningf("Listing not supported for `%s` format; defaulting to `%s` format...", 228 format, FORMAT_JSON) 229 err = DisplayLicenseListJson(document, writer, licenseFlags) 230 } 231 return 232 } 233 234 // NOTE: This list is NOT de-duplicated 235 // NOTE: if no licenses are found, the "json.Marshal" method(s) will return a value of "null" 236 // which is valid JSON (and not an empty array) 237 // TODO: Support de-duplication (flag) (which MUST be exact using deep comparison) 238 func DisplayLicenseListJson(bom *schema.BOM, writer io.Writer, flags utils.LicenseCommandFlags) (err error) { 239 getLogger().Enter() 240 defer getLogger().Exit() 241 242 var licenseInfo schema.LicenseInfo 243 var lc []schema.CDXLicenseChoice 244 245 for _, licenseName := range bom.LicenseMap.KeySet() { 246 arrLicenseInfo, _ := bom.LicenseMap.Get(licenseName) 247 248 for _, iInfo := range arrLicenseInfo { 249 licenseInfo = iInfo.(schema.LicenseInfo) 250 if licenseInfo.LicenseChoiceTypeValue != schema.LC_TYPE_INVALID { 251 lc = append(lc, licenseInfo.LicenseChoice) 252 } 253 } 254 } 255 256 // Note: JSON data files MUST ends in a newline as this is a POSIX standard 257 // which is already accounted for by the JSON encoder. 258 _, err = utils.WriteAnyAsEncodedJSONInt(writer, lc, utils.GlobalFlags.PersistentFlags.GetOutputIndentInt()) 259 return 260 } 261 262 // NOTE: This list is NOT de-duplicated 263 // TODO: Make policy column optional 264 // TODO: Add a --no-title flag to skip title output 265 // TODO: Support a new --sort <column> flag 266 func DisplayLicenseListText(bom *schema.BOM, writer io.Writer, flags utils.LicenseCommandFlags) (err error) { 267 getLogger().Enter() 268 defer getLogger().Exit() 269 270 // initialize tabwriter 271 w := new(tabwriter.Writer) 272 defer w.Flush() 273 274 // min-width, tab-width, padding, pad-char, flags 275 w.Init(writer, 8, 2, 2, ' ', 0) 276 277 // create title row and underline row from slices of optional and compulsory titles 278 titles, underlines := prepareReportTitleData(LICENSE_LIST_ROW_DATA, flags.Summary) 279 280 // Add tabs between column titles for the tabWRiter 281 fmt.Fprintf(w, "%s\n", strings.Join(titles, "\t")) 282 fmt.Fprintf(w, "%s\n", strings.Join(underlines, "\t")) 283 284 // Display a warning missing in the actual output and return (short-circuit) 285 licenseKeys := bom.LicenseMap.KeySet() 286 287 // Emit no license or assertion-only warning into output 288 checkLicenseListEmptyOrNoAssertionOnly(licenseKeys) 289 290 // Sort license using identifying key (i.e., `id`, `name` or `expression`) 291 sortLicenseKeys(licenseKeys) 292 293 // output the each license entry as a row 294 var line []string 295 var licenseInfo schema.LicenseInfo 296 var content string 297 298 for _, licenseName := range licenseKeys { 299 arrLicenseInfo, _ := bom.LicenseMap.Get(licenseName) 300 301 // Format each LicenseInfo as a line and write to output 302 for _, iInfo := range arrLicenseInfo { 303 licenseInfo = iInfo.(schema.LicenseInfo) 304 lc := licenseInfo.LicenseChoice 305 306 // NOTE: we only truncate the content text for Text (console) output 307 // TODO perhaps add flag to allow user to specify truncate length (default 8) 308 // See field "DefaultTruncateLength" in ColumnFormatData struct 309 if lc.License != nil && lc.License.Text != nil { 310 content = lc.License.Text.GetContentTruncated(8, true) 311 licenseInfo.LicenseTextContent = content 312 } 313 314 line, err = prepareReportLineData( 315 licenseInfo, 316 LICENSE_LIST_ROW_DATA, 317 flags.Summary, 318 ) 319 // Only emit line if no error 320 if err != nil { 321 return 322 } 323 fmt.Fprintf(w, "%s\n", strings.Join(line, "\t")) 324 } 325 } 326 return 327 } 328 329 // NOTE: This list is NOT de-duplicated 330 // TODO: Make policy column optional 331 // TODO: Add a --no-title flag to skip title output 332 // TODO: Support a new --sort <column> flag 333 func DisplayLicenseListCSV(bom *schema.BOM, writer io.Writer, flags utils.LicenseCommandFlags) (err error) { 334 getLogger().Enter() 335 defer getLogger().Exit() 336 337 // initialize writer and prepare the list of entries (i.e., the "rows") 338 w := csv.NewWriter(writer) 339 defer w.Flush() 340 341 // create title row 342 // TODO: Make policy column optional 343 titles, _ := prepareReportTitleData(LICENSE_LIST_ROW_DATA, flags.Summary) 344 345 if err = w.Write(titles); err != nil { 346 return getLogger().Errorf("error writing to output (%v): %s", titles, err) 347 } 348 349 // retrieve all hashed licenses (keys) found in the document and verify we have ones to process 350 licenseKeys := bom.LicenseMap.KeySet() 351 352 // Emit no license or assertion-only warning into output 353 checkLicenseListEmptyOrNoAssertionOnly(licenseKeys) 354 355 // Sort license using identifying key (i.e., `id`, `name` or `expression`) 356 sortLicenseKeys(licenseKeys) 357 358 // output the each license entry as a row 359 var line []string 360 var licenseInfo schema.LicenseInfo 361 362 for _, licenseName := range licenseKeys { 363 arrLicenseInfo, _ := bom.LicenseMap.Get(licenseName) 364 365 // An SBOM SHOULD always contain at least 1 (declared) license 366 if len(arrLicenseInfo) == 0 { 367 // TODO: pass in sbom document to this fx to (in turn) pass to the error constructor 368 getLogger().Error(NewSbomLicenseNotFoundError(nil)) 369 os.Exit(ERROR_VALIDATION) 370 } 371 372 // Format each LicenseInfo as a line and write to output 373 for _, iInfo := range arrLicenseInfo { 374 licenseInfo = iInfo.(schema.LicenseInfo) 375 line, err = prepareReportLineData( 376 licenseInfo, 377 LICENSE_LIST_ROW_DATA, 378 flags.Summary, 379 ) 380 // Only emit line if no error 381 if err != nil { 382 return 383 } 384 if err = w.Write(line); err != nil { 385 err = getLogger().Errorf("csv.Write: %w", err) 386 } 387 } 388 } 389 return 390 } 391 392 // NOTE: This list is NOT de-duplicated 393 func DisplayLicenseListMarkdown(bom *schema.BOM, writer io.Writer, flags utils.LicenseCommandFlags) (err error) { 394 getLogger().Enter() 395 defer getLogger().Exit() 396 397 titles, _ := prepareReportTitleData(LICENSE_LIST_ROW_DATA, flags.Summary) 398 titleRow := createMarkdownRow(titles) 399 fmt.Fprintf(writer, "%s\n", titleRow) 400 401 // create alignment row, include all columns that are flagged "summary" data 402 alignments := createMarkdownColumnAlignmentRow(LICENSE_LIST_ROW_DATA, flags.Summary) 403 alignmentRow := createMarkdownRow(alignments) 404 fmt.Fprintf(writer, "%s\n", alignmentRow) 405 406 // Display a warning messing in the actual output and return (short-circuit) 407 licenseKeys := bom.LicenseMap.KeySet() 408 409 // Emit no license or assertion-only warning into output 410 checkLicenseListEmptyOrNoAssertionOnly(licenseKeys) 411 412 // output the each license entry as a row 413 var line []string 414 var lineRow string 415 var licenseInfo schema.LicenseInfo 416 417 for _, licenseName := range licenseKeys { 418 arrLicenseInfo, _ := bom.LicenseMap.Get(licenseName) 419 420 // Format each LicenseInfo as a line and write to output 421 for _, iInfo := range arrLicenseInfo { 422 licenseInfo = iInfo.(schema.LicenseInfo) 423 line, err = prepareReportLineData( 424 licenseInfo, 425 LICENSE_LIST_ROW_DATA, 426 flags.Summary, 427 ) 428 // Only emit line if no error 429 if err != nil { 430 return 431 } 432 lineRow = createMarkdownRow(line) 433 fmt.Fprintf(writer, "%s\n", lineRow) 434 } 435 } 436 return 437 }