github.com/CycloneDX/sbom-utility@v0.16.0/cmd/validate.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 // "github.com/iancoleman/orderedmap" 22 import ( 23 "bytes" 24 "encoding/json" 25 "fmt" 26 "io" 27 "os" 28 "strings" 29 30 "github.com/CycloneDX/sbom-utility/resources" 31 "github.com/CycloneDX/sbom-utility/schema" 32 "github.com/CycloneDX/sbom-utility/utils" 33 "github.com/spf13/cobra" 34 "github.com/xeipuuv/gojsonschema" 35 ) 36 37 const ( 38 VALID = true 39 INVALID = false 40 ) 41 42 // validation flags 43 // TODO: support a `--truncate <int>“ flag (or similar... `err-value-truncate` <int>) used 44 // to truncate formatted "value" (details) to <int> bytes. 45 // This would replace the hardcoded "DEFAULT_MAX_ERR_DESCRIPTION_LEN" value 46 const ( 47 FLAG_VALIDATE_SCHEMA_FORCE = "force" 48 FLAG_VALIDATE_SCHEMA_VARIANT = "variant" 49 FLAG_VALIDATE_CUSTOM = "custom" // TODO: document when no longer experimental 50 FLAG_VALIDATE_ERR_LIMIT = "error-limit" 51 FLAG_VALIDATE_ERR_VALUE = "error-value" 52 MSG_VALIDATE_SCHEMA_FORCE = "force specified schema file for validation; overrides inferred schema" 53 MSG_VALIDATE_SCHEMA_VARIANT = "select named schema variant (e.g., \"strict\"); variant must be declared in configuration file (i.e., \"config.json\")" 54 MSG_VALIDATE_FLAG_CUSTOM = "perform custom validation using custom configuration settings (i.e., \"custom.json\")" 55 MSG_VALIDATE_FLAG_ERR_COLORIZE = "Colorize formatted error output (true|false); default true" 56 MSG_VALIDATE_FLAG_ERR_LIMIT = "Limit number of errors output to specified (integer) (default 10)" 57 MSG_VALIDATE_FLAG_ERR_FORMAT = "format error results using the specified format type" 58 MSG_VALIDATE_FLAG_ERR_VALUE = "include details of failing value in error results (bool) (default: true)" 59 ) 60 61 var VALIDATE_SUPPORTED_ERROR_FORMATS = MSG_VALIDATE_FLAG_ERR_FORMAT + 62 strings.Join([]string{FORMAT_TEXT, FORMAT_JSON, FORMAT_CSV}, ", ") + " (default: txt)" 63 64 // limits 65 const ( 66 DEFAULT_MAX_ERROR_LIMIT = 10 67 DEFAULT_MAX_ERR_DESCRIPTION_LEN = 128 68 ) 69 70 // Protocol 71 const ( 72 PROTOCOL_PREFIX_FILE = "file://" 73 ) 74 75 func NewCommandValidate() *cobra.Command { 76 // NOTE: `RunE` function takes precedent over `Run` (anonymous) function if both provided 77 var command = new(cobra.Command) 78 command.Use = CMD_USAGE_VALIDATE 79 command.Short = "Validate input file against its declared BOM schema" 80 command.Long = "Validate input file against its declared BOM schema, if detectable and supported." 81 command.RunE = validateCmdImpl 82 command.Flags().StringVarP(&utils.GlobalFlags.PersistentFlags.OutputFormat, FLAG_FILE_OUTPUT_FORMAT, "", "", 83 MSG_VALIDATE_FLAG_ERR_FORMAT+VALIDATE_SUPPORTED_ERROR_FORMATS) 84 command.PreRunE = func(cmd *cobra.Command, args []string) error { 85 return preRunTestForInputFile(args) 86 } 87 initCommandValidateFlags(command) 88 return command 89 } 90 91 // Add local flags to validate command 92 func initCommandValidateFlags(command *cobra.Command) { 93 getLogger().Enter() 94 defer getLogger().Exit() 95 96 // Force a schema file to use for validation (override inferred schema) 97 command.Flags().StringVarP(&utils.GlobalFlags.ValidateFlags.ForcedJsonSchemaFile, FLAG_VALIDATE_SCHEMA_FORCE, "", "", MSG_VALIDATE_SCHEMA_FORCE) 98 // Optional schema "variant" of inferred schema (e.g, "strict") 99 command.Flags().StringVarP(&utils.GlobalFlags.ValidateFlags.SchemaVariant, FLAG_VALIDATE_SCHEMA_VARIANT, "", "", MSG_VALIDATE_SCHEMA_VARIANT) 100 command.Flags().BoolVarP(&utils.GlobalFlags.ValidateFlags.CustomValidation, FLAG_VALIDATE_CUSTOM, "", false, MSG_VALIDATE_FLAG_CUSTOM) 101 command.Flags().BoolVarP(&utils.GlobalFlags.ValidateFlags.ColorizeErrorOutput, FLAG_COLORIZE_OUTPUT, "", false, MSG_VALIDATE_FLAG_ERR_COLORIZE) 102 command.Flags().IntVarP(&utils.GlobalFlags.ValidateFlags.MaxNumErrors, FLAG_VALIDATE_ERR_LIMIT, "", DEFAULT_MAX_ERROR_LIMIT, MSG_VALIDATE_FLAG_ERR_LIMIT) 103 command.Flags().BoolVarP(&utils.GlobalFlags.ValidateFlags.ShowErrorValue, FLAG_VALIDATE_ERR_VALUE, "", true, MSG_VALIDATE_FLAG_ERR_COLORIZE) 104 } 105 106 func validateCmdImpl(cmd *cobra.Command, args []string) error { 107 getLogger().Enter() 108 defer getLogger().Exit() 109 110 // Create output writer 111 outputFilename := utils.GlobalFlags.PersistentFlags.OutputFile 112 outputFile, writer, err := createOutputFile(outputFilename) 113 114 // Note: all invalid BOMs (that fail schema validation) MUST result in an InvalidSBOMError() 115 if err != nil { 116 // TODO: assure this gets normalized 117 getLogger().Error(err) 118 os.Exit(ERROR_APPLICATION) 119 } 120 121 // use function closure to assure consistent error output based upon error type 122 defer func() { 123 // always close the output file 124 if outputFile != nil { 125 err = outputFile.Close() 126 getLogger().Infof("Closed output file: `%s`", outputFilename) 127 } 128 }() 129 130 // invoke validate and consistently manage exit messages and codes 131 isValid, _, _, err := Validate(writer, utils.GlobalFlags.PersistentFlags, utils.GlobalFlags.ValidateFlags) 132 133 // Note: all invalid BOMs (that fail schema validation) MUST result in an InvalidSBOMError() 134 if err != nil { 135 if IsInvalidBOMError(err) { 136 os.Exit(ERROR_VALIDATION) 137 } 138 os.Exit(ERROR_APPLICATION) 139 } 140 141 // Note: JSON schema validation does NOT return errors so we want to 142 // clearly return an invalid return code on exit 143 // TODO: remove this if we can assure that we ALWAYS return an 144 // IsInvalidSBOMError(err) in these cases from the Validate() method 145 if !isValid { 146 // TODO: if JSON validation resulted in !valid, turn that into an 147 // InvalidSBOMError and test to make sure this works in all cases 148 os.Exit(ERROR_VALIDATION) 149 } 150 151 // Note: this implies os.Exit(0) as the default from main.go (i.e., bash rc=0) 152 return nil 153 } 154 155 // Normalize ErrorTypes from the Validate() function 156 // Note: this function name should not be changed 157 func validationError(document *schema.BOM, valid bool, err error) { 158 159 // Consistently display errors before exiting 160 if err != nil { 161 switch t := err.(type) { 162 case *json.UnmarshalTypeError: 163 schema.DisplayJSONErrorDetails(document.GetRawBytes(), err) 164 case *json.SyntaxError: 165 schema.DisplayJSONErrorDetails(document.GetRawBytes(), err) 166 case *InvalidSBOMError: 167 // Note: InvalidSBOMError type errors include schema errors which have already 168 // been added to the error type and will shown with the Error() interface 169 if valid { 170 _ = getLogger().Errorf("invalid state: error (%T) returned, but BOM valid!", t) 171 } 172 getLogger().Error(err) 173 default: 174 getLogger().Tracef("unhandled error type: `%v`", t) 175 getLogger().Error(err) 176 } 177 } 178 179 // ALWAYS output valid/invalid result (as informational) 180 message := fmt.Sprintf("document `%s`: valid=[%t]", document.GetFilename(), valid) 181 getLogger().Info(message) 182 } 183 184 func Validate(writer io.Writer, persistentFlags utils.PersistentCommandFlags, validateFlags utils.ValidateCommandFlags) (valid bool, bom *schema.BOM, schemaErrors []gojsonschema.ResultError, err error) { 185 getLogger().Enter() 186 defer getLogger().Exit() 187 188 // use function closure to assure consistent error output based upon error type 189 defer func() { 190 if err != nil { 191 // normalize the error output to console 192 validationError(bom, valid, err) 193 } 194 }() 195 196 // Attempt to load and unmarshal the input file as a Json document 197 // Note: JSON syntax errors return "encoding/json.SyntaxError" 198 bom, err = LoadInputBOMFileAndDetectSchema() 199 if err != nil { 200 return INVALID, bom, schemaErrors, err 201 } 202 203 // if "custom" flag exists, then assure we support the format 204 if validateFlags.CustomValidation && !bom.FormatInfo.IsCycloneDx() { 205 err = schema.NewUnsupportedFormatError( 206 schema.MSG_FORMAT_UNSUPPORTED_COMMAND, 207 bom.GetFilename(), 208 bom.FormatInfo.CanonicalName, 209 CMD_VALIDATE, 210 FLAG_VALIDATE_CUSTOM) 211 return valid, bom, schemaErrors, err 212 } 213 214 // Create a loader for the BOM (JSON) document 215 var documentLoader gojsonschema.JSONLoader 216 var schemaLoader gojsonschema.JSONLoader 217 var errRead error 218 var bSchema, bDocument []byte 219 220 if bDocument = bom.GetRawBytes(); len(bDocument) > 0 { 221 bufferTemp := new(bytes.Buffer) 222 // Strip off newlines which the json Decoder dislikes at EOF (as well as extra spaces, etc.) 223 if err := json.Compact(bufferTemp, bDocument); err != nil { 224 fmt.Println(err) 225 } 226 documentLoader = gojsonschema.NewBytesLoader(bufferTemp.Bytes()) 227 } else { 228 inputFile := persistentFlags.InputFile 229 documentLoader = gojsonschema.NewReferenceLoader(PROTOCOL_PREFIX_FILE + inputFile) 230 } 231 232 if documentLoader == nil { 233 return INVALID, bom, schemaErrors, fmt.Errorf("unable to load document: `%s`", bom.GetFilename()) 234 } 235 236 schemaName := bom.SchemaInfo.File 237 238 // If caller "forced" a specific schema file (version), load it instead of 239 // any SchemaInfo found in config.json 240 // TODO: support remote schema load (via URL) with a flag (default should always be local file for security) 241 forcedSchemaFile := validateFlags.ForcedJsonSchemaFile 242 if forcedSchemaFile != "" { 243 getLogger().Infof("Validating document using forced schema (i.e., `--force %s`)", forcedSchemaFile) 244 //schemaName = document.SchemaInfo.File 245 schemaName = "file://" + forcedSchemaFile 246 getLogger().Infof("Loading schema `%s`...", schemaName) 247 schemaLoader = gojsonschema.NewReferenceLoader(schemaName) 248 } else { 249 // Load the matching JSON schema (format, version and variant) from embedded resources 250 // i.e., using the matching schema found in config.json (as SchemaInfo) 251 getLogger().Infof("Loading schema `%s`...", bom.SchemaInfo.File) 252 bSchema, errRead = resources.BOMSchemaFiles.ReadFile(bom.SchemaInfo.File) 253 254 if errRead != nil { 255 // we force result to INVALID as any errors from the library means 256 // we could NOT actually confirm the input documents validity 257 return INVALID, bom, schemaErrors, errRead 258 } 259 260 schemaLoader = gojsonschema.NewBytesLoader(bSchema) 261 } 262 263 if schemaLoader == nil { 264 // we force result to INVALID as any errors from the library means 265 // we could NOT actually confirm the input documents validity 266 return INVALID, bom, schemaErrors, fmt.Errorf("unable to read schema: `%s`", schemaName) 267 } 268 269 // create a reusable schema object (TODO: validate multiple documents) 270 var errLoad error = nil 271 const RETRY int = 3 272 var jsonBOMSchema *gojsonschema.Schema 273 274 // we force result to INVALID as any errors from the library means 275 // we could NOT actually confirm the input documents validity 276 // WARNING: if schemas reference "remote" schemas which are loaded 277 // over http... then there is a chance of 503 errors (as the pkg. loads 278 // externally referenced schemas over network)... attempt fixed retry... 279 for i := 0; i < RETRY; i++ { 280 jsonBOMSchema, errLoad = gojsonschema.NewSchema(schemaLoader) 281 282 if errLoad == nil { 283 break 284 } 285 getLogger().Warningf("unable to load referenced schema over HTTP: \"%v\"\n retrying...", errLoad) 286 } 287 288 if errLoad != nil { 289 return INVALID, bom, schemaErrors, fmt.Errorf("unable to load schema: `%s`", schemaName) 290 } 291 292 getLogger().Infof("Schema `%s` loaded.", schemaName) 293 294 // Validate against the schema and save result determination 295 getLogger().Infof("Validating `%s`...", bom.GetFilenameInterpolated()) 296 result, errValidate := jsonBOMSchema.Validate(documentLoader) 297 298 // ALWAYS set the valid return parameter and provide user an informative message 299 valid = result.Valid() 300 getLogger().Infof("BOM valid against JSON schema: `%t`", valid) 301 302 // Catch general errors from the validation package/library itself and display them 303 if errValidate != nil { 304 // we force result to INVALID as any errors from the library means 305 // we could NOT actually confirm the input documents validity 306 return INVALID, bom, schemaErrors, errValidate 307 } 308 309 // Note: actual schema validation errors appear in the `result` object 310 // Save all schema errors found in the `result` object in an explicit, typed error 311 if schemaErrors = result.Errors(); len(schemaErrors) > 0 { 312 errInvalid := NewInvalidSBOMError( 313 bom, 314 MSG_SCHEMA_ERRORS, 315 nil, 316 schemaErrors) 317 318 // TODO: de-duplicate errors (e.g., array item not "unique"...) 319 format := persistentFlags.OutputFormat 320 switch format { 321 case FORMAT_JSON: 322 fallthrough 323 case FORMAT_CSV: 324 fallthrough 325 case FORMAT_TEXT: 326 // Note: we no longer add the formatted errors to the actual error "detail" field; 327 // since BOMs can have large numbers of errors. The new method is to allow 328 // the user to control the error result output (e.g., file, detail, etc.) via flags 329 FormatSchemaErrors(writer, schemaErrors, validateFlags, format) 330 default: 331 // Notify caller that we are defaulting to "txt" format 332 getLogger().Warningf(MSG_WARN_INVALID_FORMAT, format, FORMAT_TEXT) 333 FormatSchemaErrors(writer, schemaErrors, validateFlags, FORMAT_TEXT) 334 } 335 336 return INVALID, bom, schemaErrors, errInvalid 337 } 338 339 // TODO: Perhaps factor in these errors into the JSON output as if they were actual schema errors... 340 // Perform additional validation in document composition/structure 341 // and "custom" required data within specified fields 342 if validateFlags.CustomValidation { 343 valid, err = validateCustom(bom, LicensePolicyConfig) 344 } 345 346 // All validation tests passed; return VALID 347 return 348 } 349 350 func validateCustom(document *schema.BOM, policyConfig *schema.LicensePolicyConfig) (valid bool, err error) { 351 352 // If the validated BOM is of a known format, we can unmarshal it into 353 // more convenient typed structures for simplified custom validation 354 if document.FormatInfo.IsCycloneDx() { 355 document.CdxBom, err = schema.UnMarshalDocument(document.GetJSONMap()) 356 if err != nil { 357 return INVALID, err 358 } 359 } 360 361 // Perform all custom validation 362 // TODO Implement customValidation as an interface supported by the CDXDocument type 363 // and later supported by a SPDXDocument type. 364 err = validateCustomCDXDocument(document, policyConfig) 365 if err != nil { 366 // Wrap any specific validation error in a single invalid BOM error 367 if !IsInvalidBOMError(err) { 368 err = NewInvalidSBOMError( 369 document, 370 err.Error(), 371 err, 372 nil) 373 } 374 // an error implies it is also invalid (according to custom requirements) 375 return INVALID, err 376 } 377 378 return VALID, nil 379 }