Repository: oasis-open/cti-stix-validator

Issue:
when spec_version is 2.0 on bundle, do not warn
On a stix bundle that has a spec_version of 2.0, the latest version of the validator returns a warning:
    [!] Warning: bundle--59afb48d-0f9c-434d-be6a-69515424b0c3: {101} Custom property 'spec_version' should have a type that starts with 'x_' followed by a source unique identifier (like a domain name with dots replaced by hyphen), a hyphen and then the name.

It should NOT return a warning, as that is properly formatted STIX 2.0.

This was found when checking against https://github.com/pan-unit42/playbook_viewer/blob/master/playbook_json/darkhydrus.json

Discussion:

Patch:
diff --git a/stix2validator/errors.py b/stix2validator/errors.py
index c3e70b7..a0f3f42 100644
--- a/stix2validator/errors.py
+++ b/stix2validator/errors.py
@@ -222,9 +222,9 @@ def pretty_error(error, verbose=False):
                          "not an allowed value", msg)
         elif ('target_ref' in error.schema_path or
               'source_ref' in error.schema_path):
-                msg = "Relationships cannot link bundles, marking definitions"\
-                      ", sightings, or other relationships. This field must "\
-                      "contain the id of an SDO."
+            msg = "Relationships cannot link bundles, marking definitions"\
+                    ", sightings, or other relationships. This field must "\
+                    "contain the id of an SDO."
         elif 'sighting_of_ref' in error.schema_path:
             msg = "'sighting_of_ref' must refer to a STIX Domain Object or "\
                   "Custom Object"
diff --git a/stix2validator/util.py b/stix2validator/util.py
index df790d9..725a3d5 100644
--- a/stix2validator/util.py
+++ b/stix2validator/util.py
@@ -324,7 +324,7 @@ class ValidationOptions(object):
             contained within the same bundle
 
     """
-    def __init__(self, cmd_args=None, version=DEFAULT_VER, verbose=False, silent=False,
+    def __init__(self, cmd_args=None, version=None, verbose=False, silent=False,
                  files=None, recursive=False, schema_dir=None,
                  disabled="", enabled="", strict=False,
                  strict_types=False, strict_properties=False, no_cache=False,
@@ -348,7 +348,10 @@ class ValidationOptions(object):
             self.enforce_refs = cmd_args.enforce_refs
         else:
             # input options
-            self.version = version
+            if version is not None:
+                self.version = version
+            else:
+                self.version = None
             self.files = files
             self.recursive = recursive
             self.schema_dir = schema_dir
@@ -391,6 +394,25 @@ class ValidationOptions(object):
                             for x in self.enabled]
 
 
+def set_check_codes(options):
+    if options.version == '2.0':
+        options.check_codes = CHECK_CODES20
+    else:
+        options.check_codes = CHECK_CODES21
+
+    if options.disabled:
+        if isinstance(options.disabled, str):
+            options.disabled = options.disabled.split(',')
+        options.disabled = [options.check_codes[x] if x in options.check_codes else x
+                            for x in options.disabled]
+    if options.enabled:
+        if isinstance(options.enabled, str):
+            options.enabled = options.enabled.split(',')
+        options.enabled = [options.check_codes[x] if x in options.check_codes else x
+                           for x in options.enabled]
+    return options
+
+
 def has_cyber_observable_data(instance):
     """Return True only if the given instance is an observed-data object
     containing STIX Cyber Observable objects.
@@ -402,6 +424,31 @@ def has_cyber_observable_data(instance):
     return False
 
 
+def check_spec(instance, options):
+    """ Checks to see if there are differences in command-line option
+    provided spec_version and the spec_version found with bundles
+    and/or objects.
+    """
+    warnings = []
+    if options.version:
+        try:
+            if instance['type'] == 'bundle' and 'spec_version' in instance:
+                if instance['spec_version'] != options.version:
+                    warnings.append(instance['id'] + ": spec_version mismatch with command-"
+                                    "line option. Defaulting to spec_version " + options.version)
+            if instance['type'] == 'bundle' and 'objects' in instance:
+                for obj in instance['objects']:
+                    if 'spec_version' in obj:
+                        if obj['spec_version'] != options.version:
+                            warnings.append(obj['id'] + ": spec_version mismatch with command-"
+                                            "line option. Defaulting to spec_version "
+                                            + options.version)
+        except Exception:
+            pass
+
+    return warnings
+
+
 def cyber_observable_check(original_function):
     """Decorator for functions that require cyber observable data.
     """
