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  }