github.com/CycloneDX/sbom-utility@v0.16.0/cmd/root.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  	"fmt"
    23  	"io"
    24  	"os"
    25  	"path/filepath"
    26  
    27  	"github.com/CycloneDX/sbom-utility/log"
    28  	"github.com/CycloneDX/sbom-utility/schema"
    29  	"github.com/CycloneDX/sbom-utility/utils"
    30  	"github.com/spf13/cobra"
    31  )
    32  
    33  // Globals
    34  var ProjectLogger *log.MiniLogger
    35  var LicensePolicyConfig *schema.LicensePolicyConfig
    36  var SupportedFormatConfig schema.BOMFormatAndSchemaConfig
    37  
    38  // top-level commands
    39  const (
    40  	CMD_COMPONENT     = "component"
    41  	CMD_DIFF          = "diff"
    42  	CMD_LICENSE       = "license"
    43  	CMD_QUERY         = "query"
    44  	CMD_RESOURCE      = "resource"
    45  	CMD_SCHEMA        = "schema"
    46  	CMD_VALIDATE      = "validate"
    47  	CMD_VERSION       = "version"
    48  	CMD_VULNERABILITY = "vulnerability"
    49  	CMD_STATS         = "stats"
    50  	CMD_TRIM          = "trim"
    51  	CMD_PATCH         = "patch"
    52  )
    53  
    54  // WARNING!!! The ".Use" field of a Cobra command MUST have the first word be the actual command
    55  // otherwise, the command will NOT be found by the Cobra framework. This is poor code assumption is NOT documented.
    56  const (
    57  	CMD_USAGE_COMPONENT_LIST     = CMD_COMPONENT + " " + SUBCOMMAND_LICENSE_LIST + " --input-file <input_file> [--type type1[,typeN]>] [--where key=regex[,...]] [--format txt|csv|md]"
    58  	CMD_USAGE_DIFF               = CMD_DIFF + " --input-file <base_file> --input-revision <revised_file> [--format json|txt] [--colorize=true|false]"
    59  	CMD_USAGE_LICENSE_LIST       = SUBCOMMAND_LICENSE_LIST + " --input-file <input_file> [--summary] [--where key=regex[,...]] [--format json|txt|csv|md]"
    60  	CMD_USAGE_LICENSE_POLICY     = SUBCOMMAND_LICENSE_POLICY + " [--where key=regex[,...]] [--format txt|csv|md]"
    61  	CMD_USAGE_QUERY              = CMD_QUERY + " --input-file <input_file> [--select * | field1[,fieldN]] [--from key1[.keyN]] [--where key=regex[,...]]"
    62  	CMD_USAGE_RESOURCE_LIST      = CMD_RESOURCE + " --input-file <input_file> [--type component|service] [--where key=regex[,...]] [--format txt|csv|md]"
    63  	CMD_USAGE_SCHEMA_LIST        = CMD_SCHEMA + " [--where key=regex[,...]] [--format txt|csv|md]"
    64  	CMD_USAGE_VALIDATE           = CMD_VALIDATE + " --input-file <input_file> [--variant <variant_name>] [--format txt|json] [--force schema_file]"
    65  	CMD_USAGE_VULNERABILITY_LIST = CMD_VULNERABILITY + " " + SUBCOMMAND_VULNERABILITY_LIST + " --input-file <input_file> [--summary] [--where key=regex[,...]] [--format json|txt|csv|md]"
    66  	CMD_USAGE_STATS_LIST         = CMD_STATS + " --input-file <input_file> [--type component|service] [--format txt|csv|md]"
    67  	CMD_USAGE_TRIM               = CMD_TRIM + " --input-file <input_file>  --output-file <output_file> [--normalize]"
    68  	CMD_USAGE_PATCH              = CMD_PATCH + " --input-file <input_file> --patch-file <patch_file> --output-file <output_file>"
    69  )
    70  
    71  const (
    72  	FLAG_CONFIG_SCHEMA            = "config-schema"
    73  	FLAG_CONFIG_LICENSE_POLICY    = "config-license"
    74  	FLAG_CONFIG_CUSTOM_VALIDATION = "config-validation"
    75  	FLAG_TRACE                    = "trace"
    76  	FLAG_TRACE_SHORT              = "t"
    77  	FLAG_DEBUG                    = "debug"
    78  	FLAG_DEBUG_SHORT              = "d"
    79  	FLAG_FILENAME_INPUT           = "input-file"
    80  	FLAG_FILENAME_INPUT_SHORT     = "i"
    81  	FLAG_FILENAME_OUTPUT          = "output-file"
    82  	FLAG_FILENAME_OUTPUT_SHORT    = "o"
    83  	FLAG_QUIET_MODE               = "quiet"
    84  	FLAG_QUIET_MODE_SHORT         = "q"
    85  	FLAG_OUTPUT_INDENT            = "indent"
    86  	FLAG_LOG_OUTPUT_INDENT        = "log-indent"
    87  	FLAG_FILE_OUTPUT_FORMAT       = "format"
    88  	FLAG_COLORIZE_OUTPUT          = "colorize"
    89  	FLAG_OUTPUT_NORMALIZE         = "normalize"
    90  )
    91  
    92  const (
    93  	MSG_APP_NAME              = "Bill-of-Materials (BOM) utility."
    94  	MSG_APP_DESCRIPTION       = "This utility serves as centralized command-line interface for various Bill-of-Materials (BOM) helper utilities."
    95  	MSG_FLAG_TRACE            = "enable trace logging"
    96  	MSG_FLAG_DEBUG            = "enable debug logging"
    97  	MSG_FLAG_INPUT            = "input filename (e.g., \"path/sbom.json\")"
    98  	MSG_FLAG_OUTPUT           = "output filename"
    99  	MSG_FLAG_OUTPUT_FORMAT    = "format output using the specified type"
   100  	MSG_FLAG_LOG_QUIET        = "enable quiet logging mode (removes all informational messages from console output); overrides other logging commands"
   101  	MSG_FLAG_LOG_INDENT       = "enable log indentation of functional callstack"
   102  	MSG_FLAG_CONFIG_SCHEMA    = "provide custom application schema configuration file (i.e., overrides default `config.json`)"
   103  	MSG_FLAG_CONFIG_LICENSE   = "provide custom application license policy configuration file (i.e., overrides default `license.json`)"
   104  	MSG_FLAG_OUTPUT_INDENT    = "number of space characters used to indent JSON formatted output"
   105  	MSG_FLAG_OUTPUT_NORMALIZE = "Normalize BOM document"
   106  )
   107  
   108  const (
   109  	MSG_SUPPORTED_OUTPUT_FORMATS_HELP         = "\n- Supported formats: "
   110  	MSG_SUPPORTED_OUTPUT_FORMATS_SUMMARY_HELP = "\n- Supported formats using the --summary flag: "
   111  )
   112  
   113  const (
   114  	DEFAULT_SCHEMA_CONFIG            = "config.json"
   115  	DEFAULT_CUSTOM_VALIDATION_CONFIG = "custom.json"
   116  	DEFAULT_LICENSE_POLICY_CONFIG    = "license.json"
   117  )
   118  
   119  // Supported output formats
   120  const (
   121  	FORMAT_DEFAULT  = ""
   122  	FORMAT_TEXT     = "txt"
   123  	FORMAT_JSON     = "json"
   124  	FORMAT_CSV      = "csv"
   125  	FORMAT_MARKDOWN = "md"
   126  	FORMAT_ANY      = "<any>" // Used for test errors
   127  )
   128  
   129  // TODO: make flag configurable:
   130  // NOTE: 4-space indent is accepted convention:
   131  // https://docs.openstack.org/doc-contrib-guide/json-conv.html
   132  const (
   133  	DEFAULT_OUTPUT_INDENT_LENGTH = 4
   134  )
   135  
   136  // Command reserved values
   137  const (
   138  	INPUT_TYPE_STDIN = "-"
   139  )
   140  
   141  var rootCmd = &cobra.Command{
   142  	Use:           fmt.Sprintf("%s [command] [flags]", utils.GlobalFlags.Project),
   143  	SilenceErrors: false,
   144  	SilenceUsage:  false,
   145  	Short:         MSG_APP_NAME,
   146  	Long:          MSG_APP_DESCRIPTION,
   147  	RunE:          RootCmdImpl,
   148  }
   149  
   150  func getLogger() *log.MiniLogger {
   151  	if ProjectLogger == nil {
   152  		// TODO: use LDFLAGS to turn on "TRACE" (and require creation of a Logger)
   153  		// ONLY if needed to debug init() methods in the "cmd" package
   154  		ProjectLogger = log.NewLogger(log.ERROR)
   155  
   156  		// Attempt to read in `--args` values such as `--trace`
   157  		// Note: if they exist, quiet mode will be overridden
   158  		// Default to ERROR level and, turn on "Quiet mode" for tests
   159  		// This simplifies the test output to simply RUN/PASS|FAIL messages.
   160  		ProjectLogger.InitLogLevelAndModeFromFlags()
   161  	}
   162  	return ProjectLogger
   163  }
   164  
   165  // initialize the module; primarily, initialize cobra
   166  // NOTE: the "cmd" module is problematic as Cobra recommends using init() to configure flags.
   167  func init() {
   168  	// Note: getLogger(): if it is creating the logger, will also
   169  	// initialize the log "level" and set "quiet" mode from command line args.
   170  	getLogger().Enter()
   171  	defer getLogger().Exit()
   172  
   173  	// Tell Cobra what our Cobra "init" call back method is
   174  	cobra.OnInitialize(initConfigurations)
   175  
   176  	// Declare top-level, persistent flags used for configuration of utility
   177  	// NOTE: we do not set the "default" config. filenames within Cobra
   178  	// as we want the init/load methods to work apart from Cobra.
   179  	rootCmd.PersistentFlags().StringVarP(&utils.GlobalFlags.ConfigSchemaFile, FLAG_CONFIG_SCHEMA, "", "", MSG_FLAG_CONFIG_SCHEMA)
   180  	rootCmd.PersistentFlags().StringVarP(&utils.GlobalFlags.ConfigLicensePolicyFile, FLAG_CONFIG_LICENSE_POLICY, "", "", MSG_FLAG_CONFIG_LICENSE)
   181  	// TODO: Make configurable once we have organized the set of custom validation configurations
   182  	utils.GlobalFlags.ConfigCustomValidationFile = DEFAULT_CUSTOM_VALIDATION_CONFIG
   183  	//rootCmd.PersistentFlags().StringVarP(&utils.GlobalFlags.ConfigCustomValidationFile, FLAG_CONFIG_CUSTOM_VALIDATION, "", DEFAULT_CUSTOM_VALIDATION_CONFIG, "TODO")
   184  
   185  	// Declare top-level, persistent flags and where to place the post-parse values
   186  	rootCmd.PersistentFlags().BoolVarP(&utils.GlobalFlags.PersistentFlags.Trace, FLAG_TRACE, FLAG_TRACE_SHORT, false, MSG_FLAG_TRACE)
   187  	rootCmd.PersistentFlags().BoolVarP(&utils.GlobalFlags.PersistentFlags.Debug, FLAG_DEBUG, FLAG_DEBUG_SHORT, false, MSG_FLAG_DEBUG)
   188  	rootCmd.PersistentFlags().StringVarP(&utils.GlobalFlags.PersistentFlags.InputFile, FLAG_FILENAME_INPUT, FLAG_FILENAME_INPUT_SHORT, "", MSG_FLAG_INPUT)
   189  	rootCmd.PersistentFlags().StringVarP(&utils.GlobalFlags.PersistentFlags.OutputFile, FLAG_FILENAME_OUTPUT, FLAG_FILENAME_OUTPUT_SHORT, "", MSG_FLAG_OUTPUT)
   190  
   191  	// NOTE: Although we check for the quiet mode flag in main; we track the flag
   192  	// using Cobra framework in order to enable more comprehensive help
   193  	// and take advantage of other features.
   194  	rootCmd.PersistentFlags().BoolVarP(&utils.GlobalFlags.PersistentFlags.Quiet, FLAG_QUIET_MODE, FLAG_QUIET_MODE_SHORT, false, MSG_FLAG_LOG_QUIET)
   195  
   196  	// Optionally, allow log callstack trace to be indented
   197  	rootCmd.PersistentFlags().BoolVarP(&utils.GlobalFlags.LogOutputIndentCallstack, FLAG_LOG_OUTPUT_INDENT, "", false, MSG_FLAG_LOG_INDENT)
   198  
   199  	// Output (JSON) indent
   200  	rootCmd.PersistentFlags().Uint8VarP(&utils.GlobalFlags.PersistentFlags.OutputIndent, FLAG_OUTPUT_INDENT, "", DEFAULT_OUTPUT_INDENT_LENGTH, MSG_FLAG_OUTPUT_INDENT)
   201  
   202  	// Add root commands
   203  	rootCmd.AddCommand(NewCommandVersion())
   204  	rootCmd.AddCommand(NewCommandSchema())
   205  	rootCmd.AddCommand(NewCommandValidate())
   206  	rootCmd.AddCommand(NewCommandQuery())
   207  	rootCmd.AddCommand(NewCommandResource())
   208  	rootCmd.AddCommand(NewCommandVulnerability())
   209  	rootCmd.AddCommand(NewCommandDiff())
   210  	rootCmd.AddCommand(NewCommandTrim())
   211  	rootCmd.AddCommand(NewCommandPatch())
   212  	rootCmd.AddCommand(NewCommandComponent())
   213  	// TODO: when fully implemented uncomment:
   214  	//rootCmd.AddCommand(NewCommandStats())
   215  
   216  	// Add license command its subcommands
   217  	licenseCmd := NewCommandLicense()
   218  	licenseCmd.AddCommand(NewCommandList())
   219  	licenseCmd.AddCommand(NewCommandPolicy())
   220  	rootCmd.AddCommand(licenseCmd)
   221  }
   222  
   223  // load and process configuration files.  Processing includes JSON unmarshalling and hashing.
   224  // includes JSON files:
   225  // config.json (SBOM format/schema definitions),
   226  // license.json (license policy definitions),
   227  // custom.json (custom validation settings)
   228  // Note: This method cannot return values as it is used as a callback by the Cobra framework
   229  func initConfigurations() {
   230  	getLogger().Enter()
   231  	defer getLogger().Exit()
   232  
   233  	getLogger().Tracef("Executable Directory`: `%s`", utils.GlobalFlags.ExecDir)
   234  	getLogger().Tracef("Working Directory`: `%s`", utils.GlobalFlags.WorkingDir)
   235  
   236  	// Print global flags in debug mode
   237  	flagInfo, errFormat := getLogger().FormatStructE(utils.GlobalFlags)
   238  	if errFormat != nil {
   239  		getLogger().Error(errFormat.Error())
   240  	} else {
   241  		getLogger().Debugf("%s: \n%s", "utils.Flags", flagInfo)
   242  	}
   243  
   244  	// NOTE: some commands operate just on the JSON SBOM (i.e., no validation)
   245  	// we leave the code below "in place" as we may still want to validate any
   246  	// input file as JSON SBOM document that matches a known format/version (TODO in the future)
   247  
   248  	// Load application configuration file (i.e., primarily SBOM supported Formats/Schemas)
   249  	var schemaConfigFile = utils.GlobalFlags.ConfigSchemaFile
   250  	err := SupportedFormatConfig.LoadSchemaConfigFile(schemaConfigFile, DEFAULT_SCHEMA_CONFIG)
   251  	if err != nil {
   252  		getLogger().Error(err.Error())
   253  		os.Exit(ERROR_APPLICATION)
   254  	}
   255  
   256  	// License Policy Configuration (customizable via command line, with default config.)
   257  	var licensePolicyFile = utils.GlobalFlags.ConfigLicensePolicyFile
   258  	LicensePolicyConfig = new(schema.LicensePolicyConfig)
   259  	err = LicensePolicyConfig.LoadHashPolicyConfigurationFile(licensePolicyFile, DEFAULT_LICENSE_POLICY_CONFIG)
   260  	if err != nil {
   261  		getLogger().Warning(err.Error())
   262  		getLogger().Warningf("All license policies will default to `%s`.", schema.POLICY_UNDEFINED)
   263  	}
   264  }
   265  
   266  func RootCmdImpl(cmd *cobra.Command, args []string) error {
   267  	getLogger().Enter()
   268  	defer getLogger().Exit()
   269  
   270  	// no commands (empty) passed; display help
   271  	if len(args) == 0 {
   272  		// Show intent to not check error return as no recovery steps possible
   273  		_ = cmd.Help()
   274  		os.Exit(ERROR_APPLICATION)
   275  	}
   276  	return nil
   277  }
   278  
   279  func Execute() {
   280  	// instead of creating a dependency on the "main" module
   281  	getLogger().Enter()
   282  	defer getLogger().Exit()
   283  
   284  	if err := rootCmd.Execute(); err != nil {
   285  		if IsInvalidBOMError(err) {
   286  			os.Exit(ERROR_VALIDATION)
   287  		} else {
   288  			os.Exit(ERROR_APPLICATION)
   289  		}
   290  	}
   291  }
   292  
   293  // Command PreRunE helper function to test for input file
   294  func preRunTestForInputFile(args []string) error {
   295  	getLogger().Enter()
   296  	defer getLogger().Exit()
   297  	getLogger().Tracef("args: %v", args)
   298  
   299  	// Make sure the input filename is present and exists
   300  	inputFilename := utils.GlobalFlags.PersistentFlags.InputFile
   301  	if inputFilename == "" {
   302  		return getLogger().Errorf("Missing required argument(s): %s", FLAG_FILENAME_INPUT)
   303  	} else if inputFilename == INPUT_TYPE_STDIN {
   304  		return nil
   305  	} else if _, err := os.Stat(inputFilename); err != nil {
   306  		return getLogger().Errorf("File not found: `%s`", inputFilename)
   307  	}
   308  	return nil
   309  }
   310  
   311  // TODO: when the package "golang.org/x/exp/slices" is graduated from "experimental", replace
   312  // for loop with the "Contains" method.
   313  func preRunTestForSubcommand(validSubcommands []string, subcommand string) bool {
   314  	getLogger().Enter()
   315  	defer getLogger().Exit()
   316  	getLogger().Tracef("subcommands: %v, subcommand: `%v`", validSubcommands, subcommand)
   317  
   318  	for _, value := range validSubcommands {
   319  		if value == subcommand {
   320  			getLogger().Tracef("Valid subcommand `%v` found", subcommand)
   321  			return true
   322  		}
   323  	}
   324  	return false
   325  }
   326  
   327  // NOTE: Caller must Close() any open io.Writer...
   328  func createOutputFile(outputFilename string) (outputFile *os.File, writer io.Writer, err error) {
   329  	// default to Stdout
   330  	writer = os.Stdout
   331  
   332  	// validate filename
   333  	if outputFilename != "" {
   334  		// Check to see of stdin is the BOM source data
   335  		var absFilename string
   336  		if outputFilename == schema.INPUT_TYPE_STDOUT {
   337  			outputFile = os.Stdout
   338  		} else { // load the BOM data from relative filename
   339  			// Conditionally append working directory if no abs. path detected
   340  			if len(outputFilename) > 0 && !filepath.IsAbs(outputFilename) {
   341  				absFilename = filepath.Join(utils.GlobalFlags.WorkingDir, outputFilename)
   342  			} else {
   343  				absFilename = outputFilename
   344  			}
   345  
   346  			// If the (temporary, not persisted) "test" output directory does not exist, create it
   347  			path := filepath.Dir(absFilename)
   348  			if _, err = os.Stat(path); os.IsNotExist(err) {
   349  				if err = os.MkdirAll(path, os.ModePerm); err != nil {
   350  					return
   351  				}
   352  			}
   353  
   354  			// Open our jsonFile
   355  			if outputFile, err = os.Create(absFilename); err != nil {
   356  				// if input file cannot be opened, log it and terminate
   357  				getLogger().Error(err)
   358  				return
   359  			}
   360  		}
   361  
   362  		// os.File implements the io.Writer interface
   363  		writer = outputFile
   364  	}
   365  
   366  	return
   367  }