Repository: oasis-open/cti-stix-validator
Pull request #3 (2016-11-08 17:04:10)

Bug fixes and improvements
- Improve error messages
  - omit u' prefix on strings
  - use an object id or schema path to identify what caused the error
- Update checks to RC3 (partial)
  - relationship types common to all objects
  - some enum values
- Make the validator easier to use 
  - installation notes
  - better passing of arguments when used as an imported library, such as in the stix elevator


Patch:
diff --git a/README.rst b/README.rst
index f529880..3a3f32b 100644
--- a/README.rst
+++ b/README.rst
@@ -31,6 +31,26 @@ The only exception to this is the mandatory requirement that an object's 'type'
 
 The validator also color-codes its output to make it easier to tell at a glance whether validation passed.
 
+.. _install:
+
+`Installation`
+,,,,,,,
+
+The easiest way to install the STIX validator is with pip:
+
+::
+
+  $ pip install git+https://github.com/oasis-open/cti-stix-validator.git
+
+Note that if you clone or download the repository and install it that way instead, you will need to set up the submodules before you install it:
+
+::
+
+  $ git clone https://github.com/oasis-open/cti-stix-validator.git
+  $ cd cti-stix-validator/
+  $ git submodule update --init --recursive
+  $ python setup.py install
+
 .. _usage:
 
 `Usage`
diff --git a/stix2validator/__init__.py b/stix2validator/__init__.py
index 37cd190..bf4dbc7 100644
--- a/stix2validator/__init__.py
+++ b/stix2validator/__init__.py
@@ -438,7 +438,8 @@ def schema_validate(instance, options):
         chained_errors = chain(some_errors, more_errors)
         errors = sorted(chained_errors, key=lambda e: e.path)
     except schema_exceptions.RefResolutionError:
-        raise SchemaInvalidError('Invalid JSON schema: a JSON reference failed to resolve')
+        raise SchemaInvalidError('Invalid JSON schema: a JSON reference '
+                                 'failed to resolve')
 
     if len(errors) == 0:
         return ValidationResults(True)
@@ -446,14 +447,22 @@ def schema_validate(instance, options):
     # Prepare the list of errors
     error_list = []
     for error in errors:
-        if error.path:
-            error_path = error.path.popleft() + ": "
-        else:
-            error_path = ""
+        error_loc = ''
+        try:
+            error_loc = error.instance['id'] + ': '
+        except (TypeError, KeyError) as e:
+            if error.path:
+                while len(error.path) > 0:
+                    path_elem = error.path.popleft()
+                    if type(path_elem) is not int:
+                        error_loc += path_elem
+                    elif len(error.path) > 0:
+                        error_loc += '[' + str(path_elem) + ']/'
+                error_loc += ': '
 
         if options.verbose:
-            error_list.append(SchemaError(error_path + str(error)))
+            error_list.append(SchemaError(error_loc + str(error)))
         else:
-            error_list.append(SchemaError(error_path + error.message))
+            error_list.append(SchemaError(error_loc + error.message))
 
     return ValidationResults(False, error_list)
