Repository: oasis-open/cti-stix-validator
Pull request #15 (2017-01-31 12:23:33)

Improve Error Messages and Update to WD01
Error messages should now be a lot more clear and helpful in pinpointing why validation failed.

Depends on oasis-open/cti-stix2-json-schemas#20


Patch:
diff --git a/stix2validator/enums.py b/stix2validator/enums.py
index 616b031..6df1fd0 100644
--- a/stix2validator/enums.py
+++ b/stix2validator/enums.py
@@ -701,6 +701,7 @@ OBSERVABLE_PROPERTIES = {
         'created',
         'cwd',
         'arguments',
+        'command_line',
         'environment_variables',
         'opened_connection_refs',
         'creator_user_ref',
@@ -714,7 +715,7 @@ OBSERVABLE_PROPERTIES = {
         'extensions',
         'name',
         'cpe',
-        'language',
+        'languages',
         'vendor',
         'version'
     ],
@@ -847,7 +848,6 @@ OBSERVABLE_EXTENSION_PROPERTIES = {
         'descriptions',
         'display_name',
         'group_name',
-        'start_command_line',
         'start_type',
         'service_dll_refs',
         'service_type',
@@ -1126,7 +1126,8 @@ OBSERVABLE_DICT_KEY_EXCEPTIONS = [
     'ipfix',
     'options',
     'environment_variables',
-    'key'
+    'key',
+    'startup_info'
 ]
 
 # Reserved properties and objects
@@ -1511,3 +1512,53 @@ LANG_CODES = [
     'wol', 'xal', 'xho', 'yao', 'yap', 'yid', 'yor', 'ypk', 'zap', 'zbl',
     'zen', 'zgh', 'zha', 'chi', 'zho', 'znd', 'zul', 'zun', 'zxx', 'zza'
 ]
+
+HTTP_REQUEST_HEADERS = [
+    "Accept",
+    "Accept-Charset",
+    "Accept-Encoding",
+    "Accept-Language",
+    "Accept-Datetime",
+    "Authorization",
+    "Cache-Control",
+    "Connection",
+    "Cookie",
+    "Content-Length",
+    "Content-MD5",
+    "Content-Type",
+    "Date",
+    "Expect",
+    "Forwarded",
+    "From",
+    "Host",
+    "If-Match",
+    "If-Modified-Since",
+    "If-None-Match",
+    "If-Range",
+    "If-Unmodified-Since",
+    "Max-Forwards",
+    "Origin",
+    "Pragma",
+    "Proxy-Authorization",
+    "Range",
+    "Referer",
+    "TE",
+    "User-Agent",
+    "Upgrade",
+    "Via",
+    "Warning",
+    "X-Requested-With",
+    "DNT",
+    "X-Forwarded-For",
+    "X-Forwarded-Host",
+    "X-Forwarded-Proto",
+    "Front-End-Https",
+    "X-Http-Method-Override",
+    "X-ATT-DeviceId",
+    "X-Wap-Profile",
+    "Proxy-Connection",
+    "X-UIDH",
+    "X-Csrf-Token",
+    "X-Request-ID",
+    "X-Correlation-ID"
+]
diff --git a/stix2validator/errors.py b/stix2validator/errors.py
index de8df5a..f39f533 100644
--- a/stix2validator/errors.py
+++ b/stix2validator/errors.py
@@ -15,7 +15,7 @@ class JSONError(schema_exceptions.ValidationError):
             # 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_id, 0]))
+        super(JSONError, self).__init__(msg, path=deque([instance_id]))
 
 
 class NoJSONFileFoundError(OSError):
@@ -67,28 +67,35 @@ class SchemaError(ValidationError):
         return text_type(self.message)
 
 
+def remove_u(input):
+    """Remove ugly u'' prefixes from input string
+    """
+    return re.sub(r"(^| )(|\[|\(|\{|\[\{)u'", r"\g<1>\g<2>'", input)
+
+
 def pretty_error(error, verbose=False):
     """Return an error message that is easier to read and more useful.
+    May require updating if the schemas change significantly.
     """
     error_loc = ''
-    try:
-        error_loc = error.instance['id'] + ': '
-    except (TypeError, KeyError):
-        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 += '[' + text_type(path_elem) + ']/'
-            error_loc += ': '
+
+    if error.path:
+        while len(error.path) > 0:
+            path_elem = error.path.popleft()
+            if type(path_elem) is not int:
+                if error_loc:
+                    error_loc += '.'
+                error_loc += path_elem
+            # elif len(error.path) > 0:
+            else:
+                error_loc += '[' + text_type(path_elem) + ']'
+        error_loc += ': '
 
     # Get error message and remove ugly u'' prefixes
     if verbose:
-        msg = text_type(error)
+        msg = remove_u(text_type(error))
     else:
-        msg = error.message
-    msg = re.sub(r"(^| )(|\[|\(|\{)u'", r"\g<1>\g<2>'", msg)
+        msg = remove_u(error.message)
 
     # Don't reword error messages from our validators,
     # only the default error messages from the jsonschema library
@@ -116,10 +123,16 @@ def pretty_error(error, verbose=False):
         elif error.schema['title'] == 'url':
             msg = re.sub(r"match '.+'$", 'match the format '
                          'of a URL', msg)
-    # Reword 'is not valid under any of the given schemas' errors
+    elif error.validator == 'pattern' and 'observed_data_refs' in error.schema_path:
+        msg = "'observed_data_refs' must refer to Observed Data Objects"
+    elif error.validator == 'pattern' and 'where_sighted_refs' in error.schema_path:
+        msg = "'where_sighted_refs' must refer to Identity Objects"
+
+    # Reword empty array errors
     elif type(error.instance) is list and len(error.instance) == 0:
         msg = re.sub(r"\[\] is not valid .+$", 'empty arrays are not allowed',
                      msg)
+
     # Reword custom property errors
     elif 'title' in error.schema and error.schema['title'] == 'core':
         if error.validator == 'additionalProperties':
@@ -129,25 +142,99 @@ def pretty_error(error, verbose=False):
         elif error.validator == 'not' and 'anyOf' in error.validator_value:
             msg = re.sub(r".+", "Contains a reserved property ('%s')"
                          % "', '".join(enums.RESERVED_PROPERTIES), msg)
-    # Reword external reference error
+    elif 'title' in error.schema and error.schema['title'] == 'cyber-observable-core':
+        if error.validator == 'additionalProperties':
+            msg = re.sub(r"Additional .+$", 'Custom observable properties must'
+                         ' match the proper format (lowercase ASCII a-z, 0-9, '
+                         'and underscores; 3-250 characters)', msg)
+
+    elif error.validator == 'additionalProperties':
+        if 'extensions' in error.schema_path:
+            msg = re.sub(r"Additional .+$", 'Custom extension keys may only '
+                        'contain alphanumeric characters, dashes, and '
+                        'underscores; 3-256 characters', msg)
+
+    # Reword 'is valid under each of' errors
     elif error.validator == 'oneOf':
-        if 'external_references' in error.schema_path:
-            msg = "If the external reference is a CVE, 'source_name' must be" \
-                  " 'cve' and 'external_id' must be in the CVE format " \
-                  "(CVE-YYYY-NNNN+). If the external reference is a CAPEC, " \
-                  "'source_name' must be 'capec' and 'external_id' must be " \
-                  "in the CAPEC format (CAPEC-N+)."
-    # Reword forbidden enum value errors
+        try:
+            if 'external_references' in error.schema_path:
+                msg = "If the external reference is a CVE, 'source_name' must be" \
+                      " 'cve' and 'external_id' must be in the CVE format " \
+                      "(CVE-YYYY-NNNN+). If the external reference is a CAPEC, " \
+                      "'source_name' must be 'capec' and 'external_id' must be " \
+                      "in the CAPEC format (CAPEC-N+). If the external reference "\
+                      "is neither, it must contain the 'source_name' property and"\
+                      " at least one of the 'external_id', 'url', or "\
+                      "'description' properties."
+            elif 'type' in error.instance and error.instance['type'] == 'email-message':
+                if 'is_multipart' not in error.instance:
+                    msg = "'is_multipart' is a required property"
+                elif error.instance['is_multipart'] is True:
+                    msg = "Since 'is_multipart' is true, 'body_multipart' must "\
+                          "contain valid 'mime-part-type' objects and the 'body' "\
+                          "property must not be present. "
+                elif error.instance['is_multipart'] is False:
+                    msg = "Since 'is_multipart' is false, 'body' must be a string"\
+                          " and the 'body_multipart' property must not be present."
+            elif 'type' in error.instance and error.instance['type'] == 'artifact':
+                if 'payload_bin' in error.instance and 'url' in error.instance:
+                    msg = "'artifact' object must contain either 'payload_bin' "\
+                          "or 'url' but not both"
+                elif 'payload_bin' in error.instance:
+                    msg = "'payload_bin' must be base64 encoded and 'hashes', if "\
+                          "present, must contain a valid dictionary of hashes"
+                elif 'url' in error.instance:
+                    msg = "'url' must be a valid url and 'hashes', which must be "\
+                          "present, must contain a valid hash dictionary"
+                else:
+                    msg = "'artifact' object must contain either 'payload_bin' "\
+                          "or 'url'"
+            elif 'type' in error.instance and error.instance['type'] == 'marking-definition':
+                msg = "'definition' must contain a valid statement, TLP, or "\
+                      "custom marking definition"
+            elif 'type' in error.instance and error.instance['type'] == 'file':
+                if (('is_encrypted' not in error.instance or
+                        error.instance['is_encrypted'] is False) and
+                        ('encryption_algorithm' in error.instance or
+                         'decryption_key' in error.instance)):
+                    msg = "'file' objects may only contain 'encryption_algorithm'"\
+                          " or 'decryption_key' when 'is_encrypted' is true"
+            elif 'type' in error.instance and error.instance['type'] == 'network-traffic':
+                if ('is_active' in error.instance and
+                        error.instance['is_active'] is True and
+                        'end' in error.instance):
+                    msg = "If the 'is_active' property is true, then the "\
+                          "'end' property must not be included."
+            else:
+                raise TypeError
+        except TypeError:
+            msg = msg + ':\n' + remove_u(text_type(error.schema))
+
+    # reword forbidden property or value errors
     elif error.validator == 'not':
         if 'enum' in error.validator_value:
             msg = re.sub(r"\{.+\} is not allowed for '(.+)'$", r"'\g<1>' is "
                          "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"
+
+    # Reword 'is not valid under any of the given schemas' errors
     elif error.validator == 'anyOf':
-        msg = msg + ':\n' + text_type(error.schema)
+        try:
+            if 'type' in error.instance and error.instance['type'] == 'network-traffic':
+                if ('src_ref' not in error.instance and
+                        'dst_ref' not in error.instance):
+                    msg = "'network-traffic' objects must contain at least "\
+                          "one of 'src_ref' or 'dst_ref'"
+            else:
+                raise TypeError
+        except TypeError:
+            msg = msg + ':\n' + remove_u(text_type(error.schema))
 
     return error_loc + msg
diff --git a/stix2validator/musts.py b/stix2validator/musts.py
index d1fbf1b..084be86 100644
--- a/stix2validator/musts.py
+++ b/stix2validator/musts.py
@@ -18,32 +18,6 @@ def modified_created(instance):
                          instance['id'])
 
 
-def sighting_refs(instance):
-    """Ensure that properties of Sighting objects that reference other objects
-    refer to the correct type of object.
-    """
-    if instance['type'] != 'sighting':
-        return
-
-    if 'sighting_of_ref' in instance:
-        ref = instance['sighting_of_ref']
-        if ref.split('--')[0] in enums.NON_SDOS:
-            yield JSONError("`sighting_of_ref` must refer to a STIX Domain "
-                            "Object or Custom Object.", instance['id'])
-
-    if 'observed_data_refs' in instance:
-        for ref in instance['observed_data_refs']:
-            if ref.split('--')[0] != 'observed-data':
-                yield JSONError("`observed_data_refs` must refer to an "
-                                "Observed Data Object.", instance['id'])
-
-    if 'where_sighted_refs' in instance:
-        for ref in instance['where_sighted_refs']:
-            if ref.split('--')[0] != 'identity':
-                yield JSONError("`where_sighted_refs` must refer to an "
-                                "Identity Object.", instance['id'])
-
-
 def object_marking_circular_refs(instance):
     """Ensure that marking definitions do not contain circular references (ie.
     they do not reference themselves in the `object_marking_refs` property).
@@ -119,6 +93,37 @@ def marking_selector_syntax(instance):
                 prev_segmt = segmt
 
 
+def check_observable_refs(refs, obj_prop, enum_prop, embed_obj_prop, enum_vals,
+                          key, instance):
+    if embed_obj_prop != '':
+        embed_obj_prop = "'" + embed_obj_prop + "' "
+
+    if not isinstance(refs, list):
+        refs = [refs]
+    for ref in refs:
+        try:
+            refed_obj = instance['objects'][ref]
+        except KeyError:
+            yield JSONError("%s in observable object '%s' can't "
+                            "resolve %sreference '%s'."
+                            % (obj_prop, key, embed_obj_prop, ref),
+                            instance['id'])
+            continue
+        try:
+            refed_type = refed_obj['type']
+        except KeyError:
+            continue
+        if refed_type not in enum_vals:
+            if len(enum_vals) == 1:
+                valids = "'" + enum_vals[0] + "'"
+            else:
+                valids = "'%s or '%s'" % ("', '".join(enum_vals[:-1]),
+                                          enum_vals[-1])
+            yield JSONError("'%s' in observable object '%s' must "
+                            "refer to an object of type %s."
+                            % (obj_prop, key, valids), instance['id'])
+
+
 @cyber_observable_check
 def observable_object_references(instance):
     """Ensure certain observable object properties reference the correct type
@@ -137,65 +142,39 @@ def observable_object_references(instance):
             enum_prop = enums.OBSERVABLE_PROP_REFS[obj_type][obj_prop]
             if isinstance(enum_prop, list):
                 refs = obj[obj_prop]
-                if not isinstance(refs, list):
-                    refs = [refs]
-                for ref in refs:
-                    try:
-                        refed_obj = instance['objects'][ref]
-                    except KeyError:
-                        yield JSONError("%s in observable object '%s' can't "
-                                        "resolve reference '%s'."
-                                        % (obj_prop, key, ref), instance['id'])
-                        continue
-                    try:
-                        refed_type = refed_obj['type']
-                    except KeyError:
-                        continue
-                    enum_vals = enum_prop
-                    if refed_type not in enum_vals:
-                        if len(enum_vals) == 1:
-                            valids = "'" + enum_vals[0] + "'"
-                        else:
-                            valids = "'%s or '%s'" % ("', '".join(enum_vals[:-1]),
-                                                      enum_vals[-1])
-                        yield JSONError("'%s' in observable object '%s' must "
-                                        "refer to an object of type %s."
-                                        % (obj_prop, key, valids), instance['id'])
+                enum_vals = enum_prop
+                for x in check_observable_refs(refs, obj_prop, enum_prop, '',
+                                               enum_vals, key, instance):
+                    yield x
+
             elif isinstance(enum_prop, dict):
                 for embedded_prop in enum_prop:
-                    if embedded_prop not in obj[obj_prop]:
-                        continue
-                    embedded_obj = obj[obj_prop][embedded_prop]
-                    for embed_obj_prop in embedded_obj:
-                        if embed_obj_prop not in enum_prop[embedded_prop]:
+                    if isinstance(obj[obj_prop], dict):
+                        if embedded_prop not in obj[obj_prop]:
                             continue
-                        refs = embedded_obj[embed_obj_prop]
-                        if not isinstance(refs, list):
-                            refs = [refs]
-                        for ref in refs:
-                            try:
-                                refed_obj = instance['objects'][ref]
-                            except KeyError:
-                                yield JSONError("%s in observable object '%s' "
-                                                "can't resolve '%s' reference "
-                                                "'%s'."
-                                                % (obj_prop, key, embed_obj_prop, ref),
-                                                instance['id'])
-                                continue
-                            try:
-                                refed_type = refed_obj['type']
-                            except KeyError:
+                        embedded_obj = obj[obj_prop][embedded_prop]
+                        for embed_obj_prop in embedded_obj:
+                            if embed_obj_prop not in enum_prop[embedded_prop]:
                                 continue
+                            refs = embedded_obj[embed_obj_prop]
                             enum_vals = enum_prop[embedded_prop][embed_obj_prop]
-                            if refed_type not in enum_vals:
-                                if len(enum_vals) == 1:
-                                    valids = "'" + enum_vals[0] + "'"
-                                else:
-                                    valids = "'%s or '%s'" % ("', '".join(enum_vals[:-1]),
-                                                              enum_vals[-1])
-                                yield JSONError("'%s' in observable object '%s' must "
-                                                "refer to an object of type %s."
-                                                % (obj_prop, key, valids), instance['id'])
+                            for x in check_observable_refs(refs, obj_prop, enum_prop,
+                                                           embed_obj_prop, enum_vals,
+                                                           key, instance):
+                                yield x
+
+                    elif isinstance(obj[obj_prop], list):
+                        for embedded_list_obj in obj[obj_prop]:
+
+                            if embedded_prop not in embedded_list_obj:
+                                continue
+                            embedded_obj = embedded_list_obj[embedded_prop]
+                            refs = embedded_obj
+                            enum_vals = enum_prop[embedded_prop]
+                            for x in check_observable_refs(refs, obj_prop, enum_prop,
+                                                           embedded_prop, enum_vals,
+                                                           key, instance):
+                                yield x
 
 
 @cyber_observable_check
@@ -269,10 +248,13 @@ def software_language(instance):
     """
     for key, obj in instance['objects'].items():
         if ('type' in obj and obj['type'] == 'software' and
-                'language' in obj and obj['language'] not in enums.LANG_CODES):
-            yield JSONError("The 'language' property of object '%s' "
-                            "('%s') must be a valid ISO 639-2 language code."
-                            % (key, obj['language']), instance['id'])
+                'languages' in obj):
+            for lang in obj['languages']:
+                if lang not in enums.LANG_CODES:
+                    yield JSONError("The 'languages' property of object '%s' "
+                                    "contains an invalid ISO 639-2 language "
+                                    " code ('%s')."
+                                    % (key, lang), instance['id'])
 
 
 def types_strict(instance):
@@ -296,7 +278,6 @@ def list_musts(options):
     """
     validator_list = [
         modified_created,
-        sighting_refs,
         object_marking_circular_refs,
         granular_markings_circular_refs,
         marking_selector_syntax,
diff --git a/stix2validator/shoulds.py b/stix2validator/shoulds.py
index b0ba39b..73fe99d 100644
--- a/stix2validator/shoulds.py
+++ b/stix2validator/shoulds.py
@@ -568,8 +568,8 @@ def custom_observable_properties_prefix_strict(instance):
                             if (embedded not in enums.OBSERVABLE_EMBEDED_PROPERTIES[type_][prop] and
                                     not re.match("^x_.+\_.+$", embedded)):
                                 yield JSONError("Cyber Observable Object custom "
-                                                "property '%s' in the %s property of a"
-                                                " %s object should start with 'x_' "
+                                                "property '%s' in the %s property of "
+                                                "%s object should start with 'x_' "
                                                 "followed by a source unique "
                                                 "identifier (like a domain name with "
                                                 "dots replaced by dashes), a dash and "
@@ -579,8 +579,8 @@ def custom_observable_properties_prefix_strict(instance):
                     elif (embed_prop not in enums.OBSERVABLE_EMBEDED_PROPERTIES[type_][prop] and
                             not re.match("^x_.+\_.+$", embed_prop)):
                         yield JSONError("Cyber Observable Object custom "
-                                        "property '%s' in the %s property of a"
-                                        " %s object should start with 'x_' "
+                                        "property '%s' in the %s property of "
+                                        "%s object should start with 'x_' "
                                         "followed by a source unique "
                                         "identifier (like a domain name with "
                                         "dots replaced by dashes), a dash and "
@@ -654,15 +654,15 @@ def custom_observable_properties_prefix_lax(instance):
                             if (embedded not in enums.OBSERVABLE_EMBEDED_PROPERTIES[type_][prop] and
                                     not re.match("^x_.+$", embedded)):
                                 yield JSONError("Cyber Observable Object custom "
-                                                "property '%s' in the %s property of a"
-                                                " %s object should start with 'x_'."
+                                                "property '%s' in the %s property of "
+                                                "%s object should start with 'x_'."
                                                 % (embedded, prop, type_), instance['id'],
                                                 'custom-observable-properties-prefix')
                     elif (embed_prop not in enums.OBSERVABLE_EMBEDED_PROPERTIES[type_][prop] and
                             not re.match("^x_.+$", embed_prop)):
                         yield JSONError("Cyber Observable Object custom "
-                                        "property '%s' in the %s property of a"
-                                        " %s object should start with 'x_'."
+                                        "property '%s' in the %s property of "
+                                        "%s object should start with 'x_'."
                                         % (embed_prop, prop, type_), instance['id'],
                                         'custom-observable-properties-prefix')
 
@@ -812,10 +812,9 @@ def http_request_headers(instance):
                 continue
 
             for hdr in headers:
-                h_pattern = '^[A-Z][a-zA-Z0-9]*(-[A-Z][a-zA-Z0-9]*)*$'
-                if not re.match(h_pattern, hdr):
+                if hdr not in enums.HTTP_REQUEST_HEADERS:
                     yield JSONError("The 'request_header' property of object "
-                                    "'%s' contains an invalid HTTP request"
+                                    "'%s' contains an invalid HTTP request "
                                     "header ('%s')."
                                     % (key, hdr), instance['id'],
                                     'http-request-headers')
diff --git a/stix2validator/validator.py b/stix2validator/validator.py
index d1a4afb..e3730ae 100644
--- a/stix2validator/validator.py
+++ b/stix2validator/validator.py
@@ -10,7 +10,7 @@ from collections import Iterable
 
 from jsonschema import Draft4Validator, RefResolver
 from jsonschema import exceptions as schema_exceptions
-from six import text_type
+from six import text_type, iteritems
 import requests_cache
 
 from . import output, musts, shoulds
@@ -66,6 +66,12 @@ class CustomDraft4Validator(Draft4Validator):
                     for err in self.iter_errors_more(obj, check_musts):
                         yield err
 
+    def get_list(self):
+        """Return a combined list of all musts and shoulds this validator will
+        perform, both MUSTs and SHOULDs.
+        """
+        return self.musts_list + self.shoulds_list
+
 
 class BaseResults(object):
     """Base class for all validation result types.
@@ -304,6 +310,8 @@ def validate_file(fn, options=None):
                "will be performed.")
         output.info(msg.format(fn=fn))
     except Exception as ex:
+        import traceback
+        print(traceback.format_exc())
         results.fatal = ValidationErrorResults(ex)
         msg = ("Unexpected error occurred with file '{fn}'. No further "
                "validation will be performed: {error}")
@@ -350,12 +358,16 @@ def validate_string(string, options=None):
     return results
 
 
-def load_validator(schema_path, schema, options):
+def load_validator(schema_path, schema, options, custom=True):
     """Create a JSON schema validator for the given schema.
 
     Args:
         schema_path: The filename of the JSON schema.
         schema: A Python object representation of the same schema.
+        options: ValidationOptions instance with validation options for this
+            validator.
+        custom: A boolean, True indicating the CustomDraft4Validator should be
+            used, False indicating the default Draft4Validator should be used.
 
     Returns:
         An instance of Draft4Validator.
@@ -368,7 +380,11 @@ def load_validator(schema_path, schema, options):
         file_prefix = 'file:'
 
     resolver = RefResolver(file_prefix + schema_path.replace("\\", "/"), schema)
-    validator = CustomDraft4Validator(schema, resolver=resolver, options=options)
+
+    if custom:
+        validator = CustomDraft4Validator(schema, resolver=resolver, options=options)
+    else:
+        validator = Draft4Validator(schema, resolver=resolver)
 
     return validator
 
@@ -402,6 +418,76 @@ def load_schema(schema_path):
     return schema
 
 
+def object_validate(sdo, options, error_gens):
+    """Validate a single STIX object against its type's schema.
+    """
+    try:
+        sdo_schema_path = find_schema(options.schema_dir, sdo['type'])
+        sdo_schema = load_schema(sdo_schema_path)
+    except (KeyError, TypeError):
+        # Assume a custom object with no schema
+        try:
+            sdo_schema_path = find_schema(options.schema_dir, 'core')
+            sdo_schema = load_schema(sdo_schema_path)
+        except (KeyError, TypeError):
+            raise SchemaInvalidError("Cannot locate a schema for the "
+                                     "object's type, nor the base "
+                                     "schema (core.json).")
+
+    if sdo['type'] == 'observed-data':
+        # Validate against schemas for specific object types later
+        sdo_schema['allOf'][1]['properties']['objects'] = {
+            "objects": {
+                "type": "object",
+                "minProperties": 1
+            }
+        }
+
+    # Don't use custom validator; only check schemas, no additional checks
+    sdo_validator = load_validator(sdo_schema_path, sdo_schema,
+                                   options, custom=False)
+    try:
+        sdo_errors = sdo_validator.iter_errors(sdo)
+    except schema_exceptions.RefResolutionError:
+        raise SchemaInvalidError('Invalid JSON schema: a JSON '
+                                 'reference failed to resolve')
+
+    if 'id' in sdo:
+        error_prefix = sdo['id'] + ": "
+    else:
+        error_prefix = ''
+    error_gens.append((sdo_errors, error_prefix))
+
+    # Validate each cyber observable object separately
+    if sdo['type'] == 'observed-data' and 'objects' in sdo:
+        for key, obj in iteritems(sdo['objects']):
+            try:
+                obj_schema_path = find_schema(options.schema_dir, obj['type'])
+                obj_schema = load_schema(obj_schema_path)
+            except (KeyError, TypeError):
+                # Assume a custom object with no schema
+                try:
+                    obj_schema_path = find_schema(options.schema_dir,
+                                                  'cyber-observable-core')
+                    obj_schema = load_schema(obj_schema_path)
+                except (KeyError, TypeError):
+                    raise SchemaInvalidError("Cannot locate a schema for the "
+                                             "object's type, nor the base "
+                                             "schema (core.json).")
+
+            obj_validator = load_validator(obj_schema_path, obj_schema,
+                                           options, custom=False)
+            try:
+                obj_errors = obj_validator.iter_errors(obj)
+            except schema_exceptions.RefResolutionError:
+                raise SchemaInvalidError('Invalid JSON schema: a JSON '
+                                         'reference failed to resolve')
+            error_gens.append((obj_errors,
+                               error_prefix + 'object \'' + key + '\': '))
+
+    return error_gens
+
+
 def schema_validate(instance, options):
     """Perform STIX JSON Schema validation against the input JSON.
     Find the correct schema by looking at the 'type' property of the
@@ -423,15 +509,31 @@ def schema_validate(instance, options):
     try:
         schema_path = find_schema(options.schema_dir, instance['type'])
         schema = load_schema(schema_path)
-    except (KeyError, TypeError) as e:
+    except (KeyError, TypeError):
         # Assume a custom object with no schema
         try:
             schema_path = find_schema(options.schema_dir, 'core')
             schema = load_schema(schema_path)
-        except (KeyError, TypeError) as e:
+        except (KeyError, TypeError):
             raise SchemaInvalidError("Cannot locate a schema for the object's "
                                      "type, nor the base schema (core.json).")
 
+    # Validate against schemas for specific object types later
+    if instance['type'] == 'bundle':
+        schema['properties']['objects'] = {
+            "objects": {
+                "type": "array",
+                "minItems": 1
+            }
+        }
+    elif instance['type'] == 'observed-data':
+        schema['allOf'][1]['properties']['objects'] = {
+            "objects": {
+                "type": "object",
+                "minProperties": 1
+            }
+        }
+
     # Validate the schema first
     try:
         CustomDraft4Validator.check_schema(schema)
@@ -446,9 +548,8 @@ def schema_validate(instance, options):
         requests_cache.get_cache().remove_old_entries(now)
 
     validator = load_validator(schema_path, schema, options)
-    validator_list = validator.musts_list + validator.shoulds_list
     output.info("Running the following additional checks: %s."
-                % ", ".join(x.__name__ for x in validator_list))
+                % ", ".join(x.__name__ for x in validator.get_list()))
 
     # Actual validation of JSON document
     try:
@@ -462,30 +563,37 @@ def schema_validate(instance, options):
         else:
             chained_errors = chain(some_errors, more_errors)
             warnings = [pretty_error(x, options.verbose) for x in warnings]
-
-        errors = sorted(chained_errors, key=lambda e: e.path)
     except schema_exceptions.RefResolutionError:
         raise SchemaInvalidError('Invalid JSON schema: a JSON reference '
                                  'failed to resolve')
 
-    if not errors and not warnings:
-        return ValidationResults(True)
+    # List of error generators and message prefixes (to denote which object the
+    # error comes from)
+    error_gens = [(chained_errors, '')]
+
+    # Validate each object in a bundle separately
+    if instance['type'] == 'bundle' and 'objects' in instance:
+        for sdo in instance['objects']:
+            object_validate(sdo, options, error_gens)
+    else:
+        object_validate(instance, options, error_gens)
+
+    # Clear requests cache if commandline flag was set
+    if options.clear_cache:
+        now = datetime.datetime.utcnow()
+        requests_cache.get_cache().remove_old_entries(now)
 
     # Prepare the list of errors
     error_list = []
-    for error in errors:
-        msg = pretty_error(error, options.verbose)
-        error_list.append(SchemaError(msg))
+    for gen, prefix in error_gens:
+        for error in gen:
+            msg = prefix + pretty_error(error, options.verbose)
+            error_list.append(SchemaError(msg))
 
     if error_list:
         valid = False
     else:
         valid = True
 
-    # Clear requests cache if commandline flag was set
-    if options.clear_cache:
-        now = datetime.datetime.utcnow()
-        requests_cache.get_cache().remove_old_entries(now)
-
     return ValidationResults(is_valid=valid, errors=error_list,
                              warnings=warnings)


Test patch:
diff --git a/stix2validator/test/marking_definition_tests.py b/stix2validator/test/marking_definition_tests.py
index d89e525..2d69a4f 100644
--- a/stix2validator/test/marking_definition_tests.py
+++ b/stix2validator/test/marking_definition_tests.py
@@ -63,6 +63,11 @@ class MarkingDefinitionTestCases(ValidatorTest):
         }]
         self.assertFalseWithOptions(json.dumps(marking_definition))
 
+    def test_marking_definition_invalid_definition(self):
+        marking_definition = copy.deepcopy(self.valid_marking_definition)
+        marking_definition['definition']['tlp'] = 21
+        self.assertFalseWithOptions(json.dumps(marking_definition))
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/stix2validator/test/network_traffic_tests.py b/stix2validator/test/network_traffic_tests.py
index 7d3a02f..b206fb4 100644
--- a/stix2validator/test/network_traffic_tests.py
+++ b/stix2validator/test/network_traffic_tests.py
@@ -126,6 +126,14 @@ class ObservedDataTestCases(ValidatorTest):
         self.assertFalseWithOptions(json.dumps(net_traffic))
         self.check_ignore(json.dumps(net_traffic), 'socket-options')
 
+    def test_network_traffic_end_is_active(self):
+        net_traffic = copy.deepcopy(self.valid_net_traffic)
+        net_traffic['objects']['1']['is_active'] = True
+        net_traffic['objects']['1']['end'] = "2016-12-21T19:00:00Z"
+        self.assertFalseWithOptions(json.dumps(net_traffic))
+
+        del net_traffic['objects']['1']['end']
+        self.assertTrueWithOptions(json.dumps(net_traffic))
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/stix2validator/test/observed_data_tests.py b/stix2validator/test/observed_data_tests.py
index e38a483..1b02cc9 100644
--- a/stix2validator/test/observed_data_tests.py
+++ b/stix2validator/test/observed_data_tests.py
@@ -207,6 +207,7 @@ class ObservedDataTestCases(ValidatorTest):
     def test_vocab_pebinary_sections_hashes(self):
         observed_data = copy.deepcopy(self.valid_observed_data)
         observed_data['objects']['0']['extensions']['windows-pebinary-ext'] = {
+            "pe_type": "exe",
             "sections": [
                 {
                     "name": "CODE",
@@ -225,6 +226,7 @@ class ObservedDataTestCases(ValidatorTest):
     def test_vocab_pebinary_optional_header_hashes(self):
         observed_data = copy.deepcopy(self.valid_observed_data)
         observed_data['objects']['0']['extensions']['windows-pebinary-ext'] = {
+            "pe_type": "exe",
             "optional_header": {
                 "hashes": {
                     "foo": "1C19FC56AEF2048C1CD3A5E67B099350"
@@ -243,6 +245,7 @@ class ObservedDataTestCases(ValidatorTest):
     def test_vocab_pebinary_file_header_hashes(self):
         observed_data = copy.deepcopy(self.valid_observed_data)
         observed_data['objects']['0']['extensions']['windows-pebinary-ext'] = {
+            "pe_type": "exe",
             "file_header_hashes": {
                 "foo": "1C19FC56AEF2048C1CD3A5E67B099350"
             }
@@ -255,6 +258,7 @@ class ObservedDataTestCases(ValidatorTest):
     def test_vocab_pebinary_multiple_hashes(self):
         observed_data = copy.deepcopy(self.valid_observed_data)
         observed_data['objects']['0']['extensions']['windows-pebinary-ext'] = {
+            "pe_type": "exe",
             "file_header_hashes": {
                 "foo": "1C19FC56AEF2048C1CD3A5E67B099350"
             },
@@ -543,11 +547,11 @@ class ObservedDataTestCases(ValidatorTest):
         observed_data['objects']['2'] = {
             "type": "software",
             "name": "word",
-            "language": "bbb"
+            "languages": ["bbb"]
         }
         self.assertFalseWithOptions(json.dumps(observed_data))
 
-        observed_data['objects']['2']['language'] = 'eng'
+        observed_data['objects']['2']['languages'][0] = 'eng'
         self.assertTrueWithOptions(json.dumps(observed_data))
 
     def test_email_address_invalid_value(self):
@@ -604,6 +608,52 @@ class ObservedDataTestCases(ValidatorTest):
         observed_data['objects']['4']['body'] = "Hello World"
         self.assertTrueWithOptions(json.dumps(observed_data))
 
+    def test_email_message_multipart_body_raw_refs(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['2'] = {
+            "type": "email-message",
+            "is_multipart": True,
+            "body_multipart": [
+                {
+                    "content_type": "text/plain; charset=utf-8",
+                    "body": "Cats are cute!"
+                },
+                {
+                    "body_raw_ref": "999"
+                }
+            ]
+
+        }
+        self.assertFalseWithOptions(json.dumps(observed_data))
+
+        observed_data['objects']['2']['body_multipart'][1]['body_raw_ref'] = "0"
+        self.assertTrueWithOptions(json.dumps(observed_data))
+
+    def test_artifact_url_payloadbin(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['2'] = {
+            "type": "artifact",
+            "mime_type": "image/jpeg",
+            "payload_bin": "VBORw0KGgoAAAANSUhEUgAAADI==",
+            "hashes": {
+                "MD5": "69D0D97D02A03C43782DD571394E6869"
+            },
+            "url": "www.g.com"
+        }
+        self.assertFalseWithOptions(json.dumps(observed_data))
+
+    def test_file_invalid_is_encrypted(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['2'] = {
+            "type": "file",
+            "hashes": {
+                "MD5": "8D98A25E9D0662B1F4CA3BF22D6F53E9"
+            },
+            "is_encrypted": False,
+            "encryption_algorithm": "RSA"
+        }
+        self.assertFalseWithOptions(json.dumps(observed_data))
+
 
 if __name__ == "__main__":
     unittest.main()