diff --git a/stix2validator/v21/musts.py b/stix2validator/v21/musts.py
index 41dd81a..007e69b 100644
--- a/stix2validator/v21/musts.py
+++ b/stix2validator/v21/musts.py
@@ -350,8 +350,8 @@ def language(instance):
     """Ensure the 'lang' property of SDOs is a valid RFC 5646 language code.
     """
     if ('lang' in instance and instance['lang'] not in enums.LANG_CODES):
-                yield JSONError("'%s' is not a valid RFC 5646 language code."
-                                % instance['lang'], instance['id'])
+        yield JSONError("'%s' is not a valid RFC 5646 language code."
+                        % instance['lang'], instance['id'])
 
 
 @cyber_observable_check
diff --git a/stix2validator/validator.py b/stix2validator/validator.py
index e13fae2..4259724 100644
--- a/stix2validator/validator.py
+++ b/stix2validator/validator.py
@@ -15,8 +15,8 @@ from six import iteritems, string_types, text_type
 from . import output
 from .errors import (NoJSONFileFoundError, SchemaError, SchemaInvalidError,
                      ValidationError, pretty_error)
-from .util import (DEFAULT_VER, ValidationOptions, clear_requests_cache,
-                   init_requests_cache)
+from .util import (DEFAULT_VER, ValidationOptions, check_spec,
+                   clear_requests_cache, init_requests_cache, set_check_codes)
 from .v20 import musts as musts20
 from .v20 import shoulds as shoulds20
 from .v21 import musts as musts21
@@ -645,6 +645,13 @@ def _schema_validate(sdo, options):
     else:
         error_prefix = ''
 
+    if options.version is None and 'spec_version' in sdo:
+        options.version = sdo['spec_version']
+    if options.version is None:
+        options.version = "2.1"
+
+    options = set_check_codes(options)
+
     # Get validator for built-in schema
     base_sdo_errors = _get_error_generator(sdo['type'], sdo, version=options.version)
     if base_sdo_errors:
@@ -718,6 +725,8 @@ def validate_instance(instance, options=None):
 
     # Schema validation
     if instance['type'] == 'bundle' and 'objects' in instance:
+        if options.version is None and 'spec_version' in instance:
+            options.version = instance['spec_version']
         # Validate each object in a bundle separately
         for sdo in instance['objects']:
             if 'type' not in sdo:
@@ -726,6 +735,8 @@ def validate_instance(instance, options=None):
     else:
         error_gens += _schema_validate(instance, options)
 
+    spec_warnings = check_spec(instance, options)
+
     # Custom validation
     must_checks = _get_musts(options)
     should_checks = _get_shoulds(options)
@@ -741,6 +752,7 @@ def validate_instance(instance, options=None):
         else:
             chained_errors = errors
             warnings = [pretty_error(x, options.verbose) for x in warnings]
+            warnings.extend(spec_warnings)
     except schema_exceptions.RefResolutionError:
         raise SchemaInvalidError('Invalid JSON schema: a JSON reference '
                                  'failed to resolve')
@@ -756,11 +768,11 @@ def validate_instance(instance, options=None):
         for error in gen:
             msg = prefix + pretty_error(error, options.verbose)
             error_list.append(SchemaError(msg))
-
+    if options.strict:
+        error_list.extend(spec_warnings)
     if error_list:
         valid = False
     else:
         valid = True
-
     return ObjectValidationResults(is_valid=valid, object_id=instance.get('id', ''),
                                    errors=error_list, warnings=warnings)