diff --git a/stix2validator/enums.py b/stix2validator/enums.py
index a3816dc..8113341 100644
--- a/stix2validator/enums.py
+++ b/stix2validator/enums.py
@@ -78,7 +78,7 @@ MALWARE_LABEL_OV = [
     "ransomware",
     "remote-access-trojan",
     "resource-exploitation",
-    "rogue-antivirus",
+    "rogue-security-software",
     "rootkit",
     "screen-capture",
     "spyware",
@@ -87,7 +87,7 @@ MALWARE_LABEL_OV = [
     "worm"
 ]
 PATTERN_LANG_OV = [
-    "cybox",
+    "stix",
     "openioc",
     "snort",
     "yara"
@@ -381,7 +381,7 @@ PROPERTIES = {
         'first_observed',
         'last_observed',
         'number_observed',
-        'cybox'
+        'objects'
     ],
     "report": [
         'type',
@@ -529,6 +529,13 @@ PROPERTIES = {
 }
 
 
+# List of relationship types common to all object types
+COMMON_RELATIONSHIPS = [
+    'derived-from',
+    'duplicate-of',
+    'related-to'
+]
+
 # Mapping of official STIX objects to their official relationships
 RELATIONSHIPS = {
     'attack-pattern': {
diff --git a/stix2validator/output.py b/stix2validator/output.py
index 4660bc5..72b51c4 100644
--- a/stix2validator/output.py
+++ b/stix2validator/output.py
@@ -1,6 +1,6 @@
 import sys
 from colorama import init, Fore, Style
-from six import iteritems
+from six import iteritems, text_type
 from . import codes
 
 init(autoreset=True)
@@ -96,7 +96,8 @@ def print_schema_results(results, level=0):
         return
 
     for error in results.errors:
-        print_level(_RED + "[!] %s", level + 1, error)
+        err = text_type(error).replace(' u\'', ' \'')
+        print_level(_RED + "[!] %s", level + 1, err)
 
 
 def print_results(results):
diff --git a/stix2validator/scripts/stix2_validator.py b/stix2validator/scripts/stix2_validator.py
index 9548ee7..9909947 100644
--- a/stix2validator/scripts/stix2_validator.py
+++ b/stix2validator/scripts/stix2_validator.py
@@ -81,10 +81,14 @@ class NewlinesHelpFormatter(RawDescriptionHelpFormatter):
         return txt
 
 
-def _get_arg_parser():
+def _get_arg_parser(is_script=True):
     """Initializes and returns an argparse.ArgumentParser instance for this
     application.
 
+    Args:
+        is_script: Whether the arguments are intended for use in a stand-alone
+            script or imported into another tool.
+
     Returns:
         Instance of ``argparse.ArgumentParser``
 
@@ -96,13 +100,14 @@ def _get_arg_parser():
     )
 
     # Input options
-    parser.add_argument(
-        "files",
-        metavar="FILES",
-        nargs="+",
-        help="A whitespace separated list of STIX files or directories of "
-             "STIX files to validate."
-    )
+    if is_script:
+        parser.add_argument(
+            "files",
+            metavar="FILES",
+            nargs="+",
+            help="A whitespace separated list of STIX files or directories of "
+                 "STIX files to validate."
+        )
     parser.add_argument(
         "-r",
         "--recursive",
diff --git a/stix2validator/validators.py b/stix2validator/validators.py
index 5462c3f..4bc187a 100644
--- a/stix2validator/validators.py
+++ b/stix2validator/validators.py
@@ -84,12 +84,12 @@ class ValidationOptions(object):
 class JSONError(schema_exceptions.ValidationError):
     """Wrapper for errors thrown by iter_errors() in the jsonschema module.
     """
-    def __init__(self, msg=None, instance_type=None, check_code=None):
+    def __init__(self, msg=None, instance_id=None, check_code=None):
         if check_code is not None:
             # Get code number code from name
             code = list(CHECK_CODES.keys())[list(CHECK_CODES.values()).index(check_code)]
             msg = '{%s} %s' % (code, msg)
-        super(JSONError, self).__init__(msg, path=deque([instance_type]))
+        super(JSONError, self).__init__(msg, path=deque([instance_id, 0]))
 
 
 # Checks for MUST Requirements
@@ -100,7 +100,7 @@ def modified_created(instance):
     if 'modified' in instance and 'created' in instance and \
             instance['modified'] < instance['created']:
         return JSONError("'modified' (%s) must be later or equal to 'created' (%s)"
-            % (instance['modified'], instance['created']), instance['type'])
+            % (instance['modified'], instance['created']), instance['id'])
 
 
 def version(instance):
@@ -111,11 +111,11 @@ def version(instance):
         if instance['version'] == 1 and instance['modified'] != instance['created']:
             return JSONError("'version' is 1, but 'created' (%s) is not "
                 "equal to 'modified' (%s)" 
-                % (instance['created'], instance['modified']), instance['type'])
+                % (instance['created'], instance['modified']), instance['id'])
         elif instance['version'] > 1 and instance['modified'] <= instance['created']:
             return JSONError("'version' is greater than 1, but 'modified'"
                 " (%s) is not greater than 'created' (%s)" 
-                % (instance['modified'], instance['created']), instance['type'])
+                % (instance['modified'], instance['created']), instance['id'])
 
 
 def id_type(instance):
@@ -124,7 +124,7 @@ def id_type(instance):
     """
     t = instance['type']
     if not re.search("%s\-\-" % t, instance['id']):
-        return JSONError("'id' must be prefixed by %s--." % t, t)
+        return JSONError("'id' must be prefixed by %s--." % t, instance['id'])
 
 
 def timestamp_precision(instance):
@@ -138,7 +138,8 @@ def timestamp_precision(instance):
 
         ts_field = precision_matches.group(1)
         if ts_field not in instance:
-            return JSONError("There is no corresponding %s field" % ts_field, prop_name)
+            return JSONError("There is no corresponding %s field for %s"
+                             % (ts_field, prop_name), instance['id'])
 
         pattern = ""
         if instance[prop_name] == 'year':
@@ -153,8 +154,9 @@ def timestamp_precision(instance):
             pattern = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:00(\\.0+)?Z$"
 
         if not re.match(pattern, instance[ts_field]):
-            return JSONError("Timestamp is not the correct format for '%s' "
-                             "precision." % instance[prop_name], ts_field)
+            return JSONError("%s timestamp is not the correct format for '%s' "
+                             "precision." % (ts_field, instance[prop_name]),
+                             instance['id'])
 
 
 # Checks for SHOULD Requirements
@@ -163,11 +165,11 @@ def custom_object_prefix_strict(instance):
     """Ensure custom objects follow strict naming style conventions.
     """
     if instance['type'] not in enums.TYPES and not re.match("^x\-.+\-.+$", instance['type']):
-        return JSONError("Custom objects should have a type that starts with "
-                         "'x-' followed by a source unique identifier (like "
-                         "a domain name with dots replaced by dashes), a dash "
-                         "and then the name.", instance['type'],
-                         'custom-object-prefix')
+        return JSONError("Custom object type '%s' should start with 'x-' "
+                         "followed by a source unique identifier (like a"
+                         "domain name with dots replaced by dashes), a dash "
+                         "and then the name." % instance['type'],
+                         instance['id'], 'custom-object-prefix')
 
 
 def custom_object_prefix_lax(instance):
@@ -175,10 +177,10 @@ def custom_object_prefix_lax(instance):
     for forward-compatibility.
     """
     if instance['type'] not in enums.TYPES and not re.match("^x\-.+$", instance['type']):
-        return JSONError("Custom objects should have a type that starts with "
-                         "'x-' in order to be compatible with future versions"
-                         " of the STIX 2 specification.", instance['type'],
-                         'custom-object-prefix')
+        return JSONError("Custom object type '%s' should start with 'x-' in "
+                         "order to be compatible with future versions of the "
+                         "STIX 2 specification." % instance['type'],
+                         instance['id'], 'custom-object-prefix')
 
 
 def custom_property_prefix_strict(instance):
@@ -191,11 +193,12 @@ def custom_property_prefix_strict(instance):
                 prop_name not in enums.PROPERTIES[instance['type']] and
                 not re.match("^x_.+_.+$", prop_name)):
 
-            return JSONError("Custom properties should have a type that starts"
-                             " with 'x_' followed by a source unique "
+            return JSONError("Custom property '%s' should have a type that "
+                             "starts with 'x_' followed by a source unique "
                              "identifier (like a domain name with dots "
-                             "replaced by dashes), a dash and then the name.",
-                             prop_name, 'custom-property-prefix')
+                             "replaced by dashes), a dash and then the name." %
+                             prop_name, instance['id'],
+                             'custom-property-prefix')
 
 
 def custom_property_prefix_lax(instance):
@@ -209,10 +212,11 @@ def custom_property_prefix_lax(instance):
                 prop_name not in enums.PROPERTIES[instance['type']] and
                 not re.match("^x_.+$", prop_name)):
 
-            return JSONError("Custom properties should have a type that starts"
-                             " with 'x_' in order to be compatible with future"
-                             " versions of the STIX 2 specification.",
-                             prop_name, 'custom-property-prefix')
+            return JSONError("Custom property '%s' should have a type that "
+                             "starts with 'x_' in order to be compatible with "
+                             "future versions of the STIX 2 specification." %
+                             prop_name, instance['id'],
+                             'custom-property-prefix')
 
 
 def open_vocab_values(instance):
@@ -234,10 +238,10 @@ def open_vocab_values(instance):
 
             for v in values:
                 if not v.islower() or '_' in v or ' ' in v:
-                    return JSONError("Open vocabulary value (%s) should be all"
+                    return JSONError("Open vocabulary value '%s' should be all"
                                      " lowercase and use dashes instead of"
                                      " spaces or underscores as word"
-                                     " separators." % v, instance['type'],
+                                     " separators." % v, instance['id'],
                                      'open-vocab-format')
 
 
@@ -254,17 +258,17 @@ def kill_chain_phase_names(instance):
 
             chain_name = phase['kill_chain_name']
             if not chain_name.islower() or '_' in chain_name or ' ' in chain_name:
-                return JSONError("kill_chain_name (%s) should be all lowercase"
+                return JSONError("kill_chain_name '%s' should be all lowercase"
                                  " and use dashes instead of spaces or "
                                  "underscores as word separators." % chain_name,
-                                 instance['type'], 'kill-chain-names')
+                                 instance['id'], 'kill-chain-names')
 
             phase_name = phase['phase_name']
             if not phase_name.islower() or '_' in phase_name or ' ' in phase_name:
-                return JSONError("phase_name (%s) should be all lowercase and "
+                return JSONError("phase_name '%s' should be all lowercase and "
                                  "use dashes instead of spaces or underscores "
                                  "as word separators." % phase_name,
-                                 instance['type'], 'kill-chain-names')
+                                 instance['id'], 'kill-chain-names')
 
 
 def check_vocab(instance, vocab, code):
@@ -290,8 +294,8 @@ def check_vocab(instance, vocab, code):
                 if not is_in:
                     vocab_name = vocab.replace('_', '-').lower()
                     return JSONError("%s contains a value not in the %s-ov "
-                                     "vocabulary." % (prop, vocab_name), prop,
-                                     code)
+                                     "vocabulary." % (prop, vocab_name),
+                                     instance['id'], code)
 
 
 def vocab_attack_motivation(instance):
@@ -362,9 +366,9 @@ def vocab_marking_definition(instance):
             'definition_type' in instance and not
             instance['definition_type'] in enums.MARKING_DEFINITION_TYPES):
 
-        return JSONError("Marking definition's `definition_type` should be one"
-                         " of %s." % enums.MARKING_DEFINITION_TYPES,
-                         instance['type'], 'marking-definition-type')
+        return JSONError("Marking definition `definition_type` should be one "
+                         "of: %s." % ', '.join(enums.MARKING_DEFINITION_TYPES),
+                         instance['id'], 'marking-definition-type')
 
 
 def relationships_strict(instance):
@@ -385,20 +389,22 @@ def relationships_strict(instance):
     r_source = re.search("(.+)\-\-", instance['source_ref']).group(1)
     r_target = re.search("(.+)\-\-", instance['target_ref']).group(1)
 
+    if r_type in enums.COMMON_RELATIONSHIPS:
+        return
+
     if r_source not in enums.RELATIONSHIPS:
         return JSONError("'%s' is not a valid relationship source object."
-                         % r_source, "relationship_type",
-                         'relationship-types')
+                         % r_source, instance['id'], 'relationship-types')
 
     if r_type not in enums.RELATIONSHIPS[r_source]:
         return JSONError("'%s' is not a valid relationship type for '%s' "
-                         "objects." % (r_type, r_source), "relationship_type",
+                         "objects." % (r_type, r_source), instance['id'],
                          'relationship-types')
 
     if r_target not in enums.RELATIONSHIPS[r_source][r_type]:
         return JSONError("'%s' is not a valid relationship target object for "
                          "'%s' objects with the '%s' relationship."
-                         % (r_target, r_source, r_type), "relationship_type",
+                         % (r_target, r_source, r_type), instance['id'],
                          'relationship-types')
 
 
@@ -407,8 +413,8 @@ def types_strict(instance):
     from the specification.
     """
     if instance['type'] not in enums.TYPES:
-        return JSONError("Object type should be one of those detailed in the"
-                         " specification.", instance['type'])
+        return JSONError("Object type '%s' is not one of those detailed in the"
+                         " specification." % instance['type'], instance['id'])
 
 
 # Mapping of check code numbers to names


Test patch:
diff --git a/stix2validator/test/indicator_tests.py b/stix2validator/test/indicator_tests.py
index 6628203..897cfa0 100644
--- a/stix2validator/test/indicator_tests.py
+++ b/stix2validator/test/indicator_tests.py
@@ -16,7 +16,7 @@ VALID_INDICATOR = """
     "name": "Poison Ivy Malware",
     "description": "This file is part of Poison Ivy",
     "pattern": "file-object.hashes.md5 = '3773a88f65a5e780c8dff9cdc3a056f3'",
-    "pattern_lang": "cybox",
+    "pattern_lang": "stix",
     "pattern_lang_version": "1.0",
     "valid_from": "2016-04-06T20:03:48Z",
     "valid_from_precision": "full"
diff --git a/stix2validator/test/relationship_tests.py b/stix2validator/test/relationship_tests.py
index 94334bb..adb5450 100644
--- a/stix2validator/test/relationship_tests.py
+++ b/stix2validator/test/relationship_tests.py
@@ -81,5 +81,14 @@ class RelationshipTestCases(ValidatorTest):
         results = validate_string(relationship, self.options)
         self.assertTrue(results.is_valid)
 
+    def test_relationship_types_common(self):
+        relationship = copy.deepcopy(self.valid_relationship)
+        relationship['source_ref'] = "malware--31b940d4-6f7f-459a-80ea-9c1f17b5891b"
+        relationship['target_ref'] = "campaign--9c1f891b-459a-6f7f-80ea-31b940d417b5"
+        relationship['relationship_type'] = "related-to"
+        relationship = json.dumps(relationship)
+        results = validate_string(relationship, self.options)
+        self.assertTrue(results.is_valid)
+
 if __name__ == "__main__":
     unittest.main()