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 }