Test patch:
diff --git a/stix2validator/test/v21/indicator_tests.py b/stix2validator/test/v21/indicator_tests.py
index a4d3950..549506f 100644
--- a/stix2validator/test/v21/indicator_tests.py
+++ b/stix2validator/test/v21/indicator_tests.py
@@ -213,8 +213,7 @@ class IndicatorTestCases(ValidatorTest):
         indicator = copy.deepcopy(self.valid_indicator)
         indicator['name'] = 'Foobar'
         objects = [indicator, ADDTNL_INVALID_SCHEMA]
-
-        options = ValidationOptions(schema_dir=self.custom_schemas)
+        options = ValidationOptions(schema_dir=self.custom_schemas, version="2.1")
         results = validate_parsed_json(objects, options)
         assert results[0].is_valid
         assert not results[1].is_valid
diff --git a/stix2validator/test/v21/spec_version_tests.py b/stix2validator/test/v21/spec_version_tests.py
new file mode 100644
index 0000000..bfba3d5
--- /dev/null
+++ b/stix2validator/test/v21/spec_version_tests.py
@@ -0,0 +1,165 @@
+import copy
+import json
+
+from . import ValidatorTest
+from ... import ValidationOptions, validate_parsed_json
+
+VALID_BUNDLE = u"""
+{
+  "type": "bundle",
+  "id": "bundle--44af6c39-c09b-49c5-9de2-394224b04982",
+  "objects": [
+    {
+      "type": "identity",
+      "id": "identity--8ae20dde-83d4-4218-88fd-41ef0dabf9d1",
+      "created": "2016-08-22T14:09:00.123Z",
+      "modified": "2016-08-22T14:09:00.123Z",
+      "name": "mitre.org",
+      "identity_class": "organization"
+    }
+  ]
+}
+"""
+
+VERSION_NUMBERS = ["2.0", "2.1"]
+
+
+class SpecVersionTestCases(ValidatorTest):
+    valid_data = json.loads(VALID_BUNDLE)
+    internal_options = ValidationOptions()
+
+    def test_empty(self):
+        observed_data = copy.deepcopy(self.valid_data)
+        results = validate_parsed_json(observed_data, self.internal_options)
+        self.assertFalse(results.is_valid)
+
+    def test_cmd(self):
+        for version in VERSION_NUMBERS:
+            observed_data = copy.deepcopy(self.valid_data)
+            self.internal_options.version = version
+            results = validate_parsed_json(
+                observed_data,
+                self.internal_options)
+            if version == "2.0":
+                self.assertTrue(results.is_valid)
+            elif version == "2.1":
+                self.assertFalse(results.is_valid)
+
+    def test_bundle(self):
+        for version in VERSION_NUMBERS:
+            observed_data = copy.deepcopy(self.valid_data)
+            observed_data['spec_version'] = version
+            results = validate_parsed_json(observed_data)
+            if version == "2.0":
+                self.assertTrue(results.is_valid)
+            elif version == "2.1":
+                self.assertFalse(results.is_valid)
+                self.assertTrue(len(results.errors) == 1)
+                self.assertTrue(len(results.warnings) == 1)
+
+    def test_object(self):
+        observed_data = copy.deepcopy(self.valid_data)
+        for version in VERSION_NUMBERS:
+            observed_data['objects'][0]['spec_version'] = version
+            results = validate_parsed_json(observed_data)
+            self.assertTrue(results.is_valid)
+
+    def test_bundle_and_object(self):
+        observed_data = copy.deepcopy(self.valid_data)
+        for bundle_version in VERSION_NUMBERS:
+            for object_version in VERSION_NUMBERS:
+                observed_data['spec_version'] = bundle_version
+                observed_data['objects'][0]['spec_version'] = object_version
+                results = validate_parsed_json(observed_data)
+                self.assertTrue(results.is_valid)
+
+    def test_cmd_and_bundle(self):
+        observed_data = copy.deepcopy(self.valid_data)
+        for bundle_version in VERSION_NUMBERS:
+            for cmd_version in VERSION_NUMBERS:
+                observed_data['spec_version'] = bundle_version
+                self.internal_options.version = cmd_version
+                results = validate_parsed_json(
+                    observed_data,
+                    self.internal_options)
+
+                if cmd_version == "2.0" and bundle_version == "2.0":
+                    self.assertTrue(results.is_valid)
+
+                elif cmd_version == "2.0" and bundle_version == "2.1":
+                    self.assertTrue(results.is_valid)
+
+                elif cmd_version == "2.1" and bundle_version == "2.0":
+                    self.assertFalse(results.is_valid)
+                    self.assertTrue(len(results.errors) == 1)
+                    self.assertTrue(len(results.warnings) == 2)
+
+                elif cmd_version == "2.1" and bundle_version == "2.1":
+                    self.assertFalse(results.is_valid)
+                    self.assertTrue(len(results.warnings) == 1)
+                    self.assertTrue(len(results.errors) == 1)
+
+    def test_cmd_and_obj(self):
+        observed_data = copy.deepcopy(self.valid_data)
+        for cmd_version in VERSION_NUMBERS:
+            for obj_version in VERSION_NUMBERS:
+                observed_data['objects'][0]['spec_version'] = obj_version
+                self.internal_options.version = cmd_version
+                results = validate_parsed_json(
+                    observed_data,
+                    self.internal_options)
+                if cmd_version == "2.0" and obj_version == "2.0":
+                    self.assertTrue(results.is_valid)
+                    self.assertTrue(len(results.warnings) == 1)
+
+                elif cmd_version == "2.0" and obj_version == "2.1":
+                    self.assertTrue(results.is_valid)
+                    self.assertTrue(len(results.warnings) == 2)
+
+                elif cmd_version == "2.1" and obj_version == "2.0":
+                    self.assertTrue(len(results.warnings) == 1)
+                    self.assertTrue(results.is_valid)
+
+                elif cmd_version == "2.1" and obj_version == "2.1":
+                    self.assertTrue(results.is_valid)
+                    self.assertTrue(len(results.warnings) == 0)
+
+    def test_all(self):
+        observed_data = copy.deepcopy(self.valid_data)
+        for cmd_version in VERSION_NUMBERS:
+            for bundle_version in VERSION_NUMBERS:
+                for obj_version in VERSION_NUMBERS:
+                    observed_data['spec_version'] = bundle_version
+                    observed_data['objects'][-1]['spec_version'] = obj_version
+                    self.internal_options.version = cmd_version
+                    results = validate_parsed_json(
+                        observed_data,
+                        self.internal_options)
+
+                    if cmd_version == "2.0" and bundle_version == "2.0" and obj_version == "2.0":
+                        self.assertTrue(results.is_valid)
+                        self.assertTrue(len(results.warnings) == 1)
+
+                    if cmd_version == "2.1" and bundle_version == "2.0" and obj_version == "2.0":
+                        self.assertTrue(results.is_valid)
+                        self.assertTrue(len(results.warnings) == 3)
+
+                    if cmd_version == "2.0" and bundle_version == "2.1" and obj_version == "2.0":
+                        self.assertTrue(results.is_valid)
+                        self.assertTrue(len(results.warnings) == 2)
+
+                    if cmd_version == "2.0" and bundle_version == "2.0" and obj_version == "2.1":
+                        self.assertTrue(results.is_valid)
+                        self.assertTrue(len(results.warnings) == 2)
+
+                    if cmd_version == "2.1" and bundle_version == "2.1" and obj_version == "2.0":
+                        self.assertTrue(results.is_valid)
+                        self.assertTrue(len(results.warnings) == 2)
+
+                    if cmd_version == "2.0" and bundle_version == "2.1" and obj_version == "2.1":
+                        self.assertTrue(results.is_valid)
+                        self.assertTrue(len(results.warnings) == 3)
+
+                    if cmd_version == "2.1" and bundle_version == "2.1" and obj_version == "2.1":
+                        self.assertTrue(results.is_valid)
+                        self.assertTrue(len(results.warnings) == 1)