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  }