diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2ac72228..3eef386e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,7 +58,7 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} goos: ${{ matrix.goos }} goarch: ${{ matrix.goarch }} - extra_files: LICENSE config.json license.json custom.json ${{env.SBOM_NAME}} + extra_files: LICENSE README.md config.json license.json custom.json ${{env.SBOM_NAME}} # "auto" will use ZIP for Windows, otherwise default is TAR compress_assets: auto # NOTE: This verbose flag may be removed diff --git a/.vscode/settings.json b/.vscode/settings.json index 02f66a3f..72f77f2a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -62,6 +62,7 @@ "multimap", "myservices", "NOASSERTION", + "nolint", "nosec", "NTIA", "Nyffenegger", @@ -102,5 +103,8 @@ ], "files.watcherExclude": { "**/target": true - } + }, + "cSpell.ignoreWords": [ + "iancoleman" + ] } \ No newline at end of file diff --git a/README.md b/README.md index 99965217..96b868eb 100644 --- a/README.md +++ b/README.md @@ -804,11 +804,15 @@ The following flags can be used to improve performance when formatting error out ##### `--error-limit` flag -Use the `--error-limit x` flag to reduce the formatted error result output to the first `x` errors. By default, only the first 10 errors are output with an informational messaging indicating `x/y` errors were shown. +Use the `--error-limit x` (default: `10`) flag to reduce the formatted error result output to the first `x` errors. By default, only the first 10 errors are output with an informational messaging indicating `x/y` errors were shown. + +##### `--error-value` flag + +Use the `--error-value=true|false` (default: `true`) flag to reduce the formatted error result output by not showing the `value` field which shows detailed information about the failing data in the BOM. ##### `--colorize` flag -Use the `--colorize=true|false` flag to add/remove color formatting to error result output. By default, formatted error output is colorized to help with human readability; for automated use, it can be turned off. +Use the `--colorize=true|false` (default: `false`) flag to add/remove color formatting to error result `txt` formatted output. By default, `txt` formatted error output is colorized to help with human readability; for automated use, it can be turned off. #### Validate Examples @@ -911,6 +915,108 @@ The details include the full context of the failing `metadata.properties` object ]] ``` +#### Example: Validate using "JSON" format + +The JSON format will provide an `array` of schema error results that can be post-processed as part of validation toolchain. + +```bash +./sbom-utility validate -i test/validation/cdx-1-4-validate-err-components-unique-items-1.json --format json --quiet +``` + +```json +[ + { + "type": "unique", + "field": "components", + "context": "(root).components", + "description": "array items[1,2] must be unique", + "value": { + "type": "array", + "index": 1, + "item": { + "bom-ref": "pkg:npm/body-parser@1.19.0", + "description": "Node.js body parsing middleware", + "hashes": [ + { + "alg": "SHA-1", + "content": "96b2709e57c9c4e09a6fd66a8fd979844f69f08a" + } + ], + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "name": "body-parser", + "purl": "pkg:npm/body-parser@1.19.0", + "type": "library", + "version": "1.19.0" + } + } + }, + { + "type": "unique", + "field": "components", + "context": "(root).components", + "description": "array items[2,4] must be unique", + "value": { + "type": "array", + "index": 2, + "item": { + "bom-ref": "pkg:npm/body-parser@1.19.0", + "description": "Node.js body parsing middleware", + "hashes": [ + { + "alg": "SHA-1", + "content": "96b2709e57c9c4e09a6fd66a8fd979844f69f08a" + } + ], + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "name": "body-parser", + "purl": "pkg:npm/body-parser@1.19.0", + "type": "library", + "version": "1.19.0" + } + } + } +] +``` + +##### Reducing output size using `error-value=false` flag + +In many cases, BOMs may have many errors and having the `value` information details included can be too verbose and lead to large output files to inspect. In those cases, simply set the `error-value` flag to `false`. + +Rerunning the same command with this flag set to false yields a reduced set of information. + +```bash +./sbom-utility validate -i test/validation/cdx-1-4-validate-err-components-unique-items-1.json --format json --error-value=false --quiet +``` + +```json +[ + { + "type": "unique", + "field": "components", + "context": "(root).components", + "description": "array items[1,2] must be unique" + }, + { + "type": "unique", + "field": "components", + "context": "(root).components", + "description": "array items[2,4] must be unique" + } +] +``` + --- ### Vulnerability diff --git a/cmd/diff.go b/cmd/diff.go index 85ea8d94..e99d885d 100644 --- a/cmd/diff.go +++ b/cmd/diff.go @@ -20,7 +20,6 @@ package cmd import ( "encoding/json" "fmt" - "io/ioutil" "os" "strings" @@ -51,7 +50,7 @@ func NewCommandDiff() *cobra.Command { command.Use = CMD_USAGE_DIFF command.Short = "Report on differences between two BOM files using RFC 6902 format" command.Long = "Report on differences between two BOM files using RFC 6902 format" - command.Flags().StringVarP(&utils.GlobalFlags.OutputFormat, FLAG_FILE_OUTPUT_FORMAT, "", FORMAT_TEXT, + command.Flags().StringVarP(&utils.GlobalFlags.PersistentFlags.OutputFormat, FLAG_FILE_OUTPUT_FORMAT, "", FORMAT_TEXT, FLAG_DIFF_OUTPUT_FORMAT_HELP+DIFF_OUTPUT_SUPPORTED_FORMATS) command.Flags().StringVarP(&utils.GlobalFlags.DiffFlags.RevisedFile, FLAG_DIFF_FILENAME_REVISION, @@ -75,7 +74,7 @@ func preRunTestForFiles(cmd *cobra.Command, args []string) error { getLogger().Tracef("args: %v", args) // Make sure the base (input) file is present and exists - baseFilename := utils.GlobalFlags.InputFile + baseFilename := utils.GlobalFlags.PersistentFlags.InputFile if baseFilename == "" { return getLogger().Errorf("Missing required argument(s): %s", FLAG_FILENAME_INPUT) } else if _, err := os.Stat(baseFilename); err != nil { @@ -98,7 +97,8 @@ func diffCmdImpl(cmd *cobra.Command, args []string) (err error) { defer getLogger().Exit() // Create output writer - outputFile, writer, err := createOutputFile(utils.GlobalFlags.OutputFile) + outputFilename := utils.GlobalFlags.PersistentFlags.OutputFile + outputFile, writer, err := createOutputFile(outputFilename) getLogger().Tracef("outputFile: `%v`; writer: `%v`", outputFile, writer) // use function closure to assure consistent error output based upon error type @@ -109,7 +109,7 @@ func diffCmdImpl(cmd *cobra.Command, args []string) (err error) { if err != nil { return } - getLogger().Infof("Closed output file: `%s`", utils.GlobalFlags.OutputFile) + getLogger().Infof("Closed output file: `%s`", utils.GlobalFlags.PersistentFlags.OutputFile) } }() @@ -123,11 +123,11 @@ func Diff(flags utils.CommandFlags) (err error) { defer getLogger().Exit() // create locals - format := utils.GlobalFlags.OutputFormat - baseFilename := utils.GlobalFlags.InputFile - outputFilename := utils.GlobalFlags.OutputFile - outputFormat := utils.GlobalFlags.OutputFormat - deltaFilename := utils.GlobalFlags.DiffFlags.RevisedFile + format := utils.GlobalFlags.PersistentFlags.OutputFormat + inputFilename := utils.GlobalFlags.PersistentFlags.InputFile + outputFilename := utils.GlobalFlags.PersistentFlags.OutputFile + outputFormat := utils.GlobalFlags.PersistentFlags.OutputFormat + revisedFilename := utils.GlobalFlags.DiffFlags.RevisedFile deltaColorize := utils.GlobalFlags.DiffFlags.Colorize // Create output writer @@ -138,31 +138,31 @@ func Diff(flags utils.CommandFlags) (err error) { // always close the output file if outputFile != nil { err = outputFile.Close() - getLogger().Infof("Closed output file: `%s`", utils.GlobalFlags.OutputFile) + getLogger().Infof("Closed output file: `%s`", outputFilename) } }() - getLogger().Infof("Reading file (--input-file): `%s` ...", baseFilename) + getLogger().Infof("Reading file (--input-file): `%s` ...", inputFilename) // #nosec G304 (suppress warning) - bBaseData, errReadBase := ioutil.ReadFile(baseFilename) + bBaseData, errReadBase := os.ReadFile(inputFilename) if errReadBase != nil { getLogger().Debugf("%v", bBaseData[:255]) - err = getLogger().Errorf("Failed to ReadFile '%s': %s\n", utils.GlobalFlags.InputFile, err.Error()) + err = getLogger().Errorf("Failed to ReadFile '%s': %s\n", inputFilename, err.Error()) return } - getLogger().Infof("Reading file (--input-revision): `%s` ...", deltaFilename) + getLogger().Infof("Reading file (--input-revision): `%s` ...", revisedFilename) // #nosec G304 (suppress warning) - bRevisedData, errReadDelta := ioutil.ReadFile(deltaFilename) + bRevisedData, errReadDelta := os.ReadFile(revisedFilename) if errReadDelta != nil { getLogger().Debugf("%v", bRevisedData[:255]) - err = getLogger().Errorf("Failed to ReadFile '%s': %s\n", utils.GlobalFlags.InputFile, err.Error()) + err = getLogger().Errorf("Failed to ReadFile '%s': %s\n", inputFilename, err.Error()) return } // Compare the base with the revision differ := diff.New() - getLogger().Infof("Comparing files: `%s` (base) to `%s` (revised) ...", baseFilename, deltaFilename) + getLogger().Infof("Comparing files: `%s` (base) to `%s` (revised) ...", inputFilename, revisedFilename) d, err := differ.Compare(bBaseData, bRevisedData) if err != nil { err = getLogger().Errorf("Failed to Compare data: %s\n", err.Error()) @@ -178,7 +178,7 @@ func Diff(flags utils.CommandFlags) (err error) { err = json.Unmarshal(bBaseData, &aJson) if err != nil { - err = getLogger().Errorf("json.Unmarshal() failed '%s': %s\n", utils.GlobalFlags.InputFile, err.Error()) + err = getLogger().Errorf("json.Unmarshal() failed '%s': %s\n", inputFilename, err.Error()) return } @@ -201,8 +201,7 @@ func Diff(flags utils.CommandFlags) (err error) { } else { getLogger().Infof("No deltas found. baseFilename: `%s`, revisedFilename=`%s` match.", - utils.GlobalFlags.InputFile, - utils.GlobalFlags.DiffFlags.RevisedFile) + inputFilename, revisedFilename) } return diff --git a/cmd/diff_test.go b/cmd/diff_test.go index 0c6e4965..31307ac8 100644 --- a/cmd/diff_test.go +++ b/cmd/diff_test.go @@ -48,15 +48,15 @@ func innerDiffError(t *testing.T, baseFilename string, revisedFilename string, f defer getLogger().Exit() // Copy the test filename to the command line flags where the code looks for it - utils.GlobalFlags.OutputFormat = format - utils.GlobalFlags.InputFile = baseFilename + utils.GlobalFlags.PersistentFlags.OutputFormat = format + utils.GlobalFlags.PersistentFlags.InputFile = baseFilename utils.GlobalFlags.DiffFlags.RevisedFile = revisedFilename utils.GlobalFlags.DiffFlags.Colorize = true actualError = Diff(utils.GlobalFlags) getLogger().Tracef("baseFilename: `%s`, revisedFilename=`%s`, actualError=`%T`", - utils.GlobalFlags.InputFile, + utils.GlobalFlags.PersistentFlags.InputFile, utils.GlobalFlags.DiffFlags.RevisedFile, actualError) diff --git a/cmd/document.go b/cmd/document.go index 9e04520d..79822670 100644 --- a/cmd/document.go +++ b/cmd/document.go @@ -28,26 +28,28 @@ func LoadInputSbomFileAndDetectSchema() (document *schema.Sbom, err error) { getLogger().Enter() defer getLogger().Exit() + inputFile := utils.GlobalFlags.PersistentFlags.InputFile + // check for required fields on command - getLogger().Tracef("utils.Flags.InputFile: `%s`", utils.GlobalFlags.InputFile) - if utils.GlobalFlags.InputFile == "" { - return nil, fmt.Errorf("invalid input file (-%s): `%s` ", FLAG_FILENAME_INPUT_SHORT, utils.GlobalFlags.InputFile) + getLogger().Tracef("utils.Flags.InputFile: `%s`", inputFile) + if inputFile == "" { + return nil, fmt.Errorf("invalid input file (-%s): `%s` ", FLAG_FILENAME_INPUT_SHORT, inputFile) } // Construct an Sbom object around the input file - document = schema.NewSbom(utils.GlobalFlags.InputFile) + document = schema.NewSbom(inputFile) // Load the raw, candidate SBOM (file) as JSON data - getLogger().Infof("Attempting to load and unmarshal file `%s`...", utils.GlobalFlags.InputFile) + getLogger().Infof("Attempting to load and unmarshal file `%s`...", inputFile) err = document.UnmarshalSBOMAsJsonMap() // i.e., utils.Flags.InputFile if err != nil { return } - getLogger().Infof("Successfully unmarshalled data from: `%s`", utils.GlobalFlags.InputFile) + getLogger().Infof("Successfully unmarshalled data from: `%s`", inputFile) // Search the document keys/values for known SBOM formats and schema in the config. file getLogger().Infof("Determining file's SBOM format and version...") - err = document.FindFormatAndSchema() + err = document.FindFormatAndSchema(utils.GlobalFlags.PersistentFlags.InputFile) if err != nil { return } diff --git a/cmd/errors.go b/cmd/errors.go index def0bb60..843ab2aa 100644 --- a/cmd/errors.go +++ b/cmd/errors.go @@ -116,10 +116,10 @@ func (err BaseError) Error() string { return formattedMessage } -func (base BaseError) AppendMessage(addendum string) { - // Ignore (invalid) static linting message: - // "ineffective assignment to field (SA4005)" - base.Message += addendum +func (err *BaseError) AppendMessage(addendum string) { + if addendum != "" { + err.Message += addendum + } } type UtilityError struct { diff --git a/cmd/license_list.go b/cmd/license_list.go index 41b2e84f..0ee50247 100644 --- a/cmd/license_list.go +++ b/cmd/license_list.go @@ -50,7 +50,7 @@ const ( MSG_OUTPUT_NO_LICENSES_ONLY_NOASSERTION = "no valid licenses found in BOM document (only licenses marked NOASSERTION)" ) -//"Type", "ID/Name/Expression", "Component(s)", "BOM ref.", "Document location" +// "Type", "ID/Name/Expression", "Component(s)", "BOM ref.", "Document location" // filter keys const ( LICENSE_FILTER_KEY_USAGE_POLICY = "usage-policy" @@ -106,7 +106,7 @@ func NewCommandList() *cobra.Command { command.Use = CMD_USAGE_LICENSE_LIST command.Short = "List licenses found in the BOM input file" command.Long = "List licenses and associated policies found in the BOM input file" - command.Flags().StringVarP(&utils.GlobalFlags.OutputFormat, FLAG_FILE_OUTPUT_FORMAT, "", "", + command.Flags().StringVarP(&utils.GlobalFlags.PersistentFlags.OutputFormat, FLAG_FILE_OUTPUT_FORMAT, "", "", FLAG_LICENSE_LIST_OUTPUT_FORMAT_HELP+ LICENSE_LIST_SUPPORTED_FORMATS+ LICENSE_LIST_SUMMARY_SUPPORTED_FORMATS) @@ -162,14 +162,15 @@ func listCmdImpl(cmd *cobra.Command, args []string) (err error) { defer getLogger().Exit() // Create output writer - outputFile, writer, err := createOutputFile(utils.GlobalFlags.OutputFile) + outputFilename := utils.GlobalFlags.PersistentFlags.OutputFile + outputFile, writer, err := createOutputFile(outputFilename) // use function closure to assure consistent error output based upon error type defer func() { // always close the output file if outputFile != nil { err = outputFile.Close() - getLogger().Infof("Closed output file: `%s`", utils.GlobalFlags.OutputFile) + getLogger().Infof("Closed output file: `%s`", outputFilename) } }() @@ -177,7 +178,7 @@ func listCmdImpl(cmd *cobra.Command, args []string) (err error) { whereFilters, err := processWhereFlag(cmd) if err == nil { - err = ListLicenses(writer, utils.GlobalFlags.OutputFormat, whereFilters, utils.GlobalFlags.LicenseFlags.Summary) + err = ListLicenses(writer, utils.GlobalFlags.PersistentFlags.OutputFormat, whereFilters, utils.GlobalFlags.LicenseFlags.Summary) } return diff --git a/cmd/license_policy.go b/cmd/license_policy.go index 6b1b6ea3..e90f5816 100644 --- a/cmd/license_policy.go +++ b/cmd/license_policy.go @@ -122,7 +122,7 @@ func NewCommandPolicy() *cobra.Command { command.Use = CMD_USAGE_LICENSE_POLICY command.Short = "List policies associated with known licenses" command.Long = "List caller-supplied, \"allow/deny\"-style policies associated with known software, hardware or data licenses" - command.Flags().StringVarP(&utils.GlobalFlags.OutputFormat, FLAG_FILE_OUTPUT_FORMAT, "", FORMAT_TEXT, + command.Flags().StringVarP(&utils.GlobalFlags.PersistentFlags.OutputFormat, FLAG_FILE_OUTPUT_FORMAT, "", FORMAT_TEXT, FLAG_POLICY_OUTPUT_FORMAT_HELP+LICENSE_POLICY_SUPPORTED_FORMATS) command.Flags().BoolVarP( &utils.GlobalFlags.LicenseFlags.Summary, // re-use license flag @@ -161,14 +161,14 @@ func policyCmdImpl(cmd *cobra.Command, args []string) (err error) { getLogger().Enter(args) defer getLogger().Exit() - outputFile, writer, err := createOutputFile(utils.GlobalFlags.OutputFile) + outputFile, writer, err := createOutputFile(utils.GlobalFlags.PersistentFlags.OutputFile) // use function closure to assure consistent error output based upon error type defer func() { // always close the output file if outputFile != nil { err = outputFile.Close() - getLogger().Infof("Closed output file: `%s`", utils.GlobalFlags.OutputFile) + getLogger().Infof("Closed output file: `%s`", utils.GlobalFlags.PersistentFlags.OutputFile) } }() @@ -211,7 +211,7 @@ func ListLicensePolicies(writer io.Writer, whereFilters []WhereFilter, flags uti } // default output (writer) to standard out - switch utils.GlobalFlags.OutputFormat { + switch utils.GlobalFlags.PersistentFlags.OutputFormat { case FORMAT_DEFAULT: // defaults to text if no explicit `--format` parameter err = DisplayLicensePoliciesTabbedText(writer, filteredMap, flags) @@ -224,7 +224,7 @@ func ListLicensePolicies(writer io.Writer, whereFilters []WhereFilter, flags uti default: // default to text format for anything else getLogger().Warningf("Unsupported format: `%s`; using default format.", - utils.GlobalFlags.OutputFormat) + utils.GlobalFlags.PersistentFlags.OutputFormat) err = DisplayLicensePoliciesTabbedText(writer, filteredMap, flags) } return diff --git a/cmd/license_policy_config.go b/cmd/license_policy_config.go index 7587b572..3c8c443c 100644 --- a/cmd/license_policy_config.go +++ b/cmd/license_policy_config.go @@ -42,16 +42,18 @@ var VALID_USAGE_POLICIES = []string{POLICY_ALLOW, POLICY_DENY, POLICY_NEEDS_REVI var ALL_USAGE_POLICIES = []string{POLICY_ALLOW, POLICY_DENY, POLICY_NEEDS_REVIEW, POLICY_UNDEFINED, POLICY_CONFLICT} // Note: the SPDX spec. does not provide regex for an SPDX ID, but provides the following in ABNF: -// string = 1*(ALPHA / DIGIT / "-" / "." ) +// +// string = 1*(ALPHA / DIGIT / "-" / "." ) +// // Currently, the regex below tests composition of of only // alphanum, "-", and "." characters and disallows empty strings // TODO: -// - First and last chars are not "-" or "." -// - Enforce reasonable min/max lengths -// In theory, we can check overall length with positive lookahead -// (e.g., min 3 max 128): (?=.{3,128}$) -// However, this does not appear to be supported in `regexp` package -// or perhaps it must be a compiled expression TBD +// - First and last chars are not "-" or "." +// - Enforce reasonable min/max lengths +// In theory, we can check overall length with positive lookahead +// (e.g., min 3 max 128): (?=.{3,128}$) +// However, this does not appear to be supported in `regexp` package +// or perhaps it must be a compiled expression TBD const ( REGEX_VALID_SPDX_ID = "^[a-zA-Z0-9.-]+$" ) diff --git a/cmd/license_policy_test.go b/cmd/license_policy_test.go index 17bba6db..e83012c3 100644 --- a/cmd/license_policy_test.go +++ b/cmd/license_policy_test.go @@ -81,8 +81,8 @@ func innerTestLicensePolicyListCustomAndBuffered(t *testing.T, testInfo *License } // Use the test data to set the BOM input file and output format - utils.GlobalFlags.InputFile = testInfo.InputFile - utils.GlobalFlags.OutputFormat = testInfo.ListFormat + utils.GlobalFlags.PersistentFlags.InputFile = testInfo.InputFile + utils.GlobalFlags.PersistentFlags.OutputFormat = testInfo.ListFormat utils.GlobalFlags.LicenseFlags.Summary = testInfo.ListSummary // TODO: pass GlobalConfig to every Command to allow per-instance changes for tests @@ -118,9 +118,9 @@ func innerTestLicensePolicyList(t *testing.T, testInfo *LicenseTestInfo) (output return } -//----------------------------------- +// ----------------------------------- // Usage Policy: allowed value tests -//----------------------------------- +// ----------------------------------- func TestLicensePolicyUsageValueAllow(t *testing.T) { value := POLICY_ALLOW if !IsValidUsagePolicy(value) { diff --git a/cmd/license_test.go b/cmd/license_test.go index 8dfcd60b..ace9b760 100644 --- a/cmd/license_test.go +++ b/cmd/license_test.go @@ -92,7 +92,7 @@ func innerTestLicenseListBuffered(t *testing.T, testInfo *LicenseTestInfo, where defer outputWriter.Flush() // Use a test input SBOM formatted in SPDX - utils.GlobalFlags.InputFile = testInfo.InputFile + utils.GlobalFlags.PersistentFlags.InputFile = testInfo.InputFile // Invoke the actual List command (API) err = ListLicenses(outputWriter, testInfo.ListFormat, whereFilters, testInfo.ListSummary) @@ -288,9 +288,9 @@ func TestLicenseListPolicyCdx14InvalidLicenseName(t *testing.T) { innerTestLicenseList(t, lti) } -//--------------------------- +// --------------------------- // Where filter tests -//--------------------------- +// --------------------------- func TestLicenseListSummaryTextCdx13WhereUsageNeedsReview(t *testing.T) { lti := NewLicenseTestInfoBasic(TEST_LICENSE_LIST_CDX_1_3, FORMAT_TEXT, true) lti.WhereClause = "usage-policy=needs-review" diff --git a/cmd/query.go b/cmd/query.go index 46489aa7..98f3bbe9 100644 --- a/cmd/query.go +++ b/cmd/query.go @@ -64,13 +64,20 @@ var QUERY_SUPPORTED_FORMATS = MSG_SUPPORTED_OUTPUT_FORMATS_HELP + // query JSON map and return selected subset // SELECT -// , , ... // "firstname, lastname, email" || * (default) +// +// , , ... // "firstname, lastname, email" || * (default) +// // FROM -// // "product.customers" +// +// // "product.customers" +// // WHERE -// == // "country='Germany'" +// +// == // "country='Germany'" +// // ORDER BY -// // "lastname" +// +// // "lastname" // // e.g.,SELECT * FROM product.customers WHERE country="Germany"; type QueryRequest struct { @@ -138,7 +145,7 @@ func initCommandQuery(command *cobra.Command) { defer getLogger().Exit() // Add local flags to command - command.PersistentFlags().StringVar(&utils.GlobalFlags.OutputFormat, FLAG_OUTPUT_FORMAT, FORMAT_JSON, + command.PersistentFlags().StringVar(&utils.GlobalFlags.PersistentFlags.OutputFormat, FLAG_OUTPUT_FORMAT, FORMAT_JSON, FLAG_QUERY_OUTPUT_FORMAT_HELP+QUERY_SUPPORTED_FORMATS) command.Flags().StringP(FLAG_QUERY_SELECT, "", QUERY_TOKEN_WILDCARD, FLAG_QUERY_SELECT_HELP) // NOTE: TODO: There appears to be a bug in Cobra where the type of the `from`` flag is `--from` (i.e., not string) diff --git a/cmd/query_test.go b/cmd/query_test.go index f79e6511..3e33b5a8 100644 --- a/cmd/query_test.go +++ b/cmd/query_test.go @@ -46,7 +46,7 @@ func innerQuery(t *testing.T, filename string, queryRequest *QueryRequest, autof } // Copy the test filename to the command line flags were the code looks for it - utils.GlobalFlags.InputFile = filename + utils.GlobalFlags.PersistentFlags.InputFile = filename // allocate response/result object and invoke query var response = new(QueryResponse) diff --git a/cmd/resource.go b/cmd/resource.go index f07661ec..f5c52d3e 100644 --- a/cmd/resource.go +++ b/cmd/resource.go @@ -112,7 +112,7 @@ func NewCommandResource() *cobra.Command { command.Use = CMD_USAGE_RESOURCE_LIST command.Short = "Report on resources found in BOM input file" command.Long = "Report on resources found in BOM input file" - command.Flags().StringVarP(&utils.GlobalFlags.OutputFormat, FLAG_FILE_OUTPUT_FORMAT, "", FORMAT_TEXT, + command.Flags().StringVarP(&utils.GlobalFlags.PersistentFlags.OutputFormat, FLAG_FILE_OUTPUT_FORMAT, "", FORMAT_TEXT, FLAG_RESOURCE_OUTPUT_FORMAT_HELP+RESOURCE_LIST_OUTPUT_SUPPORTED_FORMATS) command.Flags().StringP(FLAG_RESOURCE_TYPE, "", RESOURCE_TYPE_DEFAULT, FLAG_RESOURCE_TYPE_HELP) command.Flags().StringP(FLAG_REPORT_WHERE, "", "", FLAG_REPORT_WHERE_HELP) @@ -168,15 +168,16 @@ func resourceCmdImpl(cmd *cobra.Command, args []string) (err error) { defer getLogger().Exit() // Create output writer - outputFile, writer, err := createOutputFile(utils.GlobalFlags.OutputFile) - getLogger().Tracef("outputFile: `%v`; writer: `%v`", outputFile, writer) + outputFilename := utils.GlobalFlags.PersistentFlags.OutputFile + outputFile, writer, err := createOutputFile(outputFilename) + getLogger().Tracef("outputFile: `%v`; writer: `%v`", outputFilename, writer) // use function closure to assure consistent error output based upon error type defer func() { // always close the output file if outputFile != nil { outputFile.Close() - getLogger().Infof("Closed output file: `%s`", utils.GlobalFlags.OutputFile) + getLogger().Infof("Closed output file: `%s`", outputFilename) } }() @@ -187,7 +188,9 @@ func resourceCmdImpl(cmd *cobra.Command, args []string) (err error) { var resourceType string resourceType, err = retrieveResourceType(cmd) - ListResources(writer, utils.GlobalFlags.OutputFormat, resourceType, whereFilters) + if err == nil { + err = ListResources(writer, utils.GlobalFlags.PersistentFlags.OutputFormat, resourceType, whereFilters) + } return } diff --git a/cmd/resource_test.go b/cmd/resource_test.go index aa4a2a1c..65118783 100644 --- a/cmd/resource_test.go +++ b/cmd/resource_test.go @@ -88,7 +88,7 @@ func innerTestResourceList(t *testing.T, testInfo *ResourceTestInfo) (outputBuff } // The command looks for the input filename in global flags struct - utils.GlobalFlags.InputFile = testInfo.InputFile + utils.GlobalFlags.PersistentFlags.InputFile = testInfo.InputFile // invoke resource list command with a byte buffer outputBuffer, err = innerBufferedTestResourceList(t, testInfo, whereFilters) diff --git a/cmd/root.go b/cmd/root.go index 123b8e1f..0909cbfa 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -55,7 +55,7 @@ const ( CMD_USAGE_QUERY = CMD_QUERY + " --input-file [--select * | field1[,fieldN]] [--from [key1[.keyN]] [--where key=regex[,...]]" CMD_USAGE_RESOURCE_LIST = CMD_RESOURCE + " --input-file [--type component|service] [--where key=regex[,...]] [--format txt|csv|md]" CMD_USAGE_SCHEMA_LIST = CMD_SCHEMA + " [--where key=regex[,...]] [--format txt|csv|md]" - CMD_USAGE_VALIDATE = CMD_VALIDATE + " --input-file [--variant ] [--error-limit ] [--colorize=true|false] [--force schema_file]" + CMD_USAGE_VALIDATE = CMD_VALIDATE + " --input-file [--variant ] [--format txt|json] [--force schema_file]" CMD_USAGE_VULNERABILITY_LIST = CMD_VULNERABILITY + " " + SUBCOMMAND_VULNERABILITY_LIST + " --input-file [--summary] [--where key=regex[,...]] [--format json|txt|csv|md]" ) @@ -85,7 +85,7 @@ const ( MSG_FLAG_DEBUG = "enable debug logging" MSG_FLAG_INPUT = "input filename (e.g., \"path/sbom.json\")" MSG_FLAG_OUTPUT = "output filename" - MSG_FLAG_LOG_QUIET = "enable quiet logging mode (removes all information messages from console output); overrides other logging commands" + MSG_FLAG_LOG_QUIET = "enable quiet logging mode (removes all informational messages from console output); overrides other logging commands" MSG_FLAG_LOG_INDENT = "enable log indentation of functional callstack" MSG_FLAG_CONFIG_SCHEMA = "provide custom application schema configuration file (i.e., overrides default `config.json`)" MSG_FLAG_CONFIG_LICENSE = "provide custom application license policy configuration file (i.e., overrides default `license.json`)" @@ -155,15 +155,15 @@ func init() { //rootCmd.PersistentFlags().StringVarP(&utils.GlobalFlags.ConfigCustomValidationFile, FLAG_CONFIG_CUSTOM_VALIDATION, "", DEFAULT_CUSTOM_VALIDATION_CONFIG, "TODO") // Declare top-level, persistent flags and where to place the post-parse values - rootCmd.PersistentFlags().BoolVarP(&utils.GlobalFlags.Trace, FLAG_TRACE, FLAG_TRACE_SHORT, false, MSG_FLAG_TRACE) - rootCmd.PersistentFlags().BoolVarP(&utils.GlobalFlags.Debug, FLAG_DEBUG, FLAG_DEBUG_SHORT, false, MSG_FLAG_DEBUG) - rootCmd.PersistentFlags().StringVarP(&utils.GlobalFlags.InputFile, FLAG_FILENAME_INPUT, FLAG_FILENAME_INPUT_SHORT, "", MSG_FLAG_INPUT) - rootCmd.PersistentFlags().StringVarP(&utils.GlobalFlags.OutputFile, FLAG_FILENAME_OUTPUT, FLAG_FILENAME_OUTPUT_SHORT, "", MSG_FLAG_OUTPUT) + rootCmd.PersistentFlags().BoolVarP(&utils.GlobalFlags.PersistentFlags.Trace, FLAG_TRACE, FLAG_TRACE_SHORT, false, MSG_FLAG_TRACE) + rootCmd.PersistentFlags().BoolVarP(&utils.GlobalFlags.PersistentFlags.Debug, FLAG_DEBUG, FLAG_DEBUG_SHORT, false, MSG_FLAG_DEBUG) + rootCmd.PersistentFlags().StringVarP(&utils.GlobalFlags.PersistentFlags.InputFile, FLAG_FILENAME_INPUT, FLAG_FILENAME_INPUT_SHORT, "", MSG_FLAG_INPUT) + rootCmd.PersistentFlags().StringVarP(&utils.GlobalFlags.PersistentFlags.OutputFile, FLAG_FILENAME_OUTPUT, FLAG_FILENAME_OUTPUT_SHORT, "", MSG_FLAG_OUTPUT) // NOTE: Although we check for the quiet mode flag in main; we track the flag // using Cobra framework in order to enable more comprehensive help // and take advantage of other features. - rootCmd.PersistentFlags().BoolVarP(&utils.GlobalFlags.Quiet, FLAG_QUIET_MODE, FLAG_QUIET_MODE_SHORT, false, MSG_FLAG_LOG_QUIET) + rootCmd.PersistentFlags().BoolVarP(&utils.GlobalFlags.PersistentFlags.Quiet, FLAG_QUIET_MODE, FLAG_QUIET_MODE_SHORT, false, MSG_FLAG_LOG_QUIET) // Optionally, allow log callstack trace to be indented rootCmd.PersistentFlags().BoolVarP(&utils.GlobalFlags.LogOutputIndentCallstack, FLAG_LOG_OUTPUT_INDENT, "", false, MSG_FLAG_LOG_INDENT) @@ -260,11 +260,11 @@ func preRunTestForInputFile(cmd *cobra.Command, args []string) error { getLogger().Tracef("args: %v", args) // Make sure the input filename is present and exists - file := utils.GlobalFlags.InputFile - if file == "" { + inputFilename := utils.GlobalFlags.PersistentFlags.InputFile + if inputFilename == "" { return getLogger().Errorf("Missing required argument(s): %s", FLAG_FILENAME_INPUT) - } else if _, err := os.Stat(file); err != nil { - return getLogger().Errorf("File not found: `%s`", file) + } else if _, err := os.Stat(inputFilename); err != nil { + return getLogger().Errorf("File not found: `%s`", inputFilename) } return nil } diff --git a/cmd/root_test.go b/cmd/root_test.go index c3579931..a5d6fe8e 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -119,9 +119,9 @@ func TestMain(m *testing.M) { flag.Parse() } getLogger().Tracef("Setting Debug=`%t`, Trace=`%t`, Quiet=`%t`,", *TestLogLevelDebug, *TestLogLevelTrace, *TestLogQuiet) - utils.GlobalFlags.Trace = *TestLogLevelTrace - utils.GlobalFlags.Debug = *TestLogLevelDebug - utils.GlobalFlags.Quiet = *TestLogQuiet + utils.GlobalFlags.PersistentFlags.Trace = *TestLogLevelTrace + utils.GlobalFlags.PersistentFlags.Debug = *TestLogLevelDebug + utils.GlobalFlags.PersistentFlags.Quiet = *TestLogQuiet // Load configs, create logger, etc. // NOTE: Be sure ALL "go test" flags are parsed/processed BEFORE initializing diff --git a/cmd/schema.go b/cmd/schema.go index c349027a..40515bdd 100644 --- a/cmd/schema.go +++ b/cmd/schema.go @@ -73,7 +73,7 @@ func NewCommandSchema() *cobra.Command { command.Use = CMD_USAGE_SCHEMA_LIST // "schema" command.Short = "View supported SBOM schemas" command.Long = fmt.Sprintf("View built-in SBOM schemas supported by the utility. The default command produces a list based upon `%s`.", DEFAULT_SCHEMA_CONFIG) - command.Flags().StringVarP(&utils.GlobalFlags.OutputFormat, FLAG_FILE_OUTPUT_FORMAT, "", FORMAT_TEXT, + command.Flags().StringVarP(&utils.GlobalFlags.PersistentFlags.OutputFormat, FLAG_FILE_OUTPUT_FORMAT, "", FORMAT_TEXT, FLAG_SCHEMA_OUTPUT_FORMAT_HELP+SCHEMA_LIST_SUPPORTED_FORMATS) command.Flags().StringP(FLAG_REPORT_WHERE, "", "", FLAG_REPORT_WHERE_HELP) command.RunE = schemaCmdImpl @@ -106,7 +106,8 @@ func schemaCmdImpl(cmd *cobra.Command, args []string) (err error) { defer getLogger().Exit() // Create output writer - outputFile, writer, err := createOutputFile(utils.GlobalFlags.OutputFile) + outputFilename := utils.GlobalFlags.PersistentFlags.OutputFile + outputFile, writer, err := createOutputFile(outputFilename) getLogger().Tracef("outputFile: `%v`; writer: `%v`", outputFile, writer) // use function closure to assure consistent error output based upon error type @@ -114,7 +115,7 @@ func schemaCmdImpl(cmd *cobra.Command, args []string) (err error) { // always close the output file if outputFile != nil { err = outputFile.Close() - getLogger().Infof("Closed output file: `%s`", utils.GlobalFlags.OutputFile) + getLogger().Infof("Closed output file: `%s`", outputFilename) } }() @@ -206,7 +207,8 @@ func ListSchemas(writer io.Writer, whereFilters []WhereFilter) (err error) { } // default output (writer) to standard out - switch utils.GlobalFlags.OutputFormat { + format := utils.GlobalFlags.PersistentFlags.OutputFormat + switch format { case FORMAT_DEFAULT: // defaults to text if no explicit `--format` parameter err = DisplaySchemasTabbedText(writer, filteredSchemas) @@ -218,8 +220,7 @@ func ListSchemas(writer io.Writer, whereFilters []WhereFilter) (err error) { err = DisplaySchemasMarkdown(writer, filteredSchemas) default: // default to text format for anything else - getLogger().Warningf("Unsupported format: `%s`; using default format.", - utils.GlobalFlags.OutputFormat) + getLogger().Warningf("unsupported format: `%s`; using default format.", format) err = DisplaySchemasTabbedText(writer, filteredSchemas) } return diff --git a/cmd/validate.go b/cmd/validate.go index 701fe730..dce748ed 100644 --- a/cmd/validate.go +++ b/cmd/validate.go @@ -17,13 +17,14 @@ package cmd +// "github.com/iancoleman/orderedmap" import ( "encoding/json" "fmt" + "io" "os" "strings" - "github.com/CycloneDX/sbom-utility/log" "github.com/CycloneDX/sbom-utility/resources" "github.com/CycloneDX/sbom-utility/schema" "github.com/CycloneDX/sbom-utility/utils" @@ -37,18 +38,27 @@ const ( ) // validation flags +// TODO: support a `--truncate “ flag (or similar... `err-value-truncate` ) used +// to truncate formatted "value" (details) to bytes. +// This would replace the hardcoded "DEFAULT_MAX_ERR_DESCRIPTION_LEN" value const ( - FLAG_SCHEMA_FORCE = "force" - FLAG_SCHEMA_VARIANT = "variant" - FLAG_CUSTOM_VALIDATION = "custom" // TODO: document when no longer experimental - FLAG_ERR_LIMIT = "error-limit" - MSG_SCHEMA_FORCE = "force specified schema file for validation; overrides inferred schema" - MSG_SCHEMA_VARIANT = "select named schema variant (e.g., \"strict\"); variant must be declared in configuration file (i.e., \"config.json\")" - MSG_FLAG_CUSTOM_VALIDATION = "perform custom validation using custom configuration settings (i.e., \"custom.json\")" - MSG_FLAG_ERR_COLORIZE = "Colorize formatted error output (true|false); default true" - MSG_FLAG_ERR_LIMIT = "Limit number of errors output (integer); default 10" + FLAG_VALIDATE_SCHEMA_FORCE = "force" + FLAG_VALIDATE_SCHEMA_VARIANT = "variant" + FLAG_VALIDATE_CUSTOM = "custom" // TODO: document when no longer experimental + FLAG_VALIDATE_ERR_LIMIT = "error-limit" + FLAG_VALIDATE_ERR_VALUE = "error-value" + MSG_VALIDATE_SCHEMA_FORCE = "force specified schema file for validation; overrides inferred schema" + MSG_VALIDATE_SCHEMA_VARIANT = "select named schema variant (e.g., \"strict\"); variant must be declared in configuration file (i.e., \"config.json\")" + MSG_VALIDATE_FLAG_CUSTOM = "perform custom validation using custom configuration settings (i.e., \"custom.json\")" + MSG_VALIDATE_FLAG_ERR_COLORIZE = "Colorize formatted error output (true|false); default true" + MSG_VALIDATE_FLAG_ERR_LIMIT = "Limit number of errors output to specified (integer) (default 10)" + MSG_VALIDATE_FLAG_ERR_FORMAT = "format error results using the specified format type" + MSG_VALIDATE_FLAG_ERR_VALUE = "include details of failing value in error results (bool) (default: true)" ) +var VALIDATE_SUPPORTED_ERROR_FORMATS = MSG_VALIDATE_FLAG_ERR_FORMAT + + strings.Join([]string{FORMAT_TEXT, FORMAT_JSON}, ", ") + " (default: txt)" + // limits const ( DEFAULT_MAX_ERROR_LIMIT = 10 @@ -67,45 +77,65 @@ func NewCommandValidate() *cobra.Command { command.Short = "Validate input file against its declared BOM schema" command.Long = "Validate input file against its declared BOM schema, if detectable and supported." command.RunE = validateCmdImpl - + command.Flags().StringVarP(&utils.GlobalFlags.PersistentFlags.OutputFormat, FLAG_FILE_OUTPUT_FORMAT, "", "", + MSG_VALIDATE_FLAG_ERR_FORMAT+VALIDATE_SUPPORTED_ERROR_FORMATS) command.PreRunE = func(cmd *cobra.Command, args []string) error { // This command can be called with this persistent flag, but does not make sense... - inputFile := utils.GlobalFlags.InputFile + inputFile := utils.GlobalFlags.PersistentFlags.InputFile if inputFile != "" { getLogger().Warningf("Invalid flag for command: `%s` (`%s`). Ignoring...", FLAG_FILENAME_OUTPUT, FLAG_FILENAME_OUTPUT_SHORT) } return preRunTestForInputFile(cmd, args) } - initCommandValidate(command) + initCommandValidateFlags(command) return command } // Add local flags to validate command -func initCommandValidate(command *cobra.Command) { +func initCommandValidateFlags(command *cobra.Command) { getLogger().Enter() defer getLogger().Exit() // Force a schema file to use for validation (override inferred schema) - command.Flags().StringVarP(&utils.GlobalFlags.ValidateFlags.ForcedJsonSchemaFile, FLAG_SCHEMA_FORCE, "", "", MSG_SCHEMA_FORCE) + command.Flags().StringVarP(&utils.GlobalFlags.ValidateFlags.ForcedJsonSchemaFile, FLAG_VALIDATE_SCHEMA_FORCE, "", "", MSG_VALIDATE_SCHEMA_FORCE) // Optional schema "variant" of inferred schema (e.g, "strict") - command.Flags().StringVarP(&utils.GlobalFlags.Variant, FLAG_SCHEMA_VARIANT, "", "", MSG_SCHEMA_VARIANT) - command.Flags().BoolVarP(&utils.GlobalFlags.CustomValidation, FLAG_CUSTOM_VALIDATION, "", false, MSG_FLAG_CUSTOM_VALIDATION) - // Colorize default: true (for historical reasons) - command.Flags().BoolVarP(&utils.GlobalFlags.ValidateFlags.ColorizeJsonErrors, FLAG_COLORIZE_OUTPUT, "", true, MSG_FLAG_ERR_COLORIZE) - command.Flags().IntVarP(&utils.GlobalFlags.ValidateFlags.MaxNumErrors, FLAG_ERR_LIMIT, "", DEFAULT_MAX_ERROR_LIMIT, MSG_FLAG_ERR_LIMIT) + command.Flags().StringVarP(&utils.GlobalFlags.ValidateFlags.SchemaVariant, FLAG_VALIDATE_SCHEMA_VARIANT, "", "", MSG_VALIDATE_SCHEMA_VARIANT) + command.Flags().BoolVarP(&utils.GlobalFlags.ValidateFlags.CustomValidation, FLAG_VALIDATE_CUSTOM, "", false, MSG_VALIDATE_FLAG_CUSTOM) + command.Flags().BoolVarP(&utils.GlobalFlags.ValidateFlags.ColorizeErrorOutput, FLAG_COLORIZE_OUTPUT, "", false, MSG_VALIDATE_FLAG_ERR_COLORIZE) + command.Flags().IntVarP(&utils.GlobalFlags.ValidateFlags.MaxNumErrors, FLAG_VALIDATE_ERR_LIMIT, "", DEFAULT_MAX_ERROR_LIMIT, MSG_VALIDATE_FLAG_ERR_LIMIT) + command.Flags().BoolVarP(&utils.GlobalFlags.ValidateFlags.ShowErrorValue, FLAG_VALIDATE_ERR_VALUE, "", true, MSG_VALIDATE_FLAG_ERR_COLORIZE) } func validateCmdImpl(cmd *cobra.Command, args []string) error { getLogger().Enter() defer getLogger().Exit() + // Create output writer + outputFilename := utils.GlobalFlags.PersistentFlags.OutputFile + outputFile, writer, err := createOutputFile(outputFilename) + + // Note: all invalid SBOMs (that fail schema validation) MUST result in an InvalidSBOMError() + if err != nil { + // TODO: assure this gets normalized + getLogger().Error(err) + os.Exit(ERROR_APPLICATION) + } + + // use function closure to assure consistent error output based upon error type + defer func() { + // always close the output file + if outputFile != nil { + err = outputFile.Close() + getLogger().Infof("Closed output file: `%s`", outputFilename) + } + }() + // invoke validate and consistently manage exit messages and codes - isValid, _, _, err := Validate() + isValid, _, _, err := Validate(writer, utils.GlobalFlags.PersistentFlags, utils.GlobalFlags.ValidateFlags) - // Note: all invalid SBOMs (that fail schema validation) SHOULD result in an - // InvalidSBOMError() + // Note: all invalid SBOMs (that fail schema validation) MUST result in an InvalidSBOMError() if err != nil { if IsInvalidSBOMError(err) { os.Exit(ERROR_VALIDATION) @@ -118,6 +148,8 @@ func validateCmdImpl(cmd *cobra.Command, args []string) error { // TODO: remove this if we can assure that we ALWAYS return an // IsInvalidSBOMError(err) in these cases from the Validate() method if !isValid { + // TODO: if JSON validation resulted in !valid, turn that into an + // InvalidSBOMError and test to make sure this works in all cases os.Exit(ERROR_VALIDATION) } @@ -125,11 +157,8 @@ func validateCmdImpl(cmd *cobra.Command, args []string) error { return nil } -// Normalize error/processValidationResults from the Validate() function -func processValidationResults(document *schema.Sbom, valid bool, err error) { - - // TODO: if JSON validation resulted in !valid, turn that into an - // InvalidSBOMError and test to make sure this works in all cases +// Normalize error/normalizeValidationErrorTypes from the Validate() function +func normalizeValidationErrorTypes(document *schema.Sbom, valid bool, err error) { // Consistently display errors before exiting if err != nil { @@ -142,7 +171,7 @@ func processValidationResults(document *schema.Sbom, valid bool, err error) { // Note: InvalidSBOMError type errors include schema errors which have already // been added to the error type and will shown with the Error() interface if valid { - getLogger().Errorf("invalid state: error (%T) returned, but SBOM valid !!!", t) + _ = getLogger().Errorf("invalid state: error (%T) returned, but SBOM valid!", t) } getLogger().Error(err) default: @@ -156,14 +185,14 @@ func processValidationResults(document *schema.Sbom, valid bool, err error) { getLogger().Info(message) } -func Validate() (valid bool, document *schema.Sbom, schemaErrors []gojsonschema.ResultError, err error) { +func Validate(output io.Writer, persistentFlags utils.PersistentCommandFlags, validateFlags utils.ValidateCommandFlags) (valid bool, document *schema.Sbom, schemaErrors []gojsonschema.ResultError, err error) { getLogger().Enter() defer getLogger().Exit() // use function closure to assure consistent error output based upon error type defer func() { if err != nil { - processValidationResults(document, valid, err) + normalizeValidationErrorTypes(document, valid, err) } }() @@ -175,18 +204,19 @@ func Validate() (valid bool, document *schema.Sbom, schemaErrors []gojsonschema. } // if "custom" flag exists, then assure we support the format - if utils.GlobalFlags.CustomValidation && !document.FormatInfo.IsCycloneDx() { + if validateFlags.CustomValidation && !document.FormatInfo.IsCycloneDx() { err = schema.NewUnsupportedFormatError( schema.MSG_FORMAT_UNSUPPORTED_COMMAND, document.GetFilename(), document.FormatInfo.CanonicalName, CMD_VALIDATE, - FLAG_CUSTOM_VALIDATION) + FLAG_VALIDATE_CUSTOM) return valid, document, schemaErrors, err } // Create a loader for the SBOM (JSON) document - documentLoader := gojsonschema.NewReferenceLoader(PROTOCOL_PREFIX_FILE + utils.GlobalFlags.InputFile) + inputFile := persistentFlags.InputFile + documentLoader := gojsonschema.NewReferenceLoader(PROTOCOL_PREFIX_FILE + inputFile) schemaName := document.SchemaInfo.File var schemaLoader gojsonschema.JSONLoader @@ -196,7 +226,7 @@ func Validate() (valid bool, document *schema.Sbom, schemaErrors []gojsonschema. // If caller "forced" a specific schema file (version), load it instead of // any SchemaInfo found in config.json // TODO: support remote schema load (via URL) with a flag (default should always be local file for security) - forcedSchemaFile := utils.GlobalFlags.ValidateFlags.ForcedJsonSchemaFile + forcedSchemaFile := validateFlags.ForcedJsonSchemaFile if forcedSchemaFile != "" { getLogger().Infof("Validating document using forced schema (i.e., `--force %s`)", forcedSchemaFile) //schemaName = document.SchemaInfo.File @@ -272,146 +302,64 @@ func Validate() (valid bool, document *schema.Sbom, schemaErrors []gojsonschema. MSG_SCHEMA_ERRORS, nil, schemaErrors) - // Append formatted schema errors "details" to the InvalidSBOMError type - formattedSchemaErrors := FormatSchemaErrors(schemaErrors) - errInvalid.Details = formattedSchemaErrors - - return INVALID, document, schemaErrors, errInvalid - } - // If the validated SBOM is of a known format, we can unmarshal it into - // more convenient typed structure for simplified custom validation - if document.FormatInfo.IsCycloneDx() { - document.CdxBom, err = schema.UnMarshalDocument(document.GetJSONMap()) - if err != nil { - return INVALID, document, schemaErrors, err + // TODO: de-duplicate errors (e.g., array item not "unique"...) + var formattedErrors string + switch persistentFlags.OutputFormat { + case FORMAT_JSON: + // Note: JSON data files MUST ends in a newline s as this is a POSIX standard + formattedErrors = FormatSchemaErrors(schemaErrors, validateFlags, FORMAT_JSON) + // getLogger().Debugf("%s", formattedErrors) + fmt.Fprintf(output, "%s", formattedErrors) + case FORMAT_TEXT: + fallthrough + default: + // Format error results and append to InvalidSBOMError error "details" + formattedErrors = FormatSchemaErrors(schemaErrors, validateFlags, FORMAT_TEXT) + errInvalid.Details = formattedErrors } + + return INVALID, document, schemaErrors, errInvalid } + // TODO: Perhaps factor in these errors into the JSON output as if they were actual schema errors... // Perform additional validation in document composition/structure // and "custom" required data within specified fields - if utils.GlobalFlags.CustomValidation { - // Perform all custom validation - err := validateCustomCDXDocument(document) - if err != nil { - // Wrap any specific validation error in a single invalid SBOM error - if !IsInvalidSBOMError(err) { - err = NewInvalidSBOMError( - document, - err.Error(), - err, - nil) - } - // an error implies it is also invalid (according to custom requirements) - return INVALID, document, schemaErrors, err - } + if validateFlags.CustomValidation { + valid, err = validateCustom(document) } // All validation tests passed; return VALID return } -func FormatSchemaErrors(errs []gojsonschema.ResultError) string { - var sb strings.Builder - - lenErrs := len(errs) - if lenErrs > 0 { - errLimit := utils.GlobalFlags.ValidateFlags.MaxNumErrors - colorize := utils.GlobalFlags.ValidateFlags.ColorizeJsonErrors - var formattedValue string - var description string - var failingObject string - - sb.WriteString(fmt.Sprintf("\n(%d) Schema errors detected (use `--debug` for more details):", lenErrs)) - for i, resultError := range errs { - - // short-circuit if we have too many errors - if i == errLimit { - // notify users more errors exist - msg := fmt.Sprintf("Too many errors. Showing (%v/%v) errors.", i, len(errs)) - getLogger().Infof("%s", msg) - // always include limit message in discrete output (i.e., not turned off by --quiet flag) - sb.WriteString("\n" + msg) - break - } - - // Some descriptions include very long enums; in those cases, - // truncate to a reasonable length using an intelligent separator - description = resultError.Description() - // truncate output unless debug flag is used - if !utils.GlobalFlags.Debug && - len(description) > DEFAULT_MAX_ERR_DESCRIPTION_LEN { - description, _, _ = strings.Cut(description, ":") - description = description + " ... (truncated)" - } - - // TODO: provide flag to allow users to "turn on", by default we do NOT want this - // as this slows down processing on SBOMs with large numbers of errors - if colorize { - formattedValue, _ = log.FormatInterfaceAsColorizedJson(resultError.Value()) - } - // Indent error detail output in logs - formattedValue = log.AddTabs(formattedValue) - // NOTE: if we do not colorize or indent we could simply do this: - failingObject = fmt.Sprintf("\n\tFailing object: [%v]", formattedValue) - - // truncate output unless debug flag is used - if !utils.GlobalFlags.Debug && - len(failingObject) > DEFAULT_MAX_ERR_DESCRIPTION_LEN { - failingObject = failingObject[:DEFAULT_MAX_ERR_DESCRIPTION_LEN] - failingObject = failingObject + " ... (truncated)" - } - - // append the numbered schema error - schemaErrorText := fmt.Sprintf("\n\t%d. Type: [%s], Field: [%s], Description: [%s] %s", - i+1, - resultError.Type(), - resultError.Field(), - description, - failingObject) - - sb.WriteString(schemaErrorText) +func validateCustom(document *schema.Sbom) (valid bool, err error) { - // TODO: leave commented out as we do not want to slow processing... - //getLogger().Debugf("processing error (%v): type: `%s`", i, resultError.Type()) + // If the validated SBOM is of a known format, we can unmarshal it into + // more convenient typed structure for simplified custom validation + if document.FormatInfo.IsCycloneDx() { + document.CdxBom, err = schema.UnMarshalDocument(document.GetJSONMap()) + if err != nil { + return INVALID, err } } - return sb.String() -} -func schemaErrorExists(schemaErrors []gojsonschema.ResultError, - expectedType string, expectedField string, expectedValue interface{}) bool { - - for i, resultError := range schemaErrors { - // Some descriptions include very long enums; in those cases, - // truncate to a reasonable length using an intelligent separator - getLogger().Tracef(">> %d. Type: [%s], Field: [%s], Value: [%v]", - i+1, - resultError.Type(), - resultError.Field(), - resultError.Value()) - - actualType := resultError.Type() - actualField := resultError.Field() - actualValue := resultError.Value() - - if actualType == expectedType { - // we have matched on the type (key) field, continue to match other fields - if expectedField != "" && - actualField != expectedField { - getLogger().Tracef("expected Field: `%s`; actual Field: `%s`", expectedField, actualField) - return false - } - - if expectedValue != "" && - actualValue != expectedValue { - getLogger().Tracef("expected Value: `%s`; actual Value: `%s`", actualValue, expectedValue) - return false - } - return true - } else { - getLogger().Debugf("Skipping result[%d]: expected Type: `%s`; actual Type: `%s`", i, expectedType, actualType) + // Perform all custom validation + // TODO Implement customValidation as an interface supported by the CDXDocument type + // and later supported by a SPDXDocument type. + err = validateCustomCDXDocument(document) + if err != nil { + // Wrap any specific validation error in a single invalid SBOM error + if !IsInvalidSBOMError(err) { + err = NewInvalidSBOMError( + document, + err.Error(), + err, + nil) } + // an error implies it is also invalid (according to custom requirements) + return INVALID, err } - return false + + return VALID, nil } diff --git a/cmd/validate_cdx_examples_test.go b/cmd/validate_cdx_examples_test.go index c9938d8f..fb22d42d 100644 --- a/cmd/validate_cdx_examples_test.go +++ b/cmd/validate_cdx_examples_test.go @@ -63,86 +63,86 @@ const ( ) func TestValidateExampleCdx14UseCaseAssembly(t *testing.T) { - innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_ASSEMBLY, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_ASSEMBLY, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } func TestValidateExampleCdx14UseCaseAuthenticityJsf(t *testing.T) { - innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_AUTHENTICITY_JSF, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_AUTHENTICITY_JSF, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } func TestValidateExampleCdx14UseCaseComponentKnownVulnerabilities(t *testing.T) { - innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_COMP_KNOWN_VULN, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_COMP_KNOWN_VULN, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } func TestValidateExampleCdx14UseCaseCompositionAndCompleteness(t *testing.T) { - innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_COMPOSITION_COMPLETENESS, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_COMPOSITION_COMPLETENESS, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } func TestValidateExampleCdx14UseCaseDependencyGraph(t *testing.T) { - innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_DEP_GRAPH, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_DEP_GRAPH, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } func TestValidateExampleCdx14UseCaseExternalReferences(t *testing.T) { - innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_EXT_REFS, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_EXT_REFS, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } func TestValidateExampleCdx14UseCaseIntegrityVerification(t *testing.T) { - innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_INTEGRITY_VERIFICATION, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_INTEGRITY_VERIFICATION, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } func TestValidateExampleCdx14UseCaseInventory(t *testing.T) { - innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_INVENTORY, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_INVENTORY, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } func TestValidateExampleCdx14UseCaseLicenseCompliance(t *testing.T) { - innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_LICENSE_COMPLIANCE, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_LICENSE_COMPLIANCE, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } func TestValidateExampleCdx14UseCaseOpenChainConformance(t *testing.T) { - innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_OPENCHAIN_CONFORMANCE, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_OPENCHAIN_CONFORMANCE, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } func TestValidateExampleCdx14UseCasePackageEvaluation(t *testing.T) { - innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_PKG_EVALUATION, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_PKG_EVALUATION, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } func TestValidateExampleCdx14UseCasePackagingDistribution(t *testing.T) { - innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_PKG_DIST, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_PKG_DIST, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } func TestValidateExampleCdx14UseCasePedigree(t *testing.T) { - innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_PEDIGREE, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_PEDIGREE, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } func TestValidateExampleCdx14UseCaseProvenance(t *testing.T) { - innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_PROVENANCE, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_PROVENANCE, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } func TestValidateExampleCdx14UseCaseSecurityAdvisories(t *testing.T) { - innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_SEC_ADVISORIES, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_SEC_ADVISORIES, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } func TestValidateExampleCdx14UseCaseServiceDefinition(t *testing.T) { - innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_SVC_DEFN, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_SVC_DEFN, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } func TestValidateExampleCdx14UseCaseVulnerabilityExploitation(t *testing.T) { - innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_VULN_EXPLOITATION, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_VULN_EXPLOITATION, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } func TestValidateExampleCdx14UseCaseVulnerabilityRemediation(t *testing.T) { - innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_VULN_REMEDIATION, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_EXAMPLE_CDX_1_4_USE_CASE_VULN_REMEDIATION, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } // CycloneDX - Examples func TestValidateExampleBomCdx12NpmJuiceShop(t *testing.T) { - innerValidateError(t, TEST_CDX_1_2_EXAMPLE_BOM_NPM_JUICE_SHOP, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_CDX_1_2_EXAMPLE_BOM_NPM_JUICE_SHOP, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } func TestValidateExampleBomCdx13Laravel(t *testing.T) { - innerValidateError(t, TEST_CDX_1_3_EXAMPLE_BOM_LARAVEL, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_CDX_1_3_EXAMPLE_BOM_LARAVEL, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } func TestValidateExampleSaaSBomCdx14ApiGatewayDatastores(t *testing.T) { - innerValidateError(t, TEST_CDX_1_4_EXAMPLE_SAASBOM_APIGW_MS_DATASTORES, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_CDX_1_4_EXAMPLE_SAASBOM_APIGW_MS_DATASTORES, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } diff --git a/cmd/validate_cdx_test.go b/cmd/validate_cdx_test.go index d528b87d..fdc76970 100644 --- a/cmd/validate_cdx_test.go +++ b/cmd/validate_cdx_test.go @@ -37,11 +37,11 @@ const ( // ----------------------------------------------------------- func TestValidateCdx13MinRequiredBasic(t *testing.T) { - innerValidateError(t, TEST_CDX_1_3_MIN_REQUIRED, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_CDX_1_3_MIN_REQUIRED, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } func TestValidateCdx14MinRequiredBasic(t *testing.T) { - innerValidateError(t, TEST_CDX_1_4_MIN_REQUIRED, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_CDX_1_4_MIN_REQUIRED, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } // ----------------------------------------------------------- diff --git a/cmd/validate_config_test.go b/cmd/validate_config_test.go index f41448e9..63c19ce8 100644 --- a/cmd/validate_config_test.go +++ b/cmd/validate_config_test.go @@ -42,6 +42,7 @@ func TestValidateConfigInvalidFormatKey(t *testing.T) { innerValidateError(t, TEST_INVALID_FORMAT_KEY_FOO, SCHEMA_VARIANT_NONE, + FORMAT_TEXT, &schema.UnsupportedFormatError{}) } @@ -49,6 +50,7 @@ func TestValidateConfigInvalidVersion(t *testing.T) { innerValidateError(t, TEST_CDX_SPEC_VERSION_INVALID, SCHEMA_VARIANT_NONE, + FORMAT_TEXT, &schema.UnsupportedSchemaError{}) } @@ -56,6 +58,7 @@ func TestValidateConfigInvalidVariant(t *testing.T) { innerValidateError(t, TEST_CDX_1_4_MIN_REQUIRED, "foo", + FORMAT_TEXT, &schema.UnsupportedSchemaError{}) } @@ -63,6 +66,7 @@ func TestValidateConfigCDXBomFormatInvalid(t *testing.T) { innerValidateError(t, TEST_CDX_BOM_FORMAT_INVALID, SCHEMA_VARIANT_NONE, + FORMAT_TEXT, &schema.UnsupportedFormatError{}) } @@ -70,6 +74,7 @@ func TestValidateConfigCDXBomFormatMissing(t *testing.T) { innerValidateError(t, TEST_CDX_BOM_FORMAT_MISSING, SCHEMA_VARIANT_NONE, + FORMAT_TEXT, &schema.UnsupportedFormatError{}) } @@ -77,6 +82,7 @@ func TestValidateConfigCDXSpecVersionMissing(t *testing.T) { innerValidateError(t, TEST_CDX_SPEC_VERSION_MISSING, SCHEMA_VARIANT_NONE, + FORMAT_TEXT, &schema.UnsupportedSchemaError{}) } @@ -84,6 +90,7 @@ func TestValidateConfigSPDXSpdxIdInvalid(t *testing.T) { innerValidateError(t, TEST_SPDX_SPDX_ID_INVALID, SCHEMA_VARIANT_NONE, + FORMAT_TEXT, &schema.UnsupportedFormatError{}) } @@ -91,5 +98,6 @@ func TestValidateConfigSPDXSpdxVersionInvalid(t *testing.T) { innerValidateError(t, TEST_SPDX_SPDX_VERSION_MISSING, SCHEMA_VARIANT_NONE, + FORMAT_TEXT, &schema.UnsupportedSchemaError{}) } diff --git a/cmd/validate_custom_test.go b/cmd/validate_custom_test.go index 5234e9b4..69835745 100644 --- a/cmd/validate_custom_test.go +++ b/cmd/validate_custom_test.go @@ -57,16 +57,16 @@ const ( // ------------------------------------------- func innerCustomValidateError(t *testing.T, filename string, variant string, innerError error) (document *schema.Sbom, schemaErrors []gojsonschema.ResultError, actualError error) { - utils.GlobalFlags.CustomValidation = true - document, schemaErrors, actualError = innerValidateError(t, filename, variant, innerError) - utils.GlobalFlags.CustomValidation = false + utils.GlobalFlags.ValidateFlags.CustomValidation = true + document, schemaErrors, actualError = innerValidateError(t, filename, variant, FORMAT_TEXT, innerError) + utils.GlobalFlags.ValidateFlags.CustomValidation = false return } func innerCustomValidateInvalidSBOMInnerError(t *testing.T, filename string, variant string, innerError error) (document *schema.Sbom, schemaErrors []gojsonschema.ResultError, actualError error) { - utils.GlobalFlags.CustomValidation = true + utils.GlobalFlags.ValidateFlags.CustomValidation = true document, schemaErrors, actualError = innerValidateInvalidSBOMInnerError(t, filename, variant, innerError) - utils.GlobalFlags.CustomValidation = false + utils.GlobalFlags.ValidateFlags.CustomValidation = false return } @@ -103,6 +103,7 @@ func TestValidateCustomCdx14MetadataPropsMissingDisclaimer(t *testing.T) { document, results, _ := innerValidateError(t, TEST_CUSTOM_CDX_1_4_METADATA_PROPS_DISCLAIMER_MISSING, SCHEMA_VARIANT_CUSTOM, + FORMAT_TEXT, &InvalidSBOMError{}) getLogger().Debugf("filename: `%s`, results:\n%v", document.GetFilename(), results) } @@ -111,6 +112,7 @@ func TestValidateCustomCdx14MetadataPropsMissingClassification(t *testing.T) { document, results, _ := innerValidateError(t, TEST_CUSTOM_CDX_1_4_METADATA_PROPS_CLASSIFICATION_MISSING, SCHEMA_VARIANT_CUSTOM, + FORMAT_TEXT, &InvalidSBOMError{}) getLogger().Debugf("filename: `%s`, results:\n%v", document.GetFilename(), results) } diff --git a/cmd/validate_format.go b/cmd/validate_format.go new file mode 100644 index 00000000..781262b6 --- /dev/null +++ b/cmd/validate_format.go @@ -0,0 +1,306 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cmd + +// "github.com/iancoleman/orderedmap" +import ( + "fmt" + "strconv" + "strings" + + "github.com/CycloneDX/sbom-utility/log" + "github.com/CycloneDX/sbom-utility/utils" + "github.com/iancoleman/orderedmap" + "github.com/xeipuuv/gojsonschema" +) + +const ( + ERROR_DETAIL_KEY_FIELD = "field" + ERROR_DETAIL_KEY_CONTEXT = "context" + ERROR_DETAIL_KEY_VALUE = "value" + ERROR_DETAIL_KEY_DATA_TYPE = "type" + ERROR_DETAIL_KEY_VALUE_TYPE_ARRAY = "array" + ERROR_DETAIL_KEY_VALUE_INDEX = "index" + ERROR_DETAIL_KEY_VALUE_ITEM = "item" + ERROR_DETAIL_KEY_VALUE_DESCRIPTION = "description" + ERROR_DETAIL_ARRAY_ITEM_INDEX_I = "i" + ERROR_DETAIL_ARRAY_ITEM_INDEX_J = "j" +) + +const ( + ERROR_DETAIL_JSON_DEFAULT_PREFIX = " " + ERROR_DETAIL_JSON_DEFAULT_INDENT = " " + ERROR_DETAIL_JSON_CONTEXT_DELIMITER = "." + ERROR_DETAIL_JSON_NEWLINE_INDENT = "\n" + ERROR_DETAIL_JSON_DEFAULT_PREFIX +) + +// JSON formatting +const ( + JSON_ARRAY_START = "[\n" + JSON_ARRAY_ITEM_SEP = ",\n" + JSON_ARRAY_END = "\n]\n" +) + +// Recurring / translatable messages +const ( + MSG_INFO_FORMATTING_ERROR_RESULTS = "Formatting error results (`%s` format)..." + MSG_INFO_SCHEMA_ERRORS_DETECTED = "(%d) schema errors detected." + MSG_INFO_TOO_MANY_ERRORS = "Too many errors. Showing (%v/%v) errors." + MSG_ERROR_FORMATTING_ERROR = "formatting error: %s" + MSG_WARN_INVALID_FORMAT = "invalid format. error results not supported for `%s` format; defaulting to `%s` format..." +) + +type ValidationResultFormatter struct { + Results []ValidationResultFormat +} + +// JsonContext is a linked-list of JSON key strings +type ValidationResultFormat struct { + resultMap *orderedmap.OrderedMap + valuesMap *orderedmap.OrderedMap + ResultError gojsonschema.ResultError + Context *gojsonschema.JsonContext `json:"context"` // jsonErrorMap["context"] = resultError.Context() +} + +func NewValidationErrResult(resultError gojsonschema.ResultError) (validationErrResult *ValidationResultFormat) { + // Prepare values that are optionally output as JSON + validationErrResult = &ValidationResultFormat{ + ResultError: resultError, + } + // Prepare for JSON output by adding all required fields to our ordered map + validationErrResult.resultMap = orderedmap.New() + validationErrResult.resultMap.Set(ERROR_DETAIL_KEY_DATA_TYPE, resultError.Type()) + validationErrResult.resultMap.Set(ERROR_DETAIL_KEY_FIELD, resultError.Field()) + if context := resultError.Context(); context != nil { + validationErrResult.resultMap.Set(ERROR_DETAIL_KEY_CONTEXT, resultError.Context().String()) + } + validationErrResult.resultMap.Set(ERROR_DETAIL_KEY_VALUE_DESCRIPTION, resultError.Description()) + + return +} + +func (validationErrResult *ValidationResultFormat) MarshalJSON() (marshalled []byte, err error) { + return validationErrResult.resultMap.MarshalJSON() +} + +func (result *ValidationResultFormat) Format(flags utils.ValidateCommandFlags) { + // Conditionally, add optional values as requested (via flags) + if flags.ShowErrorValue { + result.resultMap.Set(ERROR_DETAIL_KEY_VALUE, result.ResultError.Value()) + } +} + +func (result *ValidationResultFormat) FormatItemsMustBeUniqueError(flags utils.ValidateCommandFlags) { + + // For this error type, we want to reduce the information show to the end user. + // Originally, the entire array with duplicate items was show for EVERY occurrence; + // attempt to only show the failing item itself once (and only once) + // TODO: deduplication (planned) will also help shrink large error output results + // Conditionally, add optional values as requested (via flags) + if flags.ShowErrorValue { + details := result.ResultError.Details() + valueType, typeFound := details[ERROR_DETAIL_KEY_DATA_TYPE] + // verify the claimed type is an array + if typeFound && valueType == ERROR_DETAIL_KEY_VALUE_TYPE_ARRAY { + index, indexFound := details[ERROR_DETAIL_ARRAY_ITEM_INDEX_I] + // if a claimed duplicate index is provided (we use the first "i" index not the 2nd "j" one) + if indexFound { + value := result.ResultError.Value() + array, arrayValid := value.([]interface{}) + i, indexValid := index.(int) + // verify the claimed item index is within range + if arrayValid && indexValid && i < len(array) { + // Add just the first array item to the value key + result.valuesMap = orderedmap.New() + result.valuesMap.Set(ERROR_DETAIL_KEY_DATA_TYPE, valueType) + result.valuesMap.Set(ERROR_DETAIL_KEY_VALUE_INDEX, i) + result.valuesMap.Set(ERROR_DETAIL_KEY_VALUE_ITEM, array[i]) + result.resultMap.Set(ERROR_DETAIL_KEY_VALUE, result.valuesMap) + } + } + } + } +} + +func FormatSchemaErrors(schemaErrors []gojsonschema.ResultError, flags utils.ValidateCommandFlags, format string) (formattedSchemaErrors string) { + + getLogger().Infof(MSG_INFO_FORMATTING_ERROR_RESULTS, format) + switch format { + case FORMAT_JSON: + formattedSchemaErrors = FormatSchemaErrorsJson(schemaErrors, utils.GlobalFlags.ValidateFlags) + case FORMAT_TEXT: + formattedSchemaErrors = FormatSchemaErrorsText(schemaErrors, utils.GlobalFlags.ValidateFlags) + default: + getLogger().Warningf(MSG_WARN_INVALID_FORMAT, format, FORMAT_TEXT) + formattedSchemaErrors = FormatSchemaErrorsText(schemaErrors, utils.GlobalFlags.ValidateFlags) + } + return +} + +// Custom formatting based upon possible JSON schema error types +// the custom formatting handlers SHOULD adjust the fields/keys and their values within the `resultMap` +// for the respective errorResult being operated on. +func formatSchemaErrorTypes(resultError gojsonschema.ResultError, flags utils.ValidateCommandFlags) (formattedResult string) { + + validationErrorResult := NewValidationErrResult(resultError) + + // The cases below represent the complete set of typed errors possible. + // Most are commented out as placeholder for future custom format methods. + switch errorType := resultError.(type) { + // case *gojsonschema.AdditionalPropertyNotAllowedError: + // case *gojsonschema.ArrayContainsError: + // case *gojsonschema.ArrayMaxItemsError: + // case *gojsonschema.ArrayMaxPropertiesError: + // case *gojsonschema.ArrayMinItemsError: + // case *gojsonschema.ArrayMinPropertiesError: + // case *gojsonschema.ArrayNoAdditionalItemsError: + // case *gojsonschema.ConditionElseError: + // case *gojsonschema.ConditionThenError: + // case *gojsonschema.ConstError: + // case *gojsonschema.DoesNotMatchFormatError: + // case *gojsonschema.DoesNotMatchPatternError: + // case *gojsonschema.EnumError: + // case *gojsonschema.FalseError: + // case *gojsonschema.InternalError: + // case *gojsonschema.InvalidPropertyNameError: + // case *gojsonschema.InvalidPropertyPatternError: + // case *gojsonschema.InvalidTypeError: + case *gojsonschema.ItemsMustBeUniqueError: + validationErrorResult.FormatItemsMustBeUniqueError(flags) + // case *gojsonschema.MissingDependencyError: + // case *gojsonschema.MultipleOfError: + // case *gojsonschema.NumberAllOfError: + // case *gojsonschema.NumberAnyOfError: + // case *gojsonschema.NumberGTEError: + // case *gojsonschema.NumberGTError: + // case *gojsonschema.NumberLTEError: + // case *gojsonschema.NumberLTError: + // case *gojsonschema.NumberNotError: + // case *gojsonschema.NumberOneOfError: + // case *gojsonschema.RequiredError: + // case *gojsonschema.StringLengthGTEError: + // case *gojsonschema.StringLengthLTEError: + default: + getLogger().Debugf("default formatting: ResultError Type: [%v]", errorType) + validationErrorResult.Format(flags) + } + + return validationErrorResult.formatResultMap(flags) +} + +func (result *ValidationResultFormat) formatResultMap(flags utils.ValidateCommandFlags) string { + // format information on the failing "value" (details) with proper JSON indenting + var formattedResult string + var errFormatting error + if flags.ColorizeErrorOutput { + formattedResult, errFormatting = log.FormatIndentedInterfaceAsColorizedJson( + result.resultMap, + len(ERROR_DETAIL_JSON_DEFAULT_INDENT), + ERROR_DETAIL_JSON_NEWLINE_INDENT, + ) + } else { + formattedResult, errFormatting = log.FormatIndentedInterfaceAsJson( + result.resultMap, + ERROR_DETAIL_JSON_DEFAULT_PREFIX, + ERROR_DETAIL_JSON_DEFAULT_INDENT, + ) + } + if errFormatting != nil { + return getLogger().Errorf(MSG_ERROR_FORMATTING_ERROR, errFormatting.Error()).Error() + } + + return formattedResult +} + +func FormatSchemaErrorsJson(errs []gojsonschema.ResultError, flags utils.ValidateCommandFlags) string { + var sb strings.Builder + + lenErrs := len(errs) + if lenErrs > 0 { + getLogger().Infof(MSG_INFO_SCHEMA_ERRORS_DETECTED, lenErrs) + errLimit := flags.MaxNumErrors + + // If we have more errors than the (default or user set) limit; notify user + if lenErrs > errLimit { + // notify users more errors exist + getLogger().Infof(MSG_INFO_TOO_MANY_ERRORS, errLimit, len(errs)) + } + + // begin/open JSON array + sb.WriteString(JSON_ARRAY_START) + + for i, resultError := range errs { + // short-circuit if too many errors (i.e., using the error limit flag value) + if i == errLimit { + break + } + + // add to the result errors + schemaErrorText := formatSchemaErrorTypes(resultError, flags) + // NOTE: we must add the prefix (indent) ourselves + // see issue: https://github.com/golang/go/issues/49261 + sb.WriteString(ERROR_DETAIL_JSON_DEFAULT_PREFIX) + sb.WriteString(schemaErrorText) + + if i < (lenErrs-1) && i < (errLimit-1) { + sb.WriteString(JSON_ARRAY_ITEM_SEP) + } + } + + // end/close JSON array + sb.WriteString(JSON_ARRAY_END) + } + + return sb.String() +} + +func FormatSchemaErrorsText(errs []gojsonschema.ResultError, flags utils.ValidateCommandFlags) string { + var sb strings.Builder + var lineOutput string + lenErrs := len(errs) + if lenErrs > 0 { + getLogger().Infof(MSG_INFO_SCHEMA_ERRORS_DETECTED, lenErrs) + errLimit := utils.GlobalFlags.ValidateFlags.MaxNumErrors + var errorIndex string + + // If we have more errors than the (default or user set) limit; notify user + if lenErrs > errLimit { + // notify users more errors exist + getLogger().Infof(MSG_INFO_TOO_MANY_ERRORS, errLimit, len(errs)) + } + + for i, resultError := range errs { + + // short-circuit if too many errors (i.e., using the error limit flag value) + if i == errLimit { + break + } + + // append the numbered schema error + errorIndex = strconv.Itoa(i + 1) + + // emit formatted error result + formattedResult := formatSchemaErrorTypes(resultError, utils.GlobalFlags.ValidateFlags) + // NOTE: we must add the prefix (indent) ourselves + // see issue: https://github.com/golang/go/issues/49261 + lineOutput = fmt.Sprintf("\n%v. %s", errorIndex, formattedResult) + sb.WriteString(lineOutput) + } + } + return sb.String() +} diff --git a/cmd/validate_spdx_examples_test.go b/cmd/validate_spdx_examples_test.go index ef7b0872..47c5727d 100644 --- a/cmd/validate_spdx_examples_test.go +++ b/cmd/validate_spdx_examples_test.go @@ -32,29 +32,29 @@ const ( // SPDX - Examples func TestValidateSpdx22Example1(t *testing.T) { - innerValidateError(t, TEST_SPDX_2_2_EXAMPLE_1, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_SPDX_2_2_EXAMPLE_1, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } func TestValidateSPDX22Example2Bin(t *testing.T) { - innerValidateError(t, TEST_SPDX_2_2_EXAMPLE_2_BIN, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_SPDX_2_2_EXAMPLE_2_BIN, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } func TestValidateSPDX22Example2Src(t *testing.T) { - innerValidateError(t, TEST_SPDX_2_2_EXAMPLE_2_SRC, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_SPDX_2_2_EXAMPLE_2_SRC, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } func TestValidateSPDX22Example5Bin(t *testing.T) { - innerValidateError(t, TEST_SPDX_2_2_EXAMPLE_5_BIN, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_SPDX_2_2_EXAMPLE_5_BIN, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } func TestValidateSPDX22Example5Src(t *testing.T) { - innerValidateError(t, TEST_SPDX_2_2_EXAMPLE_5_SRC, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_SPDX_2_2_EXAMPLE_5_SRC, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } func TestValidateSPDX22Example6Lib(t *testing.T) { - innerValidateError(t, TEST_SPDX_2_2_EXAMPLE_6_LIB, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_SPDX_2_2_EXAMPLE_6_LIB, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } func TestValidateSPDX22Example6Src(t *testing.T) { - innerValidateError(t, TEST_SPDX_2_2_EXAMPLE_6_SRC, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_SPDX_2_2_EXAMPLE_6_SRC, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } diff --git a/cmd/validate_spdx_test.go b/cmd/validate_spdx_test.go index ac279013..077d3263 100644 --- a/cmd/validate_spdx_test.go +++ b/cmd/validate_spdx_test.go @@ -41,7 +41,7 @@ const ( // TODO: Need an SPDX 2.2.1 variant // TODO: Need an SPDX 2.2 "custom" variant func TestValidateSpdx22MinRequiredBasic(t *testing.T) { - innerValidateError(t, TEST_SPDX_2_2_MIN_REQUIRED, SCHEMA_VARIANT_NONE, nil) + innerValidateError(t, TEST_SPDX_2_2_MIN_REQUIRED, SCHEMA_VARIANT_NONE, FORMAT_TEXT, nil) } // ----------------------------------------------------------- diff --git a/cmd/validate_test.go b/cmd/validate_test.go index 8e1f4b69..b981cf16 100644 --- a/cmd/validate_test.go +++ b/cmd/validate_test.go @@ -18,6 +18,8 @@ package cmd import ( + "bufio" + "bytes" "encoding/json" "fmt" "io/fs" @@ -44,19 +46,32 @@ const ( TEST_CDX_1_4_MATURITY_EXAMPLE_1_BASE = "test/cyclonedx/cdx-1-4-mature-example-1.json" ) +const ( + TEST_CDX_1_4_VALIDATE_ERR_COMPONENTS_UNIQUE = "test/validation/cdx-1-4-validate-err-components-unique-items-1.json" + TEST_CDX_1_4_VALIDATE_ERR_FORMAT_IRI_REFERENCE = "test/validation/cdx-1-4-validate-err-components-format-iri-reference.json" +) + // Tests basic validation and expected errors -func innerValidateError(t *testing.T, filename string, variant string, expectedError error) (document *schema.Sbom, schemaErrors []gojsonschema.ResultError, actualError error) { +func innerValidateError(t *testing.T, filename string, variant string, format string, expectedError error) (document *schema.Sbom, schemaErrors []gojsonschema.ResultError, actualError error) { getLogger().Enter() defer getLogger().Exit() // Copy the test filename to the command line flags where the code looks for it - utils.GlobalFlags.InputFile = filename + utils.GlobalFlags.PersistentFlags.InputFile = filename + // Set the err result format + utils.GlobalFlags.PersistentFlags.OutputFormat = format // Set the schema variant where the command line flag would - utils.GlobalFlags.Variant = variant + utils.GlobalFlags.ValidateFlags.SchemaVariant = variant // Invoke the actual validate function var isValid bool - isValid, document, schemaErrors, actualError = Validate() + + // TODO: support additional tests on output buffer (e.g., format==valid JSON) + isValid, document, schemaErrors, _, actualError = innerValidateErrorBuffered( + t, + utils.GlobalFlags.PersistentFlags, + utils.GlobalFlags.ValidateFlags, + ) getLogger().Tracef("document: `%s`, isValid=`%t`, actualError=`%T`", document.GetFilename(), isValid, actualError) @@ -88,12 +103,37 @@ func innerValidateError(t *testing.T, filename string, variant string, expectedE return } +func innerValidateErrorBuffered(t *testing.T, persistentFlags utils.PersistentCommandFlags, validationFlags utils.ValidateCommandFlags) (isValid bool, document *schema.Sbom, schemaErrors []gojsonschema.ResultError, outputBuffer bytes.Buffer, err error) { + // Declare an output outputBuffer/outputWriter to use used during tests + var outputWriter = bufio.NewWriter(&outputBuffer) + // ensure all data is written to buffer before further validation + defer outputWriter.Flush() + + // Invoke the actual command (API) + isValid, document, schemaErrors, err = Validate(outputWriter, persistentFlags, utils.GlobalFlags.ValidateFlags) + getLogger().Tracef("document: `%s`, isValid=`%t`, err=`%T`", document.GetFilename(), isValid, err) + + return +} + +func innerValidateForcedSchema(t *testing.T, filename string, forcedSchema string, format string, expectedError error) (document *schema.Sbom, schemaErrors []gojsonschema.ResultError, actualError error) { + getLogger().Enter() + defer getLogger().Exit() + + utils.GlobalFlags.ValidateFlags.ForcedJsonSchemaFile = forcedSchema + innerValidateError(t, filename, SCHEMA_VARIANT_NONE, format, expectedError) + // !!!Important!!! Must reset this global flag + utils.GlobalFlags.ValidateFlags.ForcedJsonSchemaFile = "" + + return +} + // Tests *ErrorInvalidSBOM error types and any (lower-level) errors they "wrapped" func innerValidateInvalidSBOMInnerError(t *testing.T, filename string, variant string, innerError error) (document *schema.Sbom, schemaErrors []gojsonschema.ResultError, actualError error) { getLogger().Enter() defer getLogger().Exit() - document, schemaErrors, actualError = innerValidateError(t, filename, variant, &InvalidSBOMError{}) + document, schemaErrors, actualError = innerValidateError(t, filename, variant, FORMAT_TEXT, &InvalidSBOMError{}) invalidSBOMError, ok := actualError.(*InvalidSBOMError) @@ -109,7 +149,7 @@ func innerValidateInvalidSBOMInnerError(t *testing.T, filename string, variant s // It also tests that the syntax error occurred at the expected line number and character offset func innerValidateSyntaxError(t *testing.T, filename string, variant string, expectedLineNum int, expectedCharNum int) (document *schema.Sbom, actualError error) { - document, _, actualError = innerValidateError(t, filename, variant, &json.SyntaxError{}) + document, _, actualError = innerValidateError(t, filename, variant, FORMAT_TEXT, &json.SyntaxError{}) syntaxError, ok := actualError.(*json.SyntaxError) if !ok { @@ -136,6 +176,7 @@ func innerTestSchemaErrorAndErrorResults(t *testing.T, document, results, _ := innerValidateError(t, filename, variant, + FORMAT_TEXT, &InvalidSBOMError{}) getLogger().Debugf("filename: `%s`, results:\n%v", document.GetFilename(), results) @@ -150,6 +191,43 @@ func innerTestSchemaErrorAndErrorResults(t *testing.T, } } +func schemaErrorExists(schemaErrors []gojsonschema.ResultError, + expectedType string, expectedField string, expectedValue interface{}) bool { + + for i, resultError := range schemaErrors { + // Some descriptions include very long enums; in those cases, + // truncate to a reasonable length using an intelligent separator + getLogger().Tracef(">> %d. Type: [%s], Field: [%s], Value: [%v]", + i+1, + resultError.Type(), + resultError.Field(), + resultError.Value()) + + actualType := resultError.Type() + actualField := resultError.Field() + actualValue := resultError.Value() + + if actualType == expectedType { + // we have matched on the type (key) field, continue to match other fields + if expectedField != "" && + actualField != expectedField { + getLogger().Tracef("expected Field: `%s`; actual Field: `%s`", expectedField, actualField) + return false + } + + if expectedValue != "" && + actualValue != expectedValue { + getLogger().Tracef("expected Value: `%s`; actual Value: `%s`", actualValue, expectedValue) + return false + } + return true + } else { + getLogger().Debugf("Skipping result[%d]: expected Type: `%s`; actual Type: `%s`", i, expectedType, actualType) + } + } + return false +} + // ----------------------------------------------------------- // Command tests // ----------------------------------------------------------- @@ -160,6 +238,7 @@ func TestValidateInvalidInputFileLoad(t *testing.T) { innerValidateError(t, TEST_INPUT_FILE_NON_EXISTENT, SCHEMA_VARIANT_NONE, + FORMAT_TEXT, &fs.PathError{}) } @@ -193,34 +272,88 @@ func TestValidateSyntaxErrorCdx13Test2(t *testing.T) { // Force validation against a "custom" schema with compatible format (CDX) and version (1.3) func TestValidateForceCustomSchemaCdx13(t *testing.T) { - utils.GlobalFlags.ValidateFlags.ForcedJsonSchemaFile = TEST_SCHEMA_CDX_1_3_CUSTOM - innerValidateError(t, + innerValidateForcedSchema(t, TEST_CDX_1_3_MATURITY_EXAMPLE_1_BASE, - SCHEMA_VARIANT_NONE, + TEST_SCHEMA_CDX_1_3_CUSTOM, + FORMAT_TEXT, nil) } // Force validation against a "custom" schema with compatible format (CDX) and version (1.4) func TestValidateForceCustomSchemaCdx14(t *testing.T) { - utils.GlobalFlags.ValidateFlags.ForcedJsonSchemaFile = TEST_SCHEMA_CDX_1_4_CUSTOM - innerValidateError(t, + innerValidateForcedSchema(t, TEST_CDX_1_4_MATURITY_EXAMPLE_1_BASE, - SCHEMA_VARIANT_NONE, + TEST_SCHEMA_CDX_1_4_CUSTOM, + FORMAT_TEXT, nil) } // Force validation using schema with compatible format, but older version than the SBOM version func TestValidateForceCustomSchemaCdxSchemaOlder(t *testing.T) { - utils.GlobalFlags.ValidateFlags.ForcedJsonSchemaFile = TEST_SCHEMA_CDX_1_3_CUSTOM - innerValidateError(t, + innerValidateForcedSchema(t, TEST_CDX_1_4_MATURITY_EXAMPLE_1_BASE, - SCHEMA_VARIANT_NONE, + TEST_SCHEMA_CDX_1_3_CUSTOM, + FORMAT_TEXT, nil) } -// func TestValidateSyntaxErrorCdx14AdHoc2(t *testing.T) { -// innerValidateError(t, -// "sample_co_May16.json", -// SCHEMA_VARIANT_NONE, -// nil) -// } +// TODO: add additional checks on the buffered output +func TestValidateCdx14ErrorResultsUniqueComponentsText(t *testing.T) { + innerValidateError(t, + TEST_CDX_1_4_VALIDATE_ERR_COMPONENTS_UNIQUE, + SCHEMA_VARIANT_NONE, + FORMAT_TEXT, + &InvalidSBOMError{}) +} + +// TODO: add additional checks on the buffered output +func TestValidateCdx14ErrorResultsFormatIriReferencesText(t *testing.T) { + innerValidateError(t, + TEST_CDX_1_4_VALIDATE_ERR_FORMAT_IRI_REFERENCE, + SCHEMA_VARIANT_NONE, + FORMAT_TEXT, + &InvalidSBOMError{}) +} + +// TODO: add additional checks on the buffered output +func TestValidateCdx14ErrorResultsUniqueComponentsJson(t *testing.T) { + var EXPECTED_ERROR_NUM = 2 + var EXPECTED_ERROR_CONTEXT = "(root).components" + _, schemaErrors, _ := innerValidateError(t, + TEST_CDX_1_4_VALIDATE_ERR_COMPONENTS_UNIQUE, + SCHEMA_VARIANT_NONE, + FORMAT_JSON, + &InvalidSBOMError{}) + //output, _ := log.FormatIndentedInterfaceAsJson(schemaErrors, " ", " ") + + if len(schemaErrors) != EXPECTED_ERROR_NUM { + t.Errorf("invalid schema error count: expected `%v`; actual: `%v`)", EXPECTED_ERROR_NUM, len(schemaErrors)) + //fmt.Printf("schemaErrors:\n %s", output) + } + + if schemaErrors[0].Context().String() != EXPECTED_ERROR_CONTEXT { + t.Errorf("invalid schema error context: expected `%v`; actual: `%v`)", EXPECTED_ERROR_CONTEXT, schemaErrors[0].Context().String()) + } +} + +// TODO: add additional checks on the buffered output +func TestValidateCdx14ErrorResultsFormatIriReferencesJson(t *testing.T) { + var EXPECTED_ERROR_NUM = 1 + var EXPECTED_ERROR_CONTEXT = "(root).components.2.externalReferences.0.url" + _, schemaErrors, _ := innerValidateError(t, + TEST_CDX_1_4_VALIDATE_ERR_FORMAT_IRI_REFERENCE, + SCHEMA_VARIANT_NONE, + FORMAT_JSON, + &InvalidSBOMError{}) + + //output, _ := log.FormatIndentedInterfaceAsJson(schemaErrors, " ", " ") + + if len(schemaErrors) != EXPECTED_ERROR_NUM { + t.Errorf("invalid schema error count: expected `%v`; actual: `%v`)", EXPECTED_ERROR_NUM, len(schemaErrors)) + //fmt.Printf("schemaErrors:\n %s", output) + } + + if schemaErrors[0].Context().String() != EXPECTED_ERROR_CONTEXT { + t.Errorf("invalid schema error context: expected `%v`; actual: `%v`)", EXPECTED_ERROR_CONTEXT, schemaErrors[0].Context().String()) + } +} diff --git a/cmd/vulnerability.go b/cmd/vulnerability.go index c881d80f..b0310063 100644 --- a/cmd/vulnerability.go +++ b/cmd/vulnerability.go @@ -136,7 +136,7 @@ func NewCommandVulnerability() *cobra.Command { command.Use = CMD_USAGE_VULNERABILITY_LIST command.Short = "Report on vulnerabilities found in the BOM input file" command.Long = "Report on vulnerabilities found in the BOM input file" - command.Flags().StringVarP(&utils.GlobalFlags.OutputFormat, FLAG_FILE_OUTPUT_FORMAT, "", FORMAT_TEXT, + command.Flags().StringVarP(&utils.GlobalFlags.PersistentFlags.OutputFormat, FLAG_FILE_OUTPUT_FORMAT, "", FORMAT_TEXT, FLAG_VULNERABILITY_OUTPUT_FORMAT_HELP+VULNERABILITY_LIST_SUPPORTED_FORMATS) command.Flags().StringP(FLAG_REPORT_WHERE, "", "", FLAG_REPORT_WHERE_HELP) command.Flags().BoolVarP( @@ -176,7 +176,8 @@ func vulnerabilityCmdImpl(cmd *cobra.Command, args []string) (err error) { defer getLogger().Exit() // Create output writer - outputFile, writer, err := createOutputFile(utils.GlobalFlags.OutputFile) + outputFilename := utils.GlobalFlags.PersistentFlags.OutputFile + outputFile, writer, err := createOutputFile(outputFilename) getLogger().Tracef("outputFile: `%v`; writer: `%v`", outputFile, writer) // use function closure to assure consistent error output based upon error type @@ -184,7 +185,7 @@ func vulnerabilityCmdImpl(cmd *cobra.Command, args []string) (err error) { // always close the output file if outputFile != nil { err = outputFile.Close() - getLogger().Infof("Closed output file: `%s`", utils.GlobalFlags.OutputFile) + getLogger().Infof("Closed output file: `%s`", outputFilename) } }() @@ -195,7 +196,7 @@ func vulnerabilityCmdImpl(cmd *cobra.Command, args []string) (err error) { return } - err = ListVulnerabilities(writer, utils.GlobalFlags.OutputFormat, whereFilters, utils.GlobalFlags.VulnerabilityFlags) + err = ListVulnerabilities(writer, utils.GlobalFlags.PersistentFlags.OutputFormat, whereFilters, utils.GlobalFlags.VulnerabilityFlags) return } diff --git a/cmd/vulnerability_test.go b/cmd/vulnerability_test.go index 78eba553..3bbadf7f 100644 --- a/cmd/vulnerability_test.go +++ b/cmd/vulnerability_test.go @@ -88,7 +88,7 @@ func innerTestVulnList(t *testing.T, testInfo *VulnTestInfo, flags utils.Vulnera } // The command looks for the input filename in global flags struct - utils.GlobalFlags.InputFile = testInfo.InputFile + utils.GlobalFlags.PersistentFlags.InputFile = testInfo.InputFile // invoke list command with a byte buffer outputBuffer, err = innerBufferedTestVulnList(t, testInfo, whereFilters, flags) diff --git a/go.mod b/go.mod index 2dd180af..a9b40ebc 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.20 require ( github.com/fatih/color v1.15.0 github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f + github.com/iancoleman/orderedmap v0.2.0 github.com/jwangsadinata/go-multimap v0.0.0-20190620162914-c29f3d7f33b6 github.com/mrutkows/go-jsondiff v0.2.0 github.com/spf13/cobra v1.7.0 @@ -18,6 +19,7 @@ require ( github.com/kr/text v0.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sergi/go-diff v1.3.1 // indirect github.com/spf13/pflag v1.0.5 // indirect diff --git a/go.sum b/go.sum index 75cbc322..900f26f8 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWo github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f h1:7LYC+Yfkj3CTRcShK0KOL/w6iTiKyqqBA9a41Wnggw8= github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI= +github.com/iancoleman/orderedmap v0.2.0 h1:sq1N/TFpYH++aViPcaKjys3bDClUEU7s5B+z6jq8pNA= +github.com/iancoleman/orderedmap v0.2.0/go.mod h1:N0Wam8K1arqPXNWjMo21EXnBPOPp36vB07FNRdD2geA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jwangsadinata/go-multimap v0.0.0-20190620162914-c29f3d7f33b6 h1:OzCtZaD1uI5Fc1C+4oNAp7kZ4ibh5OIgxI29moH/IbE= @@ -25,6 +27,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mrutkows/go-jsondiff v0.2.0 h1:T+05e1QSe7qB6vhkVtv3NImD3ni+Jdxpj69iMsptAqY= github.com/mrutkows/go-jsondiff v0.2.0/go.mod h1:TuasE0Ldrf4r1Gp0uIatS9SnPZPYybjmTGjB7WXKWl4= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= diff --git a/log/format.go b/log/format.go index d632fcde..446aea13 100644 --- a/log/format.go +++ b/log/format.go @@ -161,7 +161,27 @@ func FormatInterfaceAsColorizedJson(data interface{}) (string, error) { return string(bytes), nil } -// TODO: make indent length configurable +func FormatIndentedInterfaceAsColorizedJson(data interface{}, indent int, newline string) (string, error) { + formatter := prettyjson.NewFormatter() + formatter.Indent = indent + formatter.Newline = newline + bytes, err := formatter.Marshal(data) + if err != nil { + return "", err + } + return string(bytes), nil +} + +func FormatIndentedInterfaceAsJson(data interface{}, prefix string, indent string) (string, error) { + bytes, err := json.MarshalIndent(data, prefix, indent) + if err != nil { + return "", err + } + return string(bytes), nil +} + +// NOTE: hardcodes indent length +// TODO: make configurable as a formatter field/value func FormatInterfaceAsJson(data interface{}) (string, error) { bytes, err := json.MarshalIndent(data, "", " ") if err != nil { diff --git a/schema/schema_formats.go b/schema/schema_formats.go index bf25ecb4..c2355a39 100644 --- a/schema/schema_formats.go +++ b/schema/schema_formats.go @@ -401,7 +401,7 @@ func (sbom *Sbom) UnmarshalCDXSbom() (err error) { return } -func (sbom *Sbom) FindFormatAndSchema() (err error) { +func (sbom *Sbom) FindFormatAndSchema(sbomFilename string) (err error) { getLogger().Enter() defer getLogger().Exit() @@ -416,13 +416,13 @@ func (sbom *Sbom) FindFormatAndSchema() (err error) { // Copy format info into Sbom context sbom.FormatInfo = format - err = sbom.findSchemaVersionWithVariant(format, version, utils.GlobalFlags.Variant) + err = sbom.findSchemaVersionWithVariant(format, version, utils.GlobalFlags.ValidateFlags.SchemaVariant) return } } // if we reach here, we did not find the format in our configuration (list) - err = NewUnknownFormatError(utils.GlobalFlags.InputFile) + err = NewUnknownFormatError(sbomFilename) return } @@ -444,9 +444,9 @@ func (sbom *Sbom) findSchemaVersionWithVariant(format FormatSchema, version stri // If a variant is also requested, see if we can find one for that criteria // Note: the default value for "variant" is an empty string - if utils.GlobalFlags.Variant == schema.Variant { + if utils.GlobalFlags.ValidateFlags.SchemaVariant == schema.Variant { getLogger().Tracef("Match found for requested schema variant: `%s`", - FormatSchemaVariant(utils.GlobalFlags.Variant)) + FormatSchemaVariant(utils.GlobalFlags.ValidateFlags.SchemaVariant)) sbom.SchemaInfo = schema return } diff --git a/test/validation/cdx-1-4-validate-err-components-format-iri-reference.json b/test/validation/cdx-1-4-validate-err-components-format-iri-reference.json new file mode 100644 index 00000000..d832d92d --- /dev/null +++ b/test/validation/cdx-1-4-validate-err-components-format-iri-reference.json @@ -0,0 +1,159 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "serialNumber": "urn:uuid:1a2b3c4d-1234-abcd-9876-a3b4c5d6e7f9", + "metadata": { + "component": { + "type": "application", + "bom-ref": "pkg:oci/example.com/product/application@10.0.4.0", + "purl": "pkg:oci/example.com/product/application@10.0.4.0", + "name": "Example Application v10.0.4", + "description": "Example's Do-It-All application", + "version": "10.0.4.0", + "licenses": [ + { + "license": { + "id": "Apache-2.0" + } + } + ], + "externalReferences": [ + { + "type": "website", + "url": "https://example.com/application" + } + ], + "properties": [ + { + "name": "urn:example.com:identifier:product", + "value": "71C22290D7DB11EBAA175CFD3E629A2A" + }, + { + "name": "urn:example.com:identifier:distribution", + "value": "5737-I23" + } + ], + "hashes": [ + { + "alg": "SHA-1", + "content": "1111aaaa2222cccc3333dddd4444eeee5555ffff" + } + ], + "supplier": { + "name": "Example Co. Distribution Dept.", + "url": [ + "https://example.com" + ], + "contact": [ + { + "email": "distribution@example.com" + } + ] + }, + "publisher": "Example Inc. EMEA" + }, + "licenses": [ + { + "license": { + "id": "Apache-1.0" + } + }, + { + "license": { + "id": "Apache-2.0" + } + }, + { + "license": { + "id": "GPL-3.0-only" + } + }, + { + "license": { + "id": "MIT" + } + } + ], + "tools": [ + { + "vendor": "SecurityTools.com", + "name": "Security Scanner v1.0", + "version": "1.0.0-beta.1+0099", + "hashes": [ + { + "alg": "SHA-1", + "content": "96b2709e57c9c4e09a6fd66a8fd979844f69f08a" + } + ] + }, + { + "vendor": "SBOM.com", + "name": "SBOM Generator v2.1", + "version": "2.1.12", + "hashes": [ + { + "alg": "SHA-1", + "content": "96b2709e57c9c4e09a6fd66a8fd979844f69f08a" + } + ] + } + ] + }, + "components": [ + { + "type": "operating-system", + "name": "debian", + "version": "10", + "description": "Debian GNU/Linux 10 (buster)", + "externalReferences": [ + { + "url": "https://www.debian.org/", + "type": "website" + }, + { + "url": "https://www.debian.org/support", + "type": "other", + "comment": "support" + } + ] + }, + { + "type": "library", + "bom-ref": "pkg:empty", + "name": "Empty", + "externalReferences": [ + { + "url": "", + "type": "build-meta" + } + ] + }, + { + "type": "library", + "bom-ref": "pkg:npm/asn1.js@5.4.1?package-id=e24b6ffc41aa39e5", + "author": "Fedor Indutny", + "name": "asn1.js", + "version": "5.4.1", + "description": "ASN.1 encoder and decoder", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "purl": "pkg:npm/asn1.js@5.4.1", + "externalReferences": [ + { + "url": "git@github.com:indutny/asn1.js", + "type": "distribution" + }, + { + "url": "https://github.com/indutny/asn1.js", + "type": "website" + } + ] + } + ] +} \ No newline at end of file diff --git a/test/validation/cdx-1-4-validate-err-components-unique-items-1.json b/test/validation/cdx-1-4-validate-err-components-unique-items-1.json new file mode 100644 index 00000000..5c360221 --- /dev/null +++ b/test/validation/cdx-1-4-validate-err-components-unique-items-1.json @@ -0,0 +1,188 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "serialNumber": "urn:uuid:1a2b3c4d-1234-abcd-9876-a3b4c5d6e7f9", + "metadata": { + "component": { + "type": "application", + "bom-ref": "pkg:oci/example.com/product/application@10.0.4.0", + "purl": "pkg:oci/example.com/product/application@10.0.4.0", + "name": "Example Application v10.0.4", + "description": "Example's Do-It-All application", + "version": "10.0.4.0", + "licenses": [ + { + "license": { + "id": "Apache-2.0" + } + } + ], + "externalReferences": [ + { + "type": "website", + "url": "https://example.com/application" + } + ], + "properties": [ + { + "name": "urn:example.com:identifier:product", + "value": "71C22290D7DB11EBAA175CFD3E629A2A" + }, + { + "name": "urn:example.com:identifier:distribution", + "value": "5737-I23" + } + ], + "hashes": [ + { + "alg": "SHA-1", + "content": "1111aaaa2222cccc3333dddd4444eeee5555ffff" + } + ], + "supplier": { + "name": "Example Co. Distribution Dept.", + "url": [ + "https://example.com" + ], + "contact": [ + { + "email": "distribution@example.com" + } + ] + }, + "publisher": "Example Inc. EMEA" + }, + "licenses": [ + { + "license": { + "id": "Apache-1.0" + } + }, + { + "license": { + "id": "Apache-2.0" + } + }, + { + "license": { + "id": "GPL-3.0-only" + } + }, + { + "license": { + "id": "MIT" + } + } + ], + "tools": [ + { + "vendor": "SecurityTools.com", + "name": "Security Scanner v1.0", + "version": "1.0.0-beta.1+0099", + "hashes": [ + { + "alg": "SHA-1", + "content": "96b2709e57c9c4e09a6fd66a8fd979844f69f08a" + } + ] + }, + { + "vendor": "SBOM.com", + "name": "SBOM Generator v2.1", + "version": "2.1.12", + "hashes": [ + { + "alg": "SHA-1", + "content": "96b2709e57c9c4e09a6fd66a8fd979844f69f08a" + } + ] + } + ] + }, + "components": [ + { + "type": "library", + "bom-ref": "pkg:npm/sample@2.0.0", + "purl": "pkg:npm/sample@2.0.0", + "name": "sample", + "version": "2.0.0", + "description": "Node.js Sampler package", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ] + }, + { + "type": "library", + "bom-ref": "pkg:npm/body-parser@1.19.0", + "purl": "pkg:npm/body-parser@1.19.0", + "name": "body-parser", + "version": "1.19.0", + "description": "Node.js body parsing middleware", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "hashes": [ + { + "alg": "SHA-1", + "content": "96b2709e57c9c4e09a6fd66a8fd979844f69f08a" + } + ] + }, + { + "type": "library", + "bom-ref": "pkg:npm/body-parser@1.19.0", + "purl": "pkg:npm/body-parser@1.19.0", + "name": "body-parser", + "version": "1.19.0", + "description": "Node.js body parsing middleware", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "hashes": [ + { + "alg": "SHA-1", + "content": "96b2709e57c9c4e09a6fd66a8fd979844f69f08a" + } + ] + }, + { + "type": "library", + "name": "body-parser", + "version": "1.20.0" + }, + { + "type": "library", + "bom-ref": "pkg:npm/body-parser@1.19.0", + "purl": "pkg:npm/body-parser@1.19.0", + "name": "body-parser", + "version": "1.19.0", + "description": "Node.js body parsing middleware", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "hashes": [ + { + "alg": "SHA-1", + "content": "96b2709e57c9c4e09a6fd66a8fd979844f69f08a" + } + ] + } + ] +} \ No newline at end of file diff --git a/utils/flags.go b/utils/flags.go index 1a03b436..db5db6bf 100644 --- a/utils/flags.go +++ b/utils/flags.go @@ -40,12 +40,7 @@ type CommandFlags struct { ConfigLicensePolicyFile string // persistent flags (common to all commands) - Quiet bool // suppresses all non-essential (informational) output from a command. Overrides any other log-level commands. - Trace bool // trace logging - Debug bool // debug logging - InputFile string - OutputFile string // Note: not used by `validate` command, which emits a warning if supplied - OutputSbomFormat string + PersistentFlags PersistentCommandFlags // Diff flags DiffFlags DiffCommandFlags @@ -57,21 +52,23 @@ type CommandFlags struct { VulnerabilityFlags VulnerabilityCommandFlags // Validate (local) flags - Variant string - ValidateProperties bool ValidateFlags ValidateCommandFlags - CustomValidation bool CustomValidationOptions CustomValidationFlags - // Summary formats (i.e., only valid for summary) - // NOTE: "query" and "list" (raw) commands always returns JSON by default - OutputFormat string // e.g., TXT (default), CSV, markdown (normalized to lowercase) - // Log indent LogOutputIndentCallstack bool } // NOTE: These flags are shared by both the list and policy subcommands +type PersistentCommandFlags struct { + Quiet bool // suppresses all non-essential (informational) output from a command. Overrides any other log-level commands. + Trace bool // trace logging + Debug bool // debug logging + InputFile string + OutputFile string // TODO: TODO: Note: not used by `validate` command, which emits a warning if supplied + OutputFormat string // e.g., "txt", "csv"", "md" (markdown) (normalized to lowercase) +} + type DiffCommandFlags struct { Colorize bool RevisedFile string @@ -83,10 +80,15 @@ type LicenseCommandFlags struct { } type ValidateCommandFlags struct { - ForcedJsonSchemaFile string + SchemaVariant string + ForcedJsonSchemaFile string + // Uses custom validation flags if "true"; defaults to config. "custom.json" + CustomValidation bool + // error result processing MaxNumErrors int MaxErrorDescriptionLength int - ColorizeJsonErrors bool + ColorizeErrorOutput bool + ShowErrorValue bool } type VulnerabilityCommandFlags struct {