Repository: oasis-open/cti-stix-validator

Issue:
Support later versions of STIX 2
We need to figure out how to support later versions of STIX 2 in the validator. It should probably default to the latest published version of the spec, with an option to set the version to validate against. People may want to validate against multiple versions of the spec if they are supporting multiple versions at once. Each version of the validator could have a maximum supported STIX version, or we could do something with subpackages like https://github.com/oasis-open/cti-python-stix2/issues/79. I'm leaning towards the latter.

Discussion:

Patch:
diff --git a/.gitmodules b/.gitmodules
index 90a8c8b..b765de6 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,6 @@
-[submodule "schemas"]
-	path = stix2validator/schemas
-	url = https://github.com/oasis-open/cti-stix2-json-schemas
+[submodule "stix2validator/schemas-2.0"]
+	path = stix2validator/schemas-2.0
+	url = https://github.com/oasis-open/cti-stix2-json-schemas.git
+[submodule "stix2validator/schemas-2.1"]
+	path = stix2validator/schemas-2.1
+	url = https://github.com/oasis-open/cti-stix2-json-schemas.git
diff --git a/docs/best-practices.rst b/docs/best-practices.rst
index f86cdf8..9ca1f25 100644
--- a/docs/best-practices.rst
+++ b/docs/best-practices.rst
@@ -33,7 +33,134 @@ or :code:`refresh_cache=True`, or cleared with :code:`--clear-cache` or
 :code:`clear_cache=True`. This caching can be disabled entirely with
 :code:`--no-cache` or :code:`no_cache=True`.
 
-**Recommended Best Practice Check Codes**
+Check Codes - STIX 2.1
+----------------------
+
++--------+-----------------------------+----------------------------------------+
+|**Code**|**Name**                     |**Ensures...**                          |
++--------+-----------------------------+----------------------------------------+
+|   1    | format-checks               | all 1xx checks are run                 |
++--------+-----------------------------+----------------------------------------+
+|  101   | custom-prefix               | names of custom object types,          |
+|        |                             | properties, observable objects,        |
+|        |                             | observable object properties, and      |
+|        |                             | observable object extensions follow    |
+|        |                             | the correct format                     |
++--------+-----------------------------+----------------------------------------+
+|  102   | custom-prefix-lax           | same as 101 but more lenient; no       |
+|        |                             | source identifier needed in prefix     |
++--------+-----------------------------+----------------------------------------+
+|  111   | open-vocab-format           | values of open vocabularies follow the |
+|        |                             | correct format                         |
++--------+-----------------------------+----------------------------------------+
+|  121   | kill-chain-names            | kill-chain-phase name and phase follow |
+|        |                             | the correct format                     |
++--------+-----------------------------+----------------------------------------+
+|  141   | observable-object-keys      | observable object keys follow the      |
+|        |                             | correct format                         |
++--------+-----------------------------+----------------------------------------+
+|  142   | observable-dictionary-keys  | dictionaries in cyber observable       |
+|        |                             | objects follow the correct format      |
++--------+-----------------------------+----------------------------------------+
+|  149   | windows-process-priority-\  | windows-process-ext's 'priority'       |
+|        | format                      | follows the correct format             |
++--------+-----------------------------+----------------------------------------+
+|  150   | hash-length                 | keys in 'hashes'-type properties are   |
+|        |                             | not too long                           |
++--------+-----------------------------+----------------------------------------+
+|   2    | approved-values             | all 2xx checks are run                 |
++--------+-----------------------------+----------------------------------------+
+|  201   | marking-definition-type     | marking definitions use a valid        |
+|        |                             | definition_type                        |
++--------+-----------------------------+----------------------------------------+
+|  202   | relationship-types          | relationships are among those defined  |
+|        |                             | in the specification                   |
++--------+-----------------------------+----------------------------------------+
+|  203   | duplicate-ids               | objects in a bundle with duplicate IDs |
+|        |                             | have different `modified` timestamps   |
++--------+-----------------------------+----------------------------------------+
+|  210   | all-vocabs                  | all of the following open vocabulary   |
+|        |                             | checks are run                         |
++--------+-----------------------------+----------------------------------------+
+|  211   | attack-motivation           | certain property values are from the   |
+|        |                             | attack_motivation vocabulary           |
++--------+-----------------------------+----------------------------------------+
+|  212   | attack-resource-level       | certain property values are from the   |
+|        |                             | attack_resource_level vocabulary       |
++--------+-----------------------------+----------------------------------------+
+|  213   | identity-class              | certain property values are from the   |
+|        |                             | identity_class vocabulary              |
++--------+-----------------------------+----------------------------------------+
+|  214   | indicator-types             | certain property values are from the   |
+|        |                             | indicator_types vocabulary             |
++--------+-----------------------------+----------------------------------------+
+|  215   | industry-sector             | certain property values are from the   |
+|        |                             | industry_sector vocabulary             |
++--------+-----------------------------+----------------------------------------+
+|  216   | malware-types               | certain property values are from the   |
+|        |                             | malware_types vocabulary               |
++--------+-----------------------------+----------------------------------------+
+|  218   | report-types                | certain property values are from the   |
+|        |                             | report_types vocabulary                |
++--------+-----------------------------+----------------------------------------+
+|  219   | threat-actor-types          | certain property values are from the   |
+|        |                             | threat_actor_types vocabulary          |
++--------+-----------------------------+----------------------------------------+
+|  220   | threat-actor-role           | certain property values are from the   |
+|        |                             | threat_actor_role vocabulary           |
++--------+-----------------------------+----------------------------------------+
+|  221   | threat-actor-sophistication | certain property values are from the   |
+|        |                             | threat_actor_sophistication vocabulary |
++--------+-----------------------------+----------------------------------------+
+|  222   | tool-types                  | certain property values are from the   |
+|        |                             | tool_types vocabulary                  |
++--------+-----------------------------+----------------------------------------+
+|  223   | region                      | certain property values are from the   |
+|        |                             | region vocabulary                      |
++--------+-----------------------------+----------------------------------------+
+|  241   | hash-algo                   | certain property values are from the   |
+|        |                             | hash-algo vocabulary                   |
++--------+-----------------------------+----------------------------------------+
+|  243   | windows-pebinary-type       | certain property values are from the   |
+|        |                             | windows-pebinary-type vocabulary       |
++--------+-----------------------------+----------------------------------------+
+|  244   | account-type                | certain property values are from the   |
+|        |                             | account-type vocabulary                |
++--------+-----------------------------+----------------------------------------+
+|  270   | all-external-sources        | all of the following external source   |
+|        |                             | checks are run                         |
++--------+-----------------------------+----------------------------------------+
+|  271   | mime-type                   | file.mime_type is a valid IANA MIME    |
+|        |                             | type                                   |
++--------+-----------------------------+----------------------------------------+
+|  272   | protocols                   | certain property values are valid IANA |
+|        |                             | Service and Protocol names             |
++--------+-----------------------------+----------------------------------------+
+|  273   | ipfix                       | certain property values are valid IANA |
+|        |                             | IP Flow Information Export (IPFIX)     |
+|        |                             | Entities                               |
++--------+-----------------------------+----------------------------------------+
+|  274   | http-request-headers        | certain property values are valid HTTP |
+|        |                             | request header names                   |
++--------+-----------------------------+----------------------------------------+
+|  275   | socket-options              | certain property values are valid      |
+|        |                             | socket options                         |
++--------+-----------------------------+----------------------------------------+
+|  276   | pdf-doc-info                | certain property values are valid PDF  |
+|        |                             | Document Information Dictionary keys   |
++--------+-----------------------------+----------------------------------------+
+|  277   | countries                   | certain property values are valid ISO  |
+|        |                             | 3166-1 ALPHA-2 codes                   |
++--------+-----------------------------+----------------------------------------+
+|  301   | network-traffic-ports       | network-traffic objects contain both   |
+|        |                             | src_port and dst_port                  |
++--------+-----------------------------+----------------------------------------+
+|  302   | extref-hashes               | external references SHOULD have hashes |
+|        |                             | if they have a url                     |
++--------+-----------------------------+----------------------------------------+
+
+Check Codes - STIX 2.0
+----------------------
 
 +--------+-----------------------------+----------------------------------------+
 |**Code**|**Name**                     |**Ensures...**                          |
diff --git a/docs/index.rst b/docs/index.rst
index 4031219..1d0c5b2 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -2,15 +2,15 @@ Welcome to the stix2-validator documentation!
 =============================================
 
 The STIX Validator checks that STIX JSON content conforms to the
-requirements specified in the STIX 2.0 specification. In addition to
+requirements specified in the latest STIX 2 specifications. In addition to
 checking conformance with the `JSON schemas <https://github.com/oasis-
 open/cti-stix2-json-schemas>`_, the validator checks conformance with
 requirements that cannot be specified in JSON schema, as well as with
 established "best practices." This validator is non-normative; in
-cases of conflict with the STIX 2.0 specification, the specification
+cases of conflict with the STIX specification, the specification
 takes precedence.
 
-The STIX 2.0 specification contains two types of requirements:
+The STIX 2 specification contains two types of requirements:
 mandatory "MUST" requirements, and recommended "SHOULD" best practice
 requirements. The validator checks documents against the "MUST"
 requirements using JSON schemas. Some of these mandatory requirements
@@ -19,11 +19,6 @@ Python functions to check them. The "SHOULD" requirements are all
 checked by Python functions, and options may be used to ignore some or
 all of these recommended "best practices."
 
-The only exception to this is the mandatory requirement that an
-object's 'type' be one of those defined by a STIX Object in the
-specification. This rules out custom objects, so this check was made
-optional.
-
 The STIX Validator uses the `stix2-patterns validator
 <https://github.com/oasis-open/cti-pattern-validator>`_ to check that
 Indicator patterns conform to the STIX Patterning language and only
diff --git a/docs/options.rst b/docs/options.rst
index f80b180..3725cc8 100644
--- a/docs/options.rst
+++ b/docs/options.rst
@@ -50,4 +50,4 @@ constructor.
 |                          |                       | validation.                                            |
 +--------------------------+-----------------------+--------------------------------------------------------+
 
-For the list of checks that can be used with the "enabled" or "disabled" options, see the :doc:`Best Practices page <best-practices>`_.
+For the list of checks that can be used with the "enabled" or "disabled" options, see the :doc:`Best Practices page <best-practices>`.
diff --git a/docs/usage.rst b/docs/usage.rst
index f34ca1a..6225c8d 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -61,6 +61,24 @@ other than the default:
   options = ValidationOptions(strict=True)
   results = validate_string(stix_json_string, options)
 
+STIX 2 Versions
+---------------
+
+By default the validator will check content against the latest version of
+the STIX 2 specification. However, older versions can be checked with the
+``version`` option. For example:
+
+::
+
+  $ stix2_validator --version=2.0 <stix_file.json>
+
+or in Python:
+
+.. code:: python
+
+  options = ValidationOptions(strict=True, version="2.0")
+  results = validate_string(stix_json_string, options)
+
 Additional Schemas
 ------------------
 
diff --git a/stix2validator/errors.py b/stix2validator/errors.py
index f94e46c..c3e70b7 100644
--- a/stix2validator/errors.py
+++ b/stix2validator/errors.py
@@ -4,28 +4,13 @@ import re
 from jsonschema import exceptions as schema_exceptions
 from six import python_2_unicode_compatible, text_type
 
-from . import enums
-from .util import CHECK_CODES
 
-
-class JSONError(schema_exceptions.ValidationError):
-    """Wrapper for errors thrown by iter_errors() in the jsonschema module.
-    Makes errors generated by our functions look like those from jsonschema.
-    """
-    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_id]))
-
-
-class PatternError(JSONError):
+class PatternError(schema_exceptions.ValidationError):
     """Represent a problem with a STIX Pattern.
     """
-    def __init__(self, msg=None, instance_id=None, check_code=None):
+    def __init__(self, msg=None, instance_id=None):
         msg = 'Pattern failed to validate: %s.' % msg
-        super(PatternError, self).__init__(msg, instance_id, check_code)
+        super(PatternError, self).__init__(msg, path=deque([instance_id]))
 
 
 class NoJSONFileFoundError(OSError):
@@ -110,7 +95,10 @@ def pretty_error(error, verbose=False):
     # Don't reword error messages from our validators,
     # only the default error messages from the jsonschema library
     if repr(error.schema) == '<unset>':
-        return error_loc + msg
+        try:
+            return error_loc + msg
+        except UnicodeDecodeError:
+            return error_loc + msg.decode('utf-8')
 
     # Reword error messages containing regexes
     if error.validator == 'pattern' and 'title' in error.schema:
@@ -156,8 +144,9 @@ def pretty_error(error, verbose=False):
                          'proper format (lowercase ASCII a-z, 0-9, and '
                          'underscores; 3-250 characters)', msg)
         elif error.validator == 'not' and 'anyOf' in error.validator_value:
+            reserved_properties = [y for x in error.validator_value['anyOf'] for y in x['required']]
             msg = re.sub(r".+", "Contains a reserved property ('%s')"
-                         % "', '".join(enums.RESERVED_PROPERTIES), msg)
+                         % "', '".join(reserved_properties), msg)
     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'
diff --git a/stix2validator/schemas b/stix2validator/schemas
deleted file mode 160000
index 8f2448b..0000000
--- a/stix2validator/schemas
+++ /dev/null
@@ -1,1 +0,0 @@
-Subproject commit 8f2448bd97e458dba103bbcf5249837781e3b716
diff --git a/stix2validator/schemas-2.0 b/stix2validator/schemas-2.0
new file mode 160000
index 0000000..db69d76
--- /dev/null
+++ b/stix2validator/schemas-2.0
@@ -0,0 +1,1 @@
+Subproject commit db69d7646f78ea6b2ad98f8890cf79d36fb9be2b
diff --git a/stix2validator/schemas-2.1 b/stix2validator/schemas-2.1
new file mode 160000
index 0000000..9fc406f
--- /dev/null
+++ b/stix2validator/schemas-2.1
@@ -0,0 +1,1 @@
+Subproject commit 9fc406f767e65fedda2dc07d22ac38fdd585cd2c
diff --git a/stix2validator/util.py b/stix2validator/util.py
index 10e7d86..422e4e3 100644
--- a/stix2validator/util.py
+++ b/stix2validator/util.py
@@ -11,12 +11,19 @@ from appdirs import AppDirs
 import requests_cache
 
 from .output import error, set_level, set_silent
+from .v20.enums import CHECK_CODES as CHECK_CODES20
+from .v21.enums import CHECK_CODES as CHECK_CODES21
+
+DEFAULT_VER = "2.1"
 
 CODES_TABLE = """
 The following is a table of all the recommended "best practice" checks which
 the validator performs, along with the code to use with the --enable or
 --disable options. By default, the validator checks all of them.
 
+This table is for STIX version {}. For older versions, please refer to
+https://stix2-validator.readthedocs.io/en/latest/best-practices.html.
+
 +------+-----------------------------+----------------------------------------+
 | Code | Name                        | Ensures...                             |
 +------+-----------------------------+----------------------------------------+
@@ -56,26 +63,26 @@ the validator performs, along with the code to use with the --enable or
 |      |                             | attack_resource_level vocabulary       |
 | 213  | identity-class              | certain property values are from the   |
 |      |                             | identity_class vocabulary              |
-| 214  | indicator-label             | certain property values are from the   |
-|      |                             | indicator_label vocabulary             |
+| 214  | indicator-types             | certain property values are from the   |
+|      |                             | indicator_types vocabulary             |
 | 215  | industry-sector             | certain property values are from the   |
 |      |                             | industry_sector vocabulary             |
-| 216  | malware-label               | certain property values are from the   |
-|      |                             | malware_label vocabulary               |
-| 218  | report-label                | certain property values are from the   |
-|      |                             | report_label vocabulary                |
-| 219  | threat-actor-label          | certain property values are from the   |
-|      |                             | threat_actor_label vocabulary          |
+| 216  | malware-types               | certain property values are from the   |
+|      |                             | malware_types vocabulary               |
+| 218  | report-types                | certain property values are from the   |
+|      |                             | report_types vocabulary                |
+| 219  | threat-actor-types          | certain property values are from the   |
+|      |                             | threat_actor_types vocabulary          |
 | 220  | threat-actor-role           | certain property values are from the   |
 |      |                             | threat_actor_role vocabulary           |
 | 221  | threat-actor-sophistication | certain property values are from the   |
 |      |                             | threat_actor_sophistication vocabulary |
-| 222  | tool-label                  | certain property values are from the   |
-|      |                             | tool_label vocabulary                  |
+| 222  | tool-types                  | certain property values are from the   |
+|      |                             | tool_types vocabulary                  |
+| 222  | region                      | certain property values are from the   |
+|      |                             | region vocabulary                      |
 | 241  | hash-algo                   | certain property values are from the   |
 |      |                             | hash-algo vocabulary                   |
-| 242  | encryption-algo             | certain property values are from the   |
-|      |                             | encryption-algo vocabulary             |
 | 243  | windows-pebinary-type       | certain property values are from the   |
 |      |                             | windows-pebinary-type vocabulary       |
 | 244  | account-type                | certain property values are from the   |
@@ -95,12 +102,14 @@ the validator performs, along with the code to use with the --enable or
 |      |                             | socket options                         |
 | 276  | pdf-doc-info                | certain property values are valid PDF  |
 |      |                             | Document Information Dictionary keys   |
+| 277  | countries                   | certain property values are valid ISO  |
+|      |                             | 3166-1 ALPHA-2 codes                   |
 | 301  | network-traffic-ports       | network-traffic objects contain both   |
 |      |                             | src_port and dst_port                  |
 | 302  | extref-hashes               | external references SHOULD have hashes |
 |      |                             | if they have a url                     |
 +------+-----------------------------+----------------------------------------+
-"""
+""".format(DEFAULT_VER)
 
 
 class NewlinesHelpFormatter(RawDescriptionHelpFormatter):
@@ -157,6 +166,13 @@ def parse_args(cmd_args, is_script=False):
              "against these schemas in addition to the STIX schemas bundled "
              "with this script."
     )
+    parser.add_argument(
+        "--version",
+        dest="version",
+        default=DEFAULT_VER,
+        help="The version of the STIX specification to validate against (e.g. "
+             "\"2.0\")."
+    )
 
     # Output options
     parser.add_argument(
@@ -264,6 +280,8 @@ def parse_args(cmd_args, is_script=False):
 
     if not is_script:
         args.files = ""
+    if not args.version:
+        args.version = DEFAULT_VER
 
     return ValidationOptions(args)
 
@@ -279,6 +297,7 @@ class ValidationOptions(object):
     Attributes:
         cmd_args: An instance of ``argparse.Namespace`` containing options
             supplied on the command line.
+        version: The version of the STIX specification to validate against.
         verbose: True if informational notes and more verbose error messages
             should be printed to stdout/stderr.
         silent: True if all output to stdout should be silenced.
@@ -305,13 +324,14 @@ class ValidationOptions(object):
             contained within the same bundle
 
     """
-    def __init__(self, cmd_args=None, verbose=False, silent=False,
+    def __init__(self, cmd_args=None, version=DEFAULT_VER, verbose=False, silent=False,
                  files=None, recursive=False, schema_dir=None,
                  disabled="", enabled="", strict=False,
                  strict_types=False, strict_properties=False, no_cache=False,
                  refresh_cache=False, clear_cache=False, enforce_refs=False):
 
         if cmd_args is not None:
+            self.version = cmd_args.version
             self.verbose = cmd_args.verbose
             self.silent = cmd_args.silent
             self.files = cmd_args.files
@@ -328,6 +348,7 @@ class ValidationOptions(object):
             self.enforce_refs = cmd_args.enforce_refs
         else:
             # input options
+            self.version = version
             self.files = files
             self.recursive = recursive
             self.schema_dir = schema_dir
@@ -353,61 +374,23 @@ class ValidationOptions(object):
         set_level(self.verbose)
         set_silent(self.silent)
 
+        if self.version == '2.0':
+            check_codes = CHECK_CODES20
+        else:  # Default version
+            check_codes = CHECK_CODES21
+
         # Convert string of comma-separated checks to a list,
         # and convert check code numbers to names
         if self.disabled:
-            self.disabled = self.disabled.split(",")
-            self.disabled = [CHECK_CODES[x] if x in CHECK_CODES else x
+            self.disabled = self.disabled.split(',')
+            self.disabled = [check_codes[x] if x in check_codes else x
                              for x in self.disabled]
         if self.enabled:
-            self.enabled = self.enabled.split(",")
-            self.enabled = [CHECK_CODES[x] if x in CHECK_CODES else x
+            self.enabled = self.enabled.split(',')
+            self.enabled = [check_codes[x] if x in check_codes else x
                             for x in self.enabled]
 
 
-# Mapping of check code numbers to names
-CHECK_CODES = {
-    '1': 'format-checks',
-    '101': 'custom-prefix',
-    '102': 'custom-prefix-lax',
-    '111': 'open-vocab-format',
-    '121': 'kill-chain-names',
-    '141': 'observable-object-keys',
-    '142': 'observable-dictionary-keys',
-    '149': 'windows-process-priority-format',
-    '150': 'hash-length',
-    '2': 'approved-values',
-    '201': 'marking-definition-type',
-    '202': 'relationship-types',
-    '203': 'duplicate-ids',
-    '210': 'all-vocabs',
-    '211': 'attack-motivation',
-    '212': 'attack-resource-level',
-    '213': 'identity-class',
-    '214': 'indicator-label',
-    '215': 'industry-sector',
-    '216': 'malware-label',
-    '218': 'report-label',
-    '219': 'threat-actor-label',
-    '220': 'threat-actor-role',
-    '221': 'threat-actor-sophistication',
-    '222': 'tool-label',
-    '241': 'hash-algo',
-    '242': 'encryption-algo',
-    '243': 'windows-pebinary-type',
-    '244': 'account-type',
-    '270': 'all-external-sources',
-    '271': 'mime-type',
-    '272': 'protocols',
-    '273': 'ipfix',
-    '274': 'http-request-headers',
-    '275': 'socket-options',
-    '276': 'pdf-doc-info',
-    '301': 'network-traffic-ports',
-    '302': 'extref-hashes',
-}
-
-
 def has_cyber_observable_data(instance):
     """Return True only if the given instance is an observed-data object
     containing STIX Cyber Observable objects.
diff --git a/stix2validator/v20/__init__.py b/stix2validator/v20/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/stix2validator/enums.py b/stix2validator/v20/enums.py
similarity index 96%
rename from stix2validator/enums.py
rename to stix2validator/v20/enums.py
index 5ce5519..4dcabf4 100644
--- a/stix2validator/enums.py
+++ b/stix2validator/v20/enums.py
@@ -1353,6 +1353,48 @@ VOCAB_PROPERTIES = {
     ]
 }
 
+# Mapping of check code numbers to names
+CHECK_CODES = {
+    '1': 'format-checks',
+    '101': 'custom-prefix',
+    '102': 'custom-prefix-lax',
+    '111': 'open-vocab-format',
+    '121': 'kill-chain-names',
+    '141': 'observable-object-keys',
+    '142': 'observable-dictionary-keys',
+    '149': 'windows-process-priority-format',
+    '150': 'hash-length',
+    '2': 'approved-values',
+    '201': 'marking-definition-type',
+    '202': 'relationship-types',
+    '203': 'duplicate-ids',
+    '210': 'all-vocabs',
+    '211': 'attack-motivation',
+    '212': 'attack-resource-level',
+    '213': 'identity-class',
+    '214': 'indicator-label',
+    '215': 'industry-sector',
+    '216': 'malware-label',
+    '218': 'report-label',
+    '219': 'threat-actor-label',
+    '220': 'threat-actor-role',
+    '221': 'threat-actor-sophistication',
+    '222': 'tool-label',
+    '241': 'hash-algo',
+    '242': 'encryption-algo',
+    '243': 'windows-pebinary-type',
+    '244': 'account-type',
+    '270': 'all-external-sources',
+    '271': 'mime-type',
+    '272': 'protocols',
+    '273': 'ipfix',
+    '274': 'http-request-headers',
+    '275': 'socket-options',
+    '276': 'pdf-doc-info',
+    '301': 'network-traffic-ports',
+    '302': 'extref-hashes',
+}
+
 
 def media_types():
     """Return a list of the IANA Media (MIME) Types, or an empty list if the
@@ -1522,7 +1564,7 @@ PDF_DID = [
     'Trapped'
 ]
 
-LANG_CODES = [
+SOFTWARE_LANG_CODES = [
     'aar', 'abk', 'ace', 'ach', 'ada', 'ady', 'afa', 'afh', 'afr', 'ain',
     'aka', 'akk', 'alb', 'sqi', 'ale', 'alg', 'alt', 'amh', 'ang', 'anp',
     'apa', 'ara', 'arc', 'arg', 'arm', 'hye', 'arn', 'arp', 'art', 'arw',
diff --git a/stix2validator/v20/errors.py b/stix2validator/v20/errors.py
new file mode 100644
index 0000000..2c63f23
--- /dev/null
+++ b/stix2validator/v20/errors.py
@@ -0,0 +1,19 @@
+from collections import deque
+
+from jsonschema import exceptions as schema_exceptions
+
+from ..errors import (NoJSONFileFoundError, PatternError, SchemaError,  # noqa
+                      SchemaInvalidError, ValidationError, pretty_error)
+from .enums import CHECK_CODES
+
+
+class JSONError(schema_exceptions.ValidationError):
+    """Wrapper for errors thrown by iter_errors() in the jsonschema module.
+    Makes errors generated by our functions look like those from jsonschema.
+    """
+    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_id]))
diff --git a/stix2validator/musts.py b/stix2validator/v20/musts.py
similarity index 99%
rename from stix2validator/musts.py
rename to stix2validator/v20/musts.py
index f30bd16..fb8710f 100644
--- a/stix2validator/musts.py
+++ b/stix2validator/v20/musts.py
@@ -9,9 +9,10 @@ from stix2patterns.pattern import Pattern
 from stix2patterns.validator import run_validator as pattern_validator
 
 from . import enums
-from .errors import JSONError, PatternError
-from .output import info
-from .util import cyber_observable_check, has_cyber_observable_data
+from ..errors import PatternError
+from ..output import info
+from ..util import cyber_observable_check, has_cyber_observable_data
+from .errors import JSONError
 
 CUSTOM_TYPE_PREFIX_RE = re.compile(r"^x\-.+\-.+$")
 CUSTOM_TYPE_LAX_PREFIX_RE = re.compile(r"^x\-.+$")
@@ -311,7 +312,7 @@ def software_language(instance):
         if ('type' in obj and obj['type'] == 'software' and
                 'languages' in obj):
             for lang in obj['languages']:
-                if lang not in enums.LANG_CODES:
+                if lang not in enums.SOFTWARE_LANG_CODES:
                     yield JSONError("The 'languages' property of object '%s' "
                                     "contains an invalid ISO 639-2 language "
                                     " code ('%s')."
diff --git a/stix2validator/shoulds.py b/stix2validator/v20/shoulds.py
similarity index 98%
rename from stix2validator/shoulds.py
rename to stix2validator/v20/shoulds.py
index 6605d2f..cadc6cc 100644
--- a/stix2validator/shoulds.py
+++ b/stix2validator/v20/shoulds.py
@@ -20,11 +20,11 @@ import re
 from six import string_types
 
 from . import enums
+from ..output import info
+from ..util import cyber_observable_check
 from .errors import JSONError
 from .musts import (CUSTOM_PROPERTY_LAX_PREFIX_RE, CUSTOM_PROPERTY_PREFIX_RE,
                     CUSTOM_TYPE_LAX_PREFIX_RE, CUSTOM_TYPE_PREFIX_RE)
-from .output import info
-from .util import cyber_observable_check
 
 PROTOCOL_RE = re.compile(r'^[a-zA-Z0-9-]{1,15}$')
 
@@ -217,7 +217,7 @@ def vocab_indicator_label(instance):
 
 def vocab_industry_sector(instance):
     return check_vocab(instance, "INDUSTRY_SECTOR",
-                       'indicator-label')
+                       'industry-sector')
 
 
 def vocab_malware_label(instance):
@@ -444,7 +444,7 @@ def vocab_encryption_algo(instance):
 @cyber_observable_check
 def vocab_windows_pebinary_type(instance):
     """Ensure file objects with the windows-pebinary-ext extension have a
-    'pe-type' property that is from the account-type-ov vocabulary.
+    'pe-type' property that is from the windows-pebinary-type-ov vocabulary.
     """
     for key, obj in instance['objects'].items():
         if 'type' in obj and obj['type'] == 'file':
@@ -818,7 +818,7 @@ def protocols(instance):
 
 @cyber_observable_check
 def ipfix(instance):
-    """Ensure the 'protocols' property of network-traffic objects contains only
+    """Ensure the 'ipfix' property of network-traffic objects contains only
     values from the IANA IP Flow Information Export (IPFIX) Entities Registry.
     """
     ipf_pattern = re.compile(r'^[a-z][a-zA-Z0-9]+')
@@ -1299,6 +1299,16 @@ def list_shoulds(options):
                 if 'all-external-sources' not in options.disabled:
                     if 'mime-type' not in options.disabled:
                         validator_list.append(CHECKS['mime-type'])
+                    if 'protocols' not in options.disabled:
+                        validator_list.append(CHECKS['protocols'])
+                    if 'ipfix' not in options.disabled:
+                        validator_list.append(CHECKS['ipfix'])
+                    if 'http-request-headers' not in options.disabled:
+                        validator_list.append(CHECKS['http-request-headers'])
+                    if 'socket-options' not in options.disabled:
+                        validator_list.append(CHECKS['socket-options'])
+                    if 'pdf-doc-info' not in options.disabled:
+                        validator_list.append(CHECKS['pdf-doc-info'])
 
             if 'network-traffic-ports' not in options.disabled:
                 validator_list.append(CHECKS['network-traffic-ports'])
diff --git a/stix2validator/v21/__init__.py b/stix2validator/v21/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/stix2validator/v21/enums.py b/stix2validator/v21/enums.py
new file mode 100644
index 0000000..5d3d7e5
--- /dev/null
+++ b/stix2validator/v21/enums.py
@@ -0,0 +1,1949 @@
+"""STIX 2.0 open vocabularies and other lists
+"""
+
+import re
+
+import requests
+
+# Enumerations of the default values of STIX open vocabularies
+ATTACK_MOTIVATION_OV = [
+    "accidental",
+    "coercion",
+    "dominance",
+    "ideology",
+    "notoriety",
+    "organizational-gain",
+    "personal-gain",
+    "personal-satisfaction",
+    "revenge",
+    "unpredictable",
+]
+ATTACK_RESOURCE_LEVEL_OV = [
+    "individual",
+    "club",
+    "contest",
+    "team",
+    "organization",
+    "government",
+]
+IDENTITY_CLASS_OV = [
+    "individual",
+    "group",
+    "system",
+    "organization",
+    "class",
+    "unspecified",
+]
+INDICATOR_TYPE_OV = [
+    "anomalous-activity",
+    "anonymization",
+    "benign",
+    "compromised",
+    "malicious-activity",
+    "attribution",
+]
+INDUSTRY_SECTOR_OV = [
+    "agriculture",
+    "aerospace",
+    "automotive",
+    "communications",
+    "construction",
+    "defence",
+    "education",
+    "energy",
+    "entertainment",
+    "financial-services",
+    "government-national",
+    "government-regional",
+    "government-local",
+    "government-public-services",
+    "healthcare",
+    "hospitality-leisure",
+    "infrastructure",
+    "insurance",
+    "manufacturing",
+    "mining",
+    "non-profit",
+    "pharmaceuticals",
+    "retail",
+    "technology",
+    "telecommunications",
+    "transportation",
+    "utilities",
+]
+MALWARE_TYPE_OV = [
+    "adware",
+    "backdoor",
+    "bot",
+    "ddos",
+    "dropper",
+    "exploit-kit",
+    "keylogger",
+    "ransomware",
+    "remote-access-trojan",
+    "resource-exploitation",
+    "rogue-security-software",
+    "rootkit",
+    "screen-capture",
+    "spyware",
+    "trojan",
+    "virus",
+    "worm",
+]
+REGION_OV = [
+    "africa",
+    "eastern-africa",
+    "middle-africa",
+    "northern-africa",
+    "southern-africa",
+    "western-africa",
+    "americas",
+    "latin-america-caribbean",
+    "south-america",
+    "caribbean",
+    "central-america",
+    "northern-america",
+    "asia",
+    "central-asia",
+    "eastern-asia",
+    "southern-asia",
+    "south-eastern-asia",
+    "western-asia",
+    "europe eastern-europe",
+    "northern-europe",
+    "southern-europe",
+    "western-europe",
+    "oceana",
+    "australia-new-zealand",
+    "melanesia",
+    "micronesia",
+    "polynesia",
+    "antarctica",
+]
+REPORT_TYPE_OV = [
+    "threat-report",
+    "attack-pattern",
+    "campaign",
+    "identity",
+    "indicator",
+    "malware",
+    "observed-data",
+    "threat-actor",
+    "tool",
+    "vulnerability",
+]
+THREAT_ACTOR_TYPE_OV = [
+    "activist",
+    "competitor",
+    "crime-syndicate",
+    "criminal",
+    "hacker",
+    "insider-accidental",
+    "insider-disgruntled",
+    "nation-state",
+    "sensationalist",
+    "spy",
+    "terrorist",
+]
+THREAT_ACTOR_ROLE_OV = [
+    "agent",
+    "director",
+    "independent",
+    "infrastructure-architect",
+    "infrastructure-operator",
+    "malware-author",
+    "sponsor",
+]
+THREAT_ACTOR_SOPHISTICATION_OV = [
+    "none",
+    "minimal",
+    "intermediate",
+    "advanced",
+    "expert",
+    "innovator",
+    "strategic",
+]
+TOOL_TYPE_OV = [
+    "denial-of-service",
+    "exploitation",
+    "information-gathering",
+    "network-capture",
+    "credential-exploitation",
+    "remote-access",
+    "vulnerability-scanning",
+]
+HASH_ALGO_OV = [
+    "MD5",
+    "MD6",
+    "RIPEMD-160",
+    "SHA-1",
+    "SHA-224",
+    "SHA-256",
+    "SHA-384",
+    "SHA-512",
+    "SHA3-224",
+    "SHA3-256",
+    "SHA3-384",
+    "SHA3-512",
+    "ssdeep",
+    "WHIRLPOOL",
+]
+ENCRYPTION_ALGO_OV = [
+    "AES128-ECB",
+    "AES128-CBC",
+    "AES128-CFB",
+    "AES128-COFB",
+    "AES128-CTR",
+    "AES128-XTS",
+    "AES128-GCM",
+    "Salsa20",
+    "Salsa12",
+    "Salsa8",
+    "ChaCha20-Poly1305",
+    "ChaCha20",
+    "DES-CBC",
+    "3DES-CBC",
+    "DES-EBC",
+    "3DES-EBC",
+    "CAST128-CBC",
+    "CAST256-CBC",
+    "RSA",
+    "DSA",
+]
+WINDOWS_PEBINARY_TYPE_OV = [
+    "exe",
+    "dll",
+    "sys",
+]
+ACCOUNT_TYPE_OV = [
+    "unix",
+    "windows local",
+    "windows domain",
+    "ldap",
+    "tacacs",
+    "radius",
+    "nis",
+    "openid",
+    "facebook",
+    "skype",
+    "twitter",
+    "kavi",
+]
+
+
+# Dictionaries mapping object types to properties that use a given vocabulary
+ATTACK_MOTIVATION_USES = {
+    "intrusion-set": [
+        "primary_motivation",
+        "secondary_motivations",
+    ],
+    "threat-actor": [
+        "primary_motivation",
+        "secondary_motivations",
+        "personal_motivations",
+    ]
+}
+ATTACK_RESOURCE_LEVEL_USES = {
+    "intrusion-set": ["resource_level"],
+    "threat-actor": ["resource_level"],
+}
+IDENTITY_CLASS_USES = {
+    "identity": ["identity_class"],
+}
+INDICATOR_TYPE_USES = {
+    "indicator": ["indicator_types"],
+}
+INDUSTRY_SECTOR_USES = {
+    "identity": ["sectors"],
+}
+REGION_USES = {
+    "location": ["region"],
+}
+MALWARE_TYPE_USES = {
+    "malware": ["malware_types"],
+}
+REPORT_TYPE_USES = {
+    "report": ["report_types"],
+}
+THREAT_ACTOR_TYPE_USES = {
+    "threat-actor": ["threat_actor_types"],
+}
+THREAT_ACTOR_ROLE_USES = {
+    "threat-actor": ["roles"],
+}
+THREAT_ACTOR_SOPHISTICATION_USES = {
+    "threat-actor": ["sophistication"],
+}
+TOOL_TYPE_USES = {
+    "tool": ["tool_types"],
+}
+
+
+# List of default STIX object types
+TYPES = [
+    "attack-pattern",
+    "campaign",
+    "course-of-action",
+    "identity",
+    "indicator",
+    "intrusion-set",
+    "location",
+    "malware",
+    "note",
+    "observed-data",
+    "opinion",
+    "report",
+    "threat-actor",
+    "tool",
+    "vulnerability",
+    "bundle",
+    "relationship",
+    "sighting",
+    "language-content",
+    "marking-definition",
+]
+
+OBSERVABLE_TYPES = [
+    "artifact",
+    "autonomous-system",
+    "directory",
+    "domain-name",
+    "email-addr",
+    "email-message",
+    "file",
+    "ipv4-addr",
+    "ipv6-addr",
+    "mac-addr",
+    "mutex",
+    "network-traffic",
+    "process",
+    "software",
+    "url",
+    "user-account",
+    "windows-registry-key",
+    "x509-certificate",
+]
+
+# List of default marking definition types
+MARKING_DEFINITION_TYPES = [
+    "statement",
+    "tlp",
+]
+
+# List of object types which have a `kill-chain-phases` property
+KILL_CHAIN_PHASE_USES = [
+    "attack-pattern",
+    "indicator",
+    "malware",
+    "tool",
+]
+
+
+# Mapping of official STIX objects to their official properties
+PROPERTIES = {
+    "attack-pattern": [
+        'type',
+        'spec_version',
+        'id',
+        'created_by_ref',
+        'created',
+        'modified',
+        'revoked',
+        'labels',
+        'confidence',
+        'lang',
+        'external_references',
+        'object_marking_refs',
+        'granular_markings',
+        'name',
+        'description',
+        'kill_chain_phases',
+    ],
+    "campaign": [
+        'type',
+        'spec_version',
+        'id',
+        'created_by_ref',
+        'created',
+        'modified',
+        'revoked',
+        'labels',
+        'confidence',
+        'lang',
+        'external_references',
+        'object_marking_refs',
+        'granular_markings',
+        'name',
+        'description',
+        'aliases',
+        'first_seen',
+        'last_seen',
+        'objective',
+    ],
+    "course-of-action": [
+        'type',
+        'spec_version',
+        'id',
+        'created_by_ref',
+        'created',
+        'modified',
+        'revoked',
+        'labels',
+        'confidence',
+        'lang',
+        'external_references',
+        'object_marking_refs',
+        'granular_markings',
+        'name',
+        'description',
+        'action',
+    ],
+    "identity": [
+        'type',
+        'spec_version',
+        'id',
+        'created_by_ref',
+        'created',
+        'modified',
+        'revoked',
+        'labels',
+        'confidence',
+        'lang',
+        'external_references',
+        'object_marking_refs',
+        'granular_markings',
+        'name',
+        'description',
+        'roles',
+        'identity_class',
+        'sectors',
+        'contact_information',
+    ],
+    "indicator": [
+        'type',
+        'spec_version',
+        'id',
+        'created_by_ref',
+        'created',
+        'modified',
+        'revoked',
+        'labels',
+        'confidence',
+        'lang',
+        'external_references',
+        'object_marking_refs',
+        'granular_markings',
+        'name',
+        'description',
+        'indicator_types',
+        'pattern',
+        'valid_from',
+        'valid_until',
+        'kill_chain_phases',
+    ],
+    "intrusion-set": [
+        'type',
+        'spec_version',
+        'id',
+        'created_by_ref',
+        'created',
+        'modified',
+        'revoked',
+        'labels',
+        'confidence',
+        'lang',
+        'external_references',
+        'object_marking_refs',
+        'granular_markings',
+        'name',
+        'description',
+        'aliases',
+        'first_seen',
+        'last_seen',
+        'goals',
+        'resource_level',
+        'primary_motivation',
+        'secondary_motivations',
+    ],
+    "location": [
+        'type',
+        'spec_version',
+        'id',
+        'created_by_ref',
+        'created',
+        'modified',
+        'revoked',
+        'labels',
+        'confidence',
+        'lang',
+        'external_references',
+        'object_marking_refs',
+        'granular_markings',
+        'description',
+        'latittude',
+        'longitude',
+        'precision',
+        'region',
+        'country',
+        'administrative_area',
+        'city',
+        'code',
+        'postal_code',
+    ],
+    "malware": [
+        'type',
+        'spec_version',
+        'id',
+        'created_by_ref',
+        'created',
+        'modified',
+        'revoked',
+        'labels',
+        'confidence',
+        'lang',
+        'external_references',
+        'object_marking_refs',
+        'granular_markings',
+        'name',
+        'description',
+        'malware_types',
+        'kill_chain_phases',
+    ],
+    "note": [
+        'type',
+        'spec_version',
+        'id',
+        'created_by_ref',
+        'created',
+        'modified',
+        'revoked',
+        'labels',
+        'confidence',
+        'lang',
+        'external_references',
+        'object_marking_refs',
+        'granular_markings',
+        'abstract',
+        'content',
+        'authors',
+        'object_refs',
+    ],
+    "observed-data": [
+        'type',
+        'spec_version',
+        'id',
+        'created_by_ref',
+        'created',
+        'modified',
+        'revoked',
+        'labels',
+        'confidence',
+        'lang',
+        'external_references',
+        'object_marking_refs',
+        'granular_markings',
+        'first_observed',
+        'last_observed',
+        'number_observed',
+        'objects',
+    ],
+    "opinion": [
+        'type',
+        'spec_version',
+        'id',
+        'created_by_ref',
+        'created',
+        'modified',
+        'revoked',
+        'labels',
+        'confidence',
+        'lang',
+        'external_references',
+        'object_marking_refs',
+        'granular_markings',
+        'explanation',
+        'authors',
+        'object_refs',
+        'opinion',
+    ],
+    "report": [
+        'type',
+        'spec_version',
+        'id',
+        'created_by_ref',
+        'created',
+        'modified',
+        'revoked',
+        'labels',
+        'confidence',
+        'lang',
+        'external_references',
+        'object_marking_refs',
+        'granular_markings',
+        'name',
+        'description',
+        'report_types',
+        'published',
+        'object_refs',
+    ],
+    "threat-actor": [
+        'type',
+        'spec_version',
+        'id',
+        'created_by_ref',
+        'created',
+        'modified',
+        'revoked',
+        'labels',
+        'confidence',
+        'lang',
+        'external_references',
+        'object_marking_refs',
+        'granular_markings',
+        'name',
+        'description',
+        'threat_actor_types',
+        'aliases',
+        'roles',
+        'goals',
+        'sophistication',
+        'resource_level',
+        'primary_motivation',
+        'secondary_motivations',
+        'personal_motivations',
+    ],
+    "tool": [
+        'type',
+        'spec_version',
+        'id',
+        'created_by_ref',
+        'created',
+        'modified',
+        'revoked',
+        'labels',
+        'confidence',
+        'lang',
+        'external_references',
+        'object_marking_refs',
+        'granular_markings',
+        'name',
+        'description',
+        'tool_types',
+        'kill_chain_phases',
+        'tool_version'
+    ],
+    "vulnerability": [
+        'type',
+        'spec_version',
+        'id',
+        'created_by_ref',
+        'created',
+        'modified',
+        'revoked',
+        'labels',
+        'confidence',
+        'lang',
+        'external_references',
+        'object_marking_refs',
+        'granular_markings',
+        'name',
+        'description',
+    ],
+    "bundle": [
+        'type',
+        'id',
+        'objects',
+    ],
+    "relationship": [
+        'type',
+        'spec_version',
+        'id',
+        'created_by_ref',
+        'created',
+        'modified',
+        'revoked',
+        'labels',
+        'confidence',
+        'lang',
+        'external_references',
+        'object_marking_refs',
+        'granular_markings',
+        'relationship_type',
+        'description',
+        'source_ref',
+        'target_ref',
+        'start_time',
+        'stop_time',
+    ],
+    "sighting": [
+        'type',
+        'spec_version',
+        'id',
+        'created_by_ref',
+        'created',
+        'modified',
+        'revoked',
+        'labels',
+        'confidence',
+        'lang',
+        'external_references',
+        'object_marking_refs',
+        'granular_markings',
+        'first_seen',
+        'last_seen',
+        'count',
+        'sighting_of_ref',
+        'observed_data_refs',
+        'where_sighted_refs',
+        'summary',
+    ],
+    "language-content": [
+        'type',
+        'spec_version',
+        'id',
+        'created_by_ref',
+        'created',
+        'modified',
+        'revoked',
+        'labels',
+        'confidence',
+        'external_references',
+        'object_marking_refs',
+        'granular_markings',
+        'object_ref',
+        'object_modified',
+        'contents',
+    ],
+    "marking-definition": [
+        'type',
+        'spec_version',
+        'id',
+        'created_by_ref',
+        'created',
+        'lang',
+        'external_references',
+        'object_marking_refs',
+        'granular_markings',
+        'definition_type',
+        'definition',
+    ]
+}
+# Mappings of official Cyber Observable Objects to their official properties
+OBSERVABLE_PROPERTIES = {
+    'artifact': [
+        'type',
+        'extensions',
+        'mime_type',
+        'payload_bin',
+        'url',
+        'hashes',
+        'encryption_algorithm',
+        'decryption_key',
+    ],
+    'autonomous-system': [
+        'type',
+        'extensions',
+        'number',
+        'name',
+        'rir',
+    ],
+    'directory': [
+        'type',
+        'extensions',
+        'path',
+        'path_enc',
+        'created',
+        'modified',
+        'accessed',
+        'contains_refs',
+    ],
+    'domain-name': [
+        'type',
+        'extensions',
+        'value',
+        'resolves_to_refs',
+    ],
+    'email-addr': [
+        'type',
+        'extensions',
+        'value',
+        'display_name',
+        'belongs_to_ref',
+    ],
+    'email-message': [
+        'type',
+        'extensions',
+        'is_multipart',
+        'date',
+        'content_type',
+        'from_ref',
+        'sender_ref',
+        'to_refs',
+        'cc_refs',
+        'bcc_refs',
+        'subject',
+        'received_lines',
+        'additional_header_fields',
+        'body',
+        'body_multipart',
+        'raw_email_ref',
+    ],
+    'file': [
+        'type',
+        'extensions',
+        'hashes',
+        'size',
+        'name',
+        'name_enc',
+        'magic_number_hex',
+        'mime_type',
+        'created',
+        'modified',
+        'accessed',
+        'parent_directory_ref',
+        'contains_refs',
+        'content_ref',
+    ],
+    'ipv4-addr': [
+        'type',
+        'extensions',
+        'value',
+        'resolves_to_refs',
+        'belongs_to_refs',
+    ],
+    'ipv6-addr': [
+        'type',
+        'extensions',
+        'value',
+        'resolves_to_refs',
+        'belongs_to_refs',
+    ],
+    'mac-addr': [
+        'type',
+        'extensions',
+        'value',
+    ],
+    'mutex': [
+        'type',
+        'extensions',
+        'name',
+    ],
+    'network-traffic': [
+        'type',
+        'extensions',
+        'start',
+        'end',
+        'is_active',
+        'src_ref',
+        'dst_ref',
+        'src_port',
+        'dst_port',
+        'protocols',
+        'src_byte_count',
+        'dst_byte_count',
+        'src_packets',
+        'dst_packets',
+        'ipfix',
+        'src_payload_ref',
+        'dst_payload_ref',
+        'encapsulates_refs',
+        'encapsulated_by_ref',
+    ],
+    'process': [
+        'type',
+        'extensions',
+        'is_hidden',
+        'pid',
+        'created',
+        'cwd',
+        'command_line',
+        'environment_variables',
+        'opened_connection_refs',
+        'creator_user_ref',
+        'image_ref',
+        'parent_ref',
+        'child_refs',
+    ],
+    'software': [
+        'type',
+        'extensions',
+        'name',
+        'cpe',
+        'languages',
+        'vendor',
+        'version',
+    ],
+    'url': [
+        'type',
+        'extensions',
+        'value',
+    ],
+    'user-account': [
+        'type',
+        'extensions',
+        'user_id',
+        'credential',
+        'account_login',
+        'account_type',
+        'display_name',
+        'is_service_account',
+        'is_privileged',
+        'can_escalate_privs',
+        'is_disabled',
+        'account_created',
+        'account_expires',
+        'credential_last_changed',
+        'account_first_login',
+        'account_last_login',
+    ],
+    'windows-registry-key': [
+        'type',
+        'extensions',
+        'key',
+        'values',
+        'modified',
+        'creator_user_ref',
+        'number_of_subkeys',
+    ],
+    'x509-certificate': [
+        'type',
+        'extensions',
+        'is_self_signed',
+        'hashes',
+        'version',
+        'serial_number',
+        'signature_algorithm',
+        'issuer',
+        'validity_not_before',
+        'validity_not_after',
+        'subject',
+        'subject_public_key_modulus',
+        'subject_public_key_exponent',
+        'x509_v3_extensions',
+    ]
+}
+OBSERVABLE_EXTENSION_PROPERTIES = {
+    'archive-ext': [
+        'contains_refs',
+        'comment',
+    ],
+    'ntfs-ext': [
+        'sid',
+        'alternate_data_streams',
+    ],
+    'pdf-ext': [
+        'version',
+        'is_optimized',
+        'document_info_dict',
+        'pdfid0',
+        'pdfid1',
+    ],
+    'raster-image-ext': [
+        'image_height',
+        'image_width',
+        'bits_per_pixel',
+        'exif_tags',
+    ],
+    'windows-pebinary-ext': [
+        'pe_type',
+        'imphash',
+        'machine_hex',
+        'number_of_sections',
+        'time_date_stamp',
+        'pointer_to_symbol_table_hex',
+        'number_of_symbols',
+        'size_of_optional_header',
+        'characteristics_hex',
+        'file_header_hashes',
+        'optional_header',
+        'sections',
+    ],
+    'http-request-ext': [
+        'request_method',
+        'request_value',
+        'request_version',
+        'request_header',
+        'message_body_length',
+        'message_body_data_ref',
+    ],
+    'icmp-ext': [
+        'icmp_type_hex',
+        'icmp_code_hex',
+    ],
+    'socket-ext': [
+        'address_family',
+        'is_blocking',
+        'is_listening',
+        'options',
+        'socket_type',
+        'socket_descriptor',
+        'socket_handle',
+    ],
+    'tcp-ext': [
+        'src_flags_hex',
+        'dst_flags_hex',
+    ],
+    'windows-process-ext': [
+        'aslr_enabled',
+        'dep_enabled',
+        'priority',
+        'owner_sid',
+        'window_title',
+        'startup_info',
+        'integrity_level',
+    ],
+    'windows-service-ext': [
+        'service_name',
+        'descriptions',
+        'display_name',
+        'group_name',
+        'start_type',
+        'service_dll_refs',
+        'service_type',
+        'service_status',
+    ],
+    'unix-account-ext': [
+        'gid',
+        'groups',
+        'home_dir',
+        'shell',
+    ]
+}
+# Mappings of properties of embedded cyber observable types
+OBSERVABLE_EMBEDDED_PROPERTIES = {
+    'email-message': {
+        'body_multipart': [
+            'body',
+            'body_raw_ref',
+            'content_type',
+            'content_disposition',
+        ]
+    },
+    'windows-registry-key': {
+        'values': [
+            'name',
+            'data',
+            'data_type',
+        ]
+    },
+    'x509-certificate': {
+        'x509_v3_extensions': [
+            'basic_constraints',
+            'name_constraints',
+            'policy_constraints',
+            'key_usage',
+            'extended_key_usage',
+            'subject_key_identifier',
+            'authority_key_identifier',
+            'subject_alternative_name',
+            'issuer_alternative_name',
+            'subject_directory_attributes',
+            'crl_distribution_points',
+            'inhibit_any_policy',
+            'private_key_usage_period_not_before',
+            'private_key_usage_period_not_after',
+            'certificate_policies',
+            'policy_mappings',
+        ]
+    }
+}
+OBSERVABLE_EXTENSION_EMBEDDED_PROPERTIES = {
+    'ntfs-ext': {
+        'alternate_data_streams': [
+            'name',
+            'hashes',
+            'size',
+        ]
+    },
+    'windows-pebinary-ext': {
+        'optional_header': [
+            'magic_hex',
+            'major_linker_version',
+            'minor_linker_version',
+            'size_of_code',
+            'size_of_initialized_data',
+            'size_of_uninitialized_data',
+            'address_of_entry_point',
+            'base_of_code',
+            'base_of_data',
+            'image_base',
+            'section_alignment',
+            'file_alignment',
+            'major_os_version',
+            'minor_os_version',
+            'major_image_version',
+            'minor_image_version',
+            'major_subsystem_version',
+            'minor_subsystem_version',
+            'win32_version_value_hex',
+            'size_of_image',
+            'size_of_headers',
+            'checksum_hex',
+            'subsystem_hex',
+            'dll_characteristics_hex',
+            'size_of_stack_reserve',
+            'size_of_stack_commit',
+            'size_of_heap_reserve',
+            'size_of_heap_commit',
+            'loader_flags_hex',
+            'number_of_rva_and_sizes',
+            'hashes',
+        ],
+        'sections': [
+            'name',
+            'size',
+            'entropy',
+            'hashes',
+        ]
+    }
+}
+
+# Official cyber observable object extensions, per object type
+OBSERVABLE_EXTENSIONS = {
+    'file': [
+        'archive-ext',
+        'ntfs-ext',
+        'pdf-ext',
+        'raster-image-ext',
+        'windows-pebinary-ext',
+    ],
+    'network-traffic': [
+        'http-request-ext',
+        'icmp-ext',
+        'socket-ext',
+        'tcp-ext',
+    ],
+    'process': [
+        'windows-process-ext',
+        'windows-service-ext',
+    ],
+    'user-account': [
+        'unix-account-ext',
+    ]
+}
+
+# Maping of Observable Object properties that reference other Objects
+OBSERVABLE_PROP_REFS = {
+    'directory': {
+        'contains_refs': [
+            'file',
+            'directory',
+        ]
+    },
+    'domain-name': {
+        'resolves_to_refs': [
+            'ipv4-addr',
+            'ipv6-addr',
+            'domain-name',
+        ]
+    },
+    'email-addr': {
+        'belongs_to_ref': [
+            'user-account',
+        ]
+    },
+    'email-message': {
+        'from_ref': [
+            'email-addr',
+        ],
+        'sender_ref': [
+            'email-addr',
+        ],
+        'to_refs': [
+            'email-addr',
+        ],
+        'cc_refs': [
+            'email-addr',
+        ],
+        'bcc_refs': [
+            'email-addr',
+        ],
+        'raw_email_ref': [
+            'artifact',
+        ],
+        'body_multipart': {
+            'body_raw_ref': [
+                'artifact',
+                'file',
+            ]
+        }
+    },
+    'file': {
+        'parent_directory_ref': [
+            'directory',
+        ],
+        'content_ref': [
+            'artifact',
+        ],
+        'extensions': {
+            'archive-ext': {
+                'contains_refs': [
+                    'file',
+                ]
+            }
+        }
+    },
+    'ipv4-addr': {
+        'resolves_to_refs': [
+            'mac-addr',
+        ],
+        'belongs_to_refs': [
+            'autonomous-system',
+        ]
+    },
+    'ipv6-addr': {
+        'resolves_to_refs': [
+            'mac-addr',
+        ],
+        'belongs_to_refs': [
+            'autonomous-system',
+        ]
+    },
+    'network-traffic': {
+        'src_ref': [
+            'ipv4-addr',
+            'ipv6-addr',
+            'mac-addr',
+            'domain-name',
+        ],
+        'dst_ref': [
+            'ipv4-addr',
+            'ipv6-addr',
+            'mac-addr',
+            'domain-name',
+        ],
+        'src_payload_ref': [
+            'artifact',
+        ],
+        'dst_payload_ref': [
+            'artifact',
+        ],
+        'encapsulates_refs': [
+            'network-traffic',
+        ],
+        'encapsulated_by_ref': [
+            'network-traffic',
+        ],
+        'extensions': {
+            'http-request-ext': {
+                'message_body_data_ref': [
+                    'artifact',
+                ]
+            }
+        }
+    },
+    'process': {
+        'opened_connection_refs': [
+            'network-traffic',
+        ],
+        'creator_user_ref': [
+            'user-account',
+        ],
+        'image_ref': [
+            'file',
+        ],
+        'parent_ref': [
+            'process',
+        ],
+        'child_refs': [
+            'process',
+        ],
+        'extensions': {
+            'windows-service-ext': {
+                'service_dll_refs': [
+                    'file',
+                ]
+            }
+        }
+    },
+    'windows-registry-key': {
+        'creator_user_ref': [
+            'user-account',
+        ]
+    }
+}
+
+# Cyber Observable Object properties of the dictionary type whose keys do not
+# fall under the requirement to be lowercase.
+OBSERVABLE_DICT_KEY_EXCEPTIONS = [
+    'hashes',
+    'file_header_hashes',
+    'request_header',
+    'additional_header_fields',
+    'document_info_dict',
+    'exif_tags',
+    'ipfix',
+    'options',
+    'environment_variables',
+    'key',
+    'startup_info',
+]
+
+# Reserved properties and objects
+RESERVED_PROPERTIES = [
+    'severity',
+    'action',
+    'usernames',
+    'phone_numbers',
+]
+RESERVED_OBJECTS = [
+    'incident',
+    'infrastructure',
+]
+OBSERVABLE_RESERVED_OBJECTS = [
+    'action',
+]
+
+
+NON_SDOS = [
+    'bundle',
+    'language-content',
+    'marking-definition',
+    'sighting',
+    'relationship',
+]
+
+# 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': {
+        'targets': [
+            'location',
+            'vulnerability',
+            'identity',
+        ],
+        'uses': [
+            'malware',
+            'tool',
+        ]
+    },
+    'campaign': {
+        'attributed-to': [
+            'intrusion-set',
+            'threat-actor',
+        ],
+        'originates-from': 'location',
+        'targets': [
+            'location',
+            'identity',
+            'vulnerability',
+        ],
+        'uses': [
+            'attack-pattern',
+            'malware',
+            'tool',
+        ]
+    },
+    'course-of-action': {
+        'mitigates': [
+            'attack-pattern',
+            'malware',
+            'tool',
+            'vulnerability',
+        ]
+    },
+    'identity': {
+        'located-at': 'location',
+    },
+    'indicator': {
+        'indicates': [
+            'attack-pattern',
+            'campaign',
+            'intrusion-set',
+            'malware',
+            'threat-actor',
+            'tool',
+        ],
+    },
+    'intrusion-set': {
+        'attributed-to': 'threat-actor',
+        'originates-from': 'location',
+        'targets': [
+            'identity',
+            'location',
+            'vulnerability',
+        ],
+        'uses': [
+            'attack-pattern',
+            'malware',
+            'tool',
+        ],
+    },
+    'malware': {
+        'originates-from': 'location',
+        'targets': [
+            'identity',
+            'location',
+            'vulnerability',
+        ],
+        'uses': 'tool',
+        'variant-of': 'malware',
+    },
+    'threat-actor': {
+        'attributed-to': 'identity',
+        'impersonates': 'identity',
+        'located-at': 'location',
+        'targets': [
+            'identity',
+            'location',
+            'vulnerability',
+        ],
+        'uses': [
+            'attack-pattern',
+            'malware',
+            'tool',
+        ]
+    },
+    'tool': {
+        'targets': [
+            'identity',
+            'location',
+            'vulnerability',
+        ]
+    }
+}
+
+
+# Mapping of official STIX objects to their timestamp properties
+# Common timestamp properties ('created', 'modified') are already included
+TIMESTAMP_PROPERTIES = {
+    'campaign': [
+        'first_seen',
+        'last_seen',
+    ],
+    'indicator': [
+        'valid_from',
+        'valid_until',
+    ],
+    'intrusion-set': [
+        'first_seen',
+        'last_seen',
+    ],
+    'language-content': [
+        'object_modified'
+    ],
+    'observed-data': [
+        'first_observed',
+        'last_observed',
+    ],
+    'report': [
+        'published',
+    ],
+    'relationship': [
+        'start_time',
+        'stop_time',
+    ],
+    'sighting': [
+        'first_seen',
+        'last_seen',
+    ],
+}
+
+
+# Mapping of official STIX Cyber Observable objects to their timestamp properties
+TIMESTAMP_OBSERVABLE_PROPERTIES = {
+    'directory': [
+        'created',
+        'modified',
+        'accessed',
+    ],
+    'email-message': [
+        'date',
+    ],
+    'file': [
+        'created',
+        'modified',
+        'accessed',
+    ],
+    'network-traffic': [
+        'start',
+        'end',
+    ],
+    'process': [
+        'created',
+    ],
+    'user-account': [
+        'account_created',
+        'account_expires',
+        'credential_last_changed',
+        'account_first_login',
+        'account_last_login',
+    ],
+    'windows-registry-key': [
+        'modified',
+    ],
+    'x509-certificate': [
+        'validity_not_before',
+        'validity_not_after',
+    ],
+}
+
+# Mapping of STIX Cyber Observable object to their timestamp-typed embedded properties
+TIMESTAMP_EMBEDDED_PROPERTIES = {
+    'file': {
+        'extensions': [
+            'time_date_stamp',
+        ],
+    },
+    'x509-certificate': {
+        'x509_v3_extensions': [
+            'private_key_usage_period_not_before',
+            'private_key_usage_period_not_after',
+        ],
+    },
+}
+
+# Mapping of STIX Object timestamp properties with a comparison requirement
+# E.g. MUST be greater than or equal to
+# created/modified are already checked
+TIMESTAMP_COMPARE = {
+    "campaign": [
+        ('last_seen', 'ge', 'first_seen'),
+    ],
+    "indicator": [
+        ('valid_until', 'gt', 'valid_from'),
+    ],
+    "intrusion-set": [
+        ('last_seen', 'ge', 'first_seen'),
+    ],
+    "observed-data": [
+        ('last_observed', 'ge', 'first_observed'),
+    ],
+    "relationship": [
+        ('stop_time', 'gt', 'start_time'),
+    ],
+    "sighting": [
+        ('last_seen', 'gt', 'first_seen'),
+    ],
+}
+
+# Mapping of STIX Object timestamp properties with a comparison requirement
+TIMESTAMP_COMPARE_OBSERVABLE = {
+    "network-traffic": [
+        ('end', 'gt', 'start'),
+    ],
+}
+
+# Mapping of official STIX objects to their open-vocab properties
+VOCAB_PROPERTIES = {
+    "identity": [
+        'identity_class',
+        'sectors',
+    ],
+    "indicator": [
+        'indicator_types',
+    ],
+    "intrusion-set": [
+        'resource_level',
+        'primary_motivation',
+        'secondary_motivations',
+    ],
+    "location": [
+        'region',
+    ],
+    "malware": [
+        'malware_types',
+    ],
+    "report": [
+        'report_types',
+    ],
+    "threat-actor": [
+        'threat_actor_types',
+        'roles',
+        'sophistication',
+        'resource_level',
+        'primary_motivation',
+        'secondary_motivations',
+        'personal_motivations',
+    ],
+    "tool": [
+        'tool_types',
+    ],
+    "marking-definition": [
+        'definition_type',
+    ]
+}
+
+# Mapping of check code numbers to names
+CHECK_CODES = {
+    '1': 'format-checks',
+    '101': 'custom-prefix',
+    '102': 'custom-prefix-lax',
+    '111': 'open-vocab-format',
+    '121': 'kill-chain-names',
+    '141': 'observable-object-keys',
+    '142': 'observable-dictionary-keys',
+    '149': 'windows-process-priority-format',
+    '150': 'hash-length',
+    '2': 'approved-values',
+    '201': 'marking-definition-type',
+    '202': 'relationship-types',
+    '203': 'duplicate-ids',
+    '210': 'all-vocabs',
+    '211': 'attack-motivation',
+    '212': 'attack-resource-level',
+    '213': 'identity-class',
+    '214': 'indicator-types',
+    '215': 'industry-sector',
+    '216': 'malware-types',
+    '218': 'report-types',
+    '219': 'threat-actor-types',
+    '220': 'threat-actor-role',
+    '221': 'threat-actor-sophistication',
+    '222': 'tool-types',
+    '223': 'region',
+    '241': 'hash-algo',
+    '242': 'encryption-algo',
+    '243': 'windows-pebinary-type',
+    '244': 'account-type',
+    '270': 'all-external-sources',
+    '271': 'mime-type',
+    '272': 'protocols',
+    '273': 'ipfix',
+    '274': 'http-request-headers',
+    '275': 'socket-options',
+    '276': 'pdf-doc-info',
+    '277': 'countries',
+    '301': 'network-traffic-ports',
+    '302': 'extref-hashes',
+}
+
+
+def media_types():
+    """Return a list of the IANA Media (MIME) Types, or an empty list if the
+    IANA website is unreachable.
+    Store it as a function attribute so that we only build the list once.
+    """
+    if not hasattr(media_types, 'typelist'):
+        tlist = []
+        categories = [
+            'application',
+            'audio',
+            'font',
+            'image',
+            'message',
+            'model',
+            'multipart',
+            'text',
+            'video'
+        ]
+        for cat in categories:
+            try:
+                data = requests.get('http://www.iana.org/assignments/'
+                                    'media-types/%s.csv' % cat)
+            except requests.exceptions.RequestException:
+                return []
+
+            types = []
+            for line in data.iter_lines():
+                if line:
+                    line = line.decode("utf-8")
+                    if line.count(',') > 0:
+                        reg_template = line.split(',')[1]
+                        if reg_template:
+                            types.append(reg_template)
+                        else:
+                            types.append(cat + '/' + line.split(',')[0])
+
+            tlist.extend(types)
+        media_types.typelist = tlist
+    return media_types.typelist
+
+
+def char_sets():
+    """Return a list of the IANA Character Sets, or an empty list if the
+    IANA website is unreachable.
+    Store it as a function attribute so that we only build the list once.
+    """
+    if not hasattr(char_sets, 'setlist'):
+        clist = []
+        try:
+            data = requests.get('http://www.iana.org/assignments/character-'
+                                'sets/character-sets-1.csv')
+        except requests.exceptions.RequestException:
+            return []
+
+        for line in data.iter_lines():
+            if line:
+                line = line.decode("utf-8")
+                if line.count(',') > 0:
+                    vals = line.split(',')
+                    if vals[0]:
+                        clist.append(vals[0])
+                    else:
+                        clist.append(vals[1])
+
+        char_sets.setlist = clist
+    return char_sets.setlist
+
+
+def protocols():
+    """Return a list of values from the IANA Service Name and Transport
+    Protocol Port Number Registry, or an empty list if the IANA website is
+    unreachable.
+    Store it as a function attribute so that we only build the list once.
+    """
+    if not hasattr(protocols, 'protlist'):
+        plist = []
+        try:
+            data = requests.get('http://www.iana.org/assignments/service-names'
+                                '-port-numbers/service-names-port-numbers.csv')
+        except requests.exceptions.RequestException:
+            return []
+
+        for line in data.iter_lines():
+            if line:
+                line = line.decode("utf-8")
+                if line.count(',') > 0:
+                    vals = line.split(',')
+                    if vals[0]:
+                        plist.append(vals[0])
+                    if len(vals) > 2 and vals[2] and vals[2] not in plist:
+                        plist.append(vals[2])
+
+        plist.append('ipv4')
+        plist.append('ipv6')
+        plist.append('ssl')
+        plist.append('tls')
+        plist.append('dns')
+        protocols.protlist = plist
+    return protocols.protlist
+
+
+def ipfix():
+    """Return a list of values from the list of IANA IP Flow Information Export
+    (IPFIX) Entities, or an empty list if the IANA website is unreachable.
+    Store it as a function attribute so that we only build the list once.
+    """
+    if not hasattr(ipfix, 'ipflist'):
+        ilist = []
+        try:
+            data = requests.get('http://www.iana.org/assignments/ipfix/ipfix-'
+                                'information-elements.csv')
+        except requests.exceptions.RequestException:
+            return []
+
+        for line in data.iter_lines():
+            if line:
+                line = line.decode("utf-8")
+                if re.match(r'^\d+(,[a-zA-Z0-9]+){2},', line):
+                    vals = line.split(',')
+                    if vals[1]:
+                        ilist.append(vals[1])
+
+        ipfix.ipflist = ilist
+    return ipfix.ipflist
+
+
+SOCKET_OPTIONS = [
+    'SO_ACCEPTCONN',
+    'SO_BINDTODEVICE',
+    'SO_BROADCAST',
+    'SO_BSDCOMPAT',
+    'SO_DEBUG',
+    'SO_DOMAIN',
+    'SO_ERROR',
+    'SO_DONTROUTE',
+    'SO_KEEPALIVE',
+    'SO_LINGER',
+    'SO_MARK',
+    'SO_OOBINLINE',
+    'SO_PASSCRED',
+    'SO_PEERCRED',
+    'SO_PRIORITY',
+    'SO_PROTOCOL',
+    'SO_RCVBUF',
+    'SO_RCVBUFFORCE',
+    'SO_RCVLOWAT',
+    'SO_SNDLOWAT',
+    'SO_RCVTIMEO',
+    'SO_SNDTIMEO',
+    'SO_REUSEADDR',
+    'SO_SNDBUF',
+    'SO_SNDBUFFORCE',
+    'SO_TIMESTAMP',
+    'SO_TYPE'
+]
+
+PDF_DID = [
+    'Title',
+    'Author',
+    'Subject',
+    'Keywords',
+    'Creator',
+    'Producer',
+    'CreationDate',
+    'ModDate',
+    'Trapped'
+]
+
+LANG_CODES = [
+    'af', 'af-ZA', 'ar', 'ar-AE', 'ar-BH', 'ar-DZ', 'ar-EG', 'ar-IQ', 'ar-JO',
+    'ar-KW', 'ar-LB', 'ar-LY', 'ar-MA', 'ar-OM', 'ar-QA', 'ar-SA', 'ar-SY',
+    'ar-TN', 'ar-YE', 'az', 'az-AZ', 'az-Cyrl-AZ', 'be', 'be-BY', 'bg', 'bg-BG',
+    'bs-BA', 'ca', 'ca-ES', 'cs', 'cs-CZ', 'cy', 'cy-GB', 'da', 'da-DK', 'de',
+    'de-AT', 'de-CH', 'de-DE', 'de-LI', 'de-LU', 'dv', 'dv-MV', 'el', 'el-GR',
+    'en', 'en-AU', 'en-BZ', 'en-CA', 'en-CB', 'en-GB', 'en-IE', 'en-JM',
+    'en-NZ', 'en-PH', 'en-TT', 'en-US', 'en-ZA', 'en-ZW', 'eo', 'es', 'es-AR',
+    'es-BO', 'es-CL', 'es-CO', 'es-CR', 'es-DO', 'es-EC', 'es-ES', 'es-GT',
+    'es-HN', 'es-MX', 'es-NI', 'es-PA', 'es-PE', 'es-PR', 'es-PY', 'es-SV',
+    'es-UY', 'es-VE', 'et', 'et-EE', 'eu', 'eu-ES', 'fa', 'fa-IR', 'fi',
+    'fi-FI', 'fo', 'fo-FO', 'fr', 'fr-BE', 'fr-CA', 'fr-CH', 'fr-FR', 'fr-LU',
+    'fr-MC', 'gl', 'gl-ES', 'gu', 'gu-IN', 'he', 'he-IL', 'hi', 'hi-IN', 'hr',
+    'hr-BA', 'hr-HR', 'hu', 'hu-HU', 'hy', 'hy-AM', 'id', 'id-ID', 'is',
+    'is-IS', 'it', 'it-CH', 'it-IT', 'ja', 'ja-JP', 'ka', 'ka-GE', 'kk',
+    'kk-KZ', 'kn', 'kn-IN', 'ko', 'ko-KR', 'kok', 'kok-IN', 'ky', 'ky-KG',
+    'lt', 'lt-LT', 'lv', 'lv-LV', 'mi', 'mi-NZ', 'mk', 'mk-MK', 'mn', 'mn-MN',
+    'mr', 'mr-IN', 'ms', 'ms-BN', 'ms-MY', 'mt', 'mt-MT', 'nb', 'nb-NO', 'nl',
+    'nl-BE', 'nl-NL', 'nn-NO', 'ns', 'ns-ZA', 'pa', 'pa-IN', 'pl', 'pl-PL',
+    'ps', 'ps-AR', 'pt', 'pt-BR', 'pt-PT', 'qu', 'qu-BO', 'qu-EC', 'qu-PE',
+    'ro', 'ro-RO', 'ru', 'ru-RU', 'sa', 'sa-IN', 'se', 'se-FI', 'se-NO',
+    'se-SE', 'sk', 'sk-SK', 'sl', 'sl-SI', 'sq', 'sq-AL', 'sr-BA', 'sr-Cyrl-BA',
+    'sr-SP', 'sr-Cyrl-SP', 'sv', 'sv-FI', 'sv-SE', 'sw', 'sw-KE', 'syr',
+    'syr-SY', 'ta', 'ta-IN', 'te', 'te-IN', 'th', 'th-TH', 'tl', 'tl-PH', 'tn',
+    'tn-ZA', 'tr', 'tr-TR', 'tt', 'tt-RU', 'ts', 'uk', 'uk-UA', 'ur', 'ur-PK',
+    'uz', 'uz-UZ', 'uz-Cyrl-UZ', 'vi', 'vi-VN', 'xh', 'xh-ZA', 'zh', 'zh-CN',
+    'zh-HK', 'zh-MO', 'zh-SG', 'zh-TW', 'zu', 'zu-ZA',
+]
+
+SOFTWARE_LANG_CODES = [
+    'aar', 'abk', 'ace', 'ach', 'ada', 'ady', 'afa', 'afh', 'afr', 'ain',
+    'aka', 'akk', 'alb', 'sqi', 'ale', 'alg', 'alt', 'amh', 'ang', 'anp',
+    'apa', 'ara', 'arc', 'arg', 'arm', 'hye', 'arn', 'arp', 'art', 'arw',
+    'asm', 'ast', 'ath', 'aus', 'ava', 'ave', 'awa', 'aym', 'aze', 'bad',
+    'bai', 'bak', 'bal', 'bam', 'ban', 'baq', 'eus', 'bas', 'bat', 'bej',
+    'bel', 'bem', 'ben', 'ber', 'bho', 'bih', 'bik', 'bin', 'bis', 'bla',
+    'bnt', 'tib', 'bod', 'bos', 'bra', 'bre', 'btk', 'bua', 'bug', 'bul',
+    'bur', 'mya', 'byn', 'cad', 'cai', 'car', 'cat', 'cau', 'ceb', 'cel',
+    'cze', 'ces', 'cha', 'chb', 'che', 'chg', 'chi', 'zho', 'chk', 'chm',
+    'chn', 'cho', 'chp', 'chr', 'chu', 'chv', 'chy', 'cmc', 'cop', 'cor',
+    'cos', 'cpe', 'cpf', 'cpp', 'cre', 'crh', 'crp', 'csb', 'cus', 'wel',
+    'cym', 'cze', 'ces', 'dak', 'dan', 'dar', 'day', 'del', 'den', 'ger',
+    'deu', 'dgr', 'din', 'div', 'doi', 'dra', 'dsb', 'dua', 'dum', 'dut',
+    'nld', 'dyu', 'dzo', 'efi', 'egy', 'eka', 'gre', 'ell', 'elx', 'eng',
+    'enm', 'epo', 'est', 'baq', 'eus', 'ewe', 'ewo', 'fan', 'fao', 'per',
+    'fas', 'fat', 'fij', 'fil', 'fin', 'fiu', 'fon', 'fre', 'fra', 'frm',
+    'fro', 'frr', 'frs', 'fry', 'ful', 'fur', 'gaa', 'gay', 'gba', 'gem',
+    'geo', 'kat', 'ger', 'deu', 'gez', 'gil', 'gla', 'gle', 'glg', 'glv',
+    'gmh', 'goh', 'gon', 'gor', 'got', 'grb', 'grc', 'gre', 'ell', 'grn',
+    'gsw', 'guj', 'gwi', 'hai', 'hat', 'hau', 'haw', 'heb', 'her', 'hil',
+    'him', 'hin', 'hit', 'hmn', 'hmo', 'hrv', 'hsb', 'hun', 'hup', 'arm',
+    'hye', 'iba', 'ibo', 'ice', 'isl', 'ido', 'iii', 'ijo', 'iku', 'ile',
+    'ilo', 'ina', 'inc', 'ind', 'ine', 'inh', 'ipk', 'ira', 'iro', 'ice',
+    'isl', 'ita', 'jav', 'jbo', 'jpn', 'jpr', 'jrb', 'kaa', 'kab', 'kac',
+    'kal', 'kam', 'kan', 'kar', 'kas', 'geo', 'kat', 'kau', 'kaw', 'kaz',
+    'kbd', 'kha', 'khi', 'khm', 'kho', 'kik', 'kin', 'kir', 'kmb', 'kok',
+    'kom', 'kon', 'kor', 'kos', 'kpe', 'krc', 'krl', 'kro', 'kru', 'kua',
+    'kum', 'kur', 'kut', 'lad', 'lah', 'lam', 'lao', 'lat', 'lav', 'lez',
+    'lim', 'lin', 'lit', 'lol', 'loz', 'ltz', 'lua', 'lub', 'lug', 'lui',
+    'lun', 'luo', 'lus', 'mac', 'mkd', 'mad', 'mag', 'mah', 'mai', 'mak',
+    'mal', 'man', 'mao', 'mri', 'map', 'mar', 'mas', 'may', 'msa', 'mdf',
+    'mdr', 'men', 'mga', 'mic', 'min', 'mis', 'mac', 'mkd', 'mkh', 'mlg',
+    'mlt', 'mnc', 'mni', 'mno', 'moh', 'mon', 'mos', 'mao', 'mri', 'may',
+    'msa', 'mul', 'mun', 'mus', 'mwl', 'mwr', 'bur', 'mya', 'myn', 'myv',
+    'nah', 'nai', 'nap', 'nau', 'nav', 'nbl', 'nde', 'ndo', 'nds', 'nep',
+    'new', 'nia', 'nic', 'niu', 'dut', 'nld', 'nno', 'nob', 'nog', 'non',
+    'nor', 'nqo', 'nso', 'nub', 'nwc', 'nya', 'nym', 'nyn', 'nyo', 'nzi',
+    'oci', 'oji', 'ori', 'orm', 'osa', 'oss', 'ota', 'oto', 'paa', 'pag',
+    'pal', 'pam', 'pan', 'pap', 'pau', 'peo', 'per', 'fas', 'phi', 'phn',
+    'pli', 'pol', 'pon', 'por', 'pra', 'pro', 'pus', 'qaa-qtz', 'que', 'raj',
+    'rap', 'rar', 'roa', 'roh', 'rom', 'rum', 'ron', 'run', 'rup', 'rus',
+    'sad', 'sag', 'sah', 'sai', 'sal', 'sam', 'san', 'sas', 'sat', 'scn',
+    'sco', 'sel', 'sem', 'sga', 'sgn', 'shn', 'sid', 'sin', 'sio', 'sit',
+    'sla', 'slo', 'slk', 'slv', 'sma', 'sme', 'smi', 'smj', 'smn', 'smo',
+    'sms', 'sna', 'snd', 'snk', 'sog', 'som', 'son', 'sot', 'spa', 'alb',
+    'sqi', 'srd', 'srn', 'srp', 'srr', 'ssa', 'ssw', 'suk', 'sun', 'sus',
+    'sux', 'swa', 'swe', 'syc', 'syr', 'tah', 'tai', 'tam', 'tat', 'tel',
+    'tem', 'ter', 'tet', 'tgk', 'tgl', 'tha', 'tib', 'bod', 'tig', 'tir',
+    'tiv', 'tkl', 'tlh', 'tli', 'tmh', 'tog', 'ton', 'tpi', 'tsi', 'tsn',
+    'tso', 'tuk', 'tum', 'tup', 'tur', 'tut', 'tvl', 'twi', 'tyv', 'udm',
+    'uga', 'uig', 'ukr', 'umb', 'und', 'urd', 'uzb', 'vai', 'ven', 'vie',
+    'vol', 'vot', 'wak', 'wal', 'war', 'was', 'wel', 'cym', 'wen', 'wln',
+    'wol', 'xal', 'xho', 'yao', 'yap', 'yid', 'yor', 'ypk', 'zap', 'zbl',
+    'zen', 'zgh', 'zha', 'chi', 'zho', 'znd', 'zul', 'zun', 'zxx', 'zza'
+]
+
+COUNTRY_CODES = [
+    'AC', 'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AN', 'AO', 'AP', 'AQ',
+    'AR', 'AS', 'AT', 'AU', 'AW', 'AX', 'AZ', 'BA', 'BB', 'BD', 'BE', 'BF',
+    'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN', 'BO', 'BQ', 'BR', 'BS', 'BT',
+    'BU', 'BV', 'BW', 'BX', 'BY', 'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH',
+    'CI', 'CK', 'CL', 'CM', 'CN', 'CO', 'CP', 'CR', 'CS', 'CT', 'CU', 'CV',
+    'CW', 'CX', 'CY', 'CZ', 'DD', 'DE', 'DG', 'DJ', 'DK', 'DM', 'DO', 'DY',
+    'DZ', 'EA', 'EC', 'EE', 'EF', 'EG', 'EH', 'EM', 'EP', 'ER', 'ES', 'ET',
+    'EU', 'EV', 'EW', 'EZ', 'FI', 'FJ', 'FK', 'FL', 'FM', 'FO', 'FQ', 'FR',
+    'FX', 'GA', 'GB', 'GC', 'GD', 'GE', 'GF', 'GG', 'GH', 'GI', 'GL', 'GM',
+    'GN', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GW', 'GY', 'HK', 'HM', 'HN',
+    'HR', 'HT', 'HU', 'HV', 'IB', 'IC', 'ID', 'IE', 'IL', 'IM', 'IN', 'IO',
+    'IQ', 'IR', 'IS', 'IT', 'JA', 'JE', 'JM', 'JO', 'JP', 'JT', 'KE', 'KG',
+    'KH', 'KI', 'KM', 'KN', 'KP', 'KR', 'KW', 'KY', 'KZ', 'LA', 'LB', 'LC',
+    'LF', 'LI', 'LK', 'LR', 'LS', 'LT', 'LU', 'LV', 'LY', 'MA', 'MC', 'MD',
+    'ME', 'MF', 'MG', 'MH', 'MI', 'MK', 'ML', 'MM', 'MN', 'MO', 'MP', 'MQ',
+    'MR', 'MS', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ', 'NA', 'NC', 'NE',
+    'NF', 'NG', 'NH', 'NI', 'NL', 'NO', 'NP', 'NQ', 'NR', 'NT', 'NU', 'NZ',
+    'OA', 'OM', 'PA', 'PC', 'PE', 'PF', 'PG', 'PH', 'PI', 'PK', 'PL', 'PM',
+    'PN', 'PR', 'PS', 'PT', 'PU', 'PW', 'PY', 'PZ', 'QA', 'RA', 'RB', 'RC',
+    'RE', 'RH', 'RI', 'RL', 'RM', 'RN', 'RO', 'RP', 'RS', 'RU', 'RW', 'SA',
+    'SB', 'SC', 'SD', 'SE', 'SF', 'SG', 'SH', 'SI', 'SJ', 'SK', 'SL', 'SM',
+    'SN', 'SO', 'SR', 'SS', 'ST', 'SU', 'SV', 'SX', 'SY', 'SZ', 'TA', 'TC',
+    'TD', 'TF', 'TG', 'TH', 'TJ', 'TK', 'TL', 'TM', 'TN', 'TO', 'TP', 'TR',
+    'TT', 'TV', 'TW', 'TZ', 'UA', 'UG', 'UK', 'UM', 'UN', 'US', 'UY', 'UZ',
+    'VA', 'VC', 'VD', 'VE', 'VG', 'VI', 'VN', 'VU', 'WF', 'WG', 'WK', 'WL',
+    'WO', 'WS', 'WV', 'YD', 'YE', 'YT', 'YU', 'YV', 'ZA', 'ZM', 'ZR', 'ZW',
+]
+
+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/v21/errors.py b/stix2validator/v21/errors.py
new file mode 100644
index 0000000..2c63f23
--- /dev/null
+++ b/stix2validator/v21/errors.py
@@ -0,0 +1,19 @@
+from collections import deque
+
+from jsonschema import exceptions as schema_exceptions
+
+from ..errors import (NoJSONFileFoundError, PatternError, SchemaError,  # noqa
+                      SchemaInvalidError, ValidationError, pretty_error)
+from .enums import CHECK_CODES
+
+
+class JSONError(schema_exceptions.ValidationError):
+    """Wrapper for errors thrown by iter_errors() in the jsonschema module.
+    Makes errors generated by our functions look like those from jsonschema.
+    """
+    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_id]))
diff --git a/stix2validator/v21/musts.py b/stix2validator/v21/musts.py
new file mode 100644
index 0000000..41dd81a
--- /dev/null
+++ b/stix2validator/v21/musts.py
@@ -0,0 +1,567 @@
+"""Mandatory (MUST) requirement checking functions
+"""
+
+import operator
+import re
+
+from dateutil import parser
+from six import string_types
+from stix2patterns.pattern import Pattern
+from stix2patterns.validator import run_validator as pattern_validator
+
+from . import enums
+from ..errors import PatternError
+from ..output import info
+from ..util import cyber_observable_check, has_cyber_observable_data
+from .errors import JSONError
+
+TYPE_FORMAT_RE = re.compile(r'^\-?[a-z0-9]+(-[a-z0-9]+)*\-?$')
+PROPERTY_FORMAT_RE = re.compile(r'^[a-z0-9_]{3,250}$')
+CUSTOM_TYPE_PREFIX_RE = re.compile(r"^x\-.+\-.+$")
+CUSTOM_TYPE_LAX_PREFIX_RE = re.compile(r"^x\-.+$")
+CUSTOM_PROPERTY_PREFIX_RE = re.compile(r"^x_.+_.+$")
+CUSTOM_PROPERTY_LAX_PREFIX_RE = re.compile(r"^x_.+$")
+
+
+def timestamp(instance):
+    """Ensure timestamps contain sane months, days, hours, minutes, seconds.
+    """
+    ts_re = re.compile(r"^[0-9]{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]|60)(\.[0-9]+)?Z$")
+    timestamp_props = ['created', 'modified']
+    if instance['type'] in enums.TIMESTAMP_PROPERTIES:
+        timestamp_props += enums.TIMESTAMP_PROPERTIES[instance['type']]
+
+    for tprop in timestamp_props:
+        if tprop in instance and ts_re.match(instance[tprop]):
+            # Don't raise an error if schemas will catch it
+            try:
+                parser.parse(instance[tprop])
+            except ValueError as e:
+                yield JSONError("'%s': '%s' is not a valid timestamp: %s"
+                                % (tprop, instance[tprop], str(e)), instance['id'])
+
+    if has_cyber_observable_data(instance):
+        for key, obj in instance['objects'].items():
+            if 'type' not in obj:
+                continue
+            if obj['type'] in enums.TIMESTAMP_OBSERVABLE_PROPERTIES:
+                for tprop in enums.TIMESTAMP_OBSERVABLE_PROPERTIES[obj['type']]:
+                    if tprop in obj and ts_re.match(obj[tprop]):
+                        # Don't raise an error if schemas will catch it
+                        try:
+                            parser.parse(obj[tprop])
+                        except ValueError as e:
+                            yield JSONError("'%s': '%s': '%s' is not a valid timestamp: %s"
+                                            % (obj['type'], tprop, obj[tprop], str(e)), instance['id'])
+            if obj['type'] in enums.TIMESTAMP_EMBEDDED_PROPERTIES:
+                for embed in enums.TIMESTAMP_EMBEDDED_PROPERTIES[obj['type']]:
+                    if embed in obj:
+                        for tprop in enums.TIMESTAMP_EMBEDDED_PROPERTIES[obj['type']][embed]:
+                            if embed == 'extensions':
+                                for ext in obj[embed]:
+                                    if tprop in obj[embed][ext] and ts_re.match(obj[embed][ext][tprop]):
+                                        try:
+                                            parser.parse(obj[embed][ext][tprop])
+                                        except ValueError as e:
+                                            yield JSONError("'%s': '%s': '%s': '%s' is not a valid timestamp: %s"
+                                                            % (obj['type'], ext, tprop, obj[embed][ext][tprop], str(e)), instance['id'])
+                            elif tprop in obj[embed] and ts_re.match(obj[embed][tprop]):
+                                try:
+                                    parser.parse(obj[embed][tprop])
+                                except ValueError as e:
+                                    yield JSONError("'%s': '%s': '%s' is not a valid timestamp: %s"
+                                                    % (obj['type'], tprop, obj[embed][tprop], str(e)), instance['id'])
+
+
+def get_comparison_string(op):
+    """Return a string explaining the given comparison operator.
+    """
+    if op == 'gt':
+        return 'later than'
+    elif op == 'ge':
+        return 'later than or equal to'
+    else:
+        raise ValueError('Unknown operator: {}'.format(op))
+
+
+def timestamp_compare(instance):
+    """Ensure timestamp properties with a comparison requirement are valid.
+
+    E.g. `modified` must be later or equal to `created`.
+    """
+    compares = [('modified', 'ge', 'created')]
+    additional_compares = enums.TIMESTAMP_COMPARE.get(instance.get('type', ''), [])
+    compares.extend(additional_compares)
+
+    for first, op, second in compares:
+        comp = getattr(operator, op)
+        comp_str = get_comparison_string(op)
+
+        if first in instance and second in instance and \
+                not comp(instance[first], instance[second]):
+            msg = "'%s' (%s) must be %s '%s' (%s)"
+            yield JSONError(msg % (first, instance[first], comp_str, second, instance[second]),
+                            instance['id'])
+
+
+@cyber_observable_check
+def observable_timestamp_compare(instance):
+    """Ensure cyber observable timestamp properties with a comparison
+    requirement are valid.
+    """
+    for key, obj in instance['objects'].items():
+        compares = enums.TIMESTAMP_COMPARE_OBSERVABLE.get(obj.get('type', ''), [])
+        print(compares)
+        for first, op, second in compares:
+            comp = getattr(operator, op)
+            comp_str = get_comparison_string(op)
+
+            if first in obj and second in obj and \
+                    not comp(obj[first], obj[second]):
+                msg = "In object '%s', '%s' (%s) must be %s '%s' (%s)"
+                yield JSONError(msg % (key, first, obj[first], comp_str, second, obj[second]),
+                                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).
+    """
+    if instance['type'] != 'marking-definition':
+        return
+
+    if 'object_marking_refs' in instance:
+        for ref in instance['object_marking_refs']:
+            if ref == instance['id']:
+                yield JSONError("`object_marking_refs` cannot contain any "
+                                "references to this marking definition object"
+                                " (no circular references).", instance['id'])
+
+
+def granular_markings_circular_refs(instance):
+    """Ensure that marking definitions do not contain circular references (ie.
+    they do not reference themselves in the `granular_markings` property).
+    """
+    if instance['type'] != 'marking-definition':
+        return
+
+    if 'granular_markings' in instance:
+        for marking in instance['granular_markings']:
+            if 'marking_ref' in marking and marking['marking_ref'] == instance['id']:
+                yield JSONError("`granular_markings` cannot contain any "
+                                "references to this marking definition object"
+                                " (no circular references).", instance['id'])
+
+
+def marking_selector_syntax(instance):
+    """Ensure selectors in granular markings refer to items which are actually
+    present in the object.
+    """
+    if 'granular_markings' not in instance:
+        return
+
+    list_index_re = re.compile(r"\[(\d+)\]")
+    for marking in instance['granular_markings']:
+        if 'selectors' not in marking:
+            continue
+
+        selectors = marking['selectors']
+        for selector in selectors:
+            segments = selector.split('.')
+
+            obj = instance
+            prev_segmt = None
+            for segmt in segments:
+                index_match = list_index_re.match(segmt)
+                if index_match:
+                    try:
+                        idx = int(index_match.group(1))
+                        obj = obj[idx]
+                    except IndexError:
+                        yield JSONError("'%s' is not a valid selector because"
+                                        " %s is not a valid index."
+                                        % (selector, idx), instance['id'])
+                    except KeyError:
+                        yield JSONError("'%s' is not a valid selector because"
+                                        " '%s' is not a list."
+                                        % (selector, prev_segmt), instance['id'])
+                else:
+                    try:
+                        obj = obj[segmt]
+                    except KeyError as e:
+                        yield JSONError("'%s' is not a valid selector because"
+                                        " %s is not a property."
+                                        % (selector, e), instance['id'])
+                    except TypeError:
+                        yield JSONError("'%s' is not a valid selector because"
+                                        " '%s' is not a property."
+                                        % (selector, segmt), instance['id'])
+                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
+    of object.
+    """
+    for key, obj in instance['objects'].items():
+        if 'type' not in obj:
+            continue
+        elif obj['type'] not in enums.OBSERVABLE_PROP_REFS:
+            continue
+
+        obj_type = obj['type']
+        for obj_prop in enums.OBSERVABLE_PROP_REFS[obj_type]:
+            if obj_prop not in obj:
+                continue
+            enum_prop = enums.OBSERVABLE_PROP_REFS[obj_type][obj_prop]
+            if isinstance(enum_prop, list):
+                refs = obj[obj_prop]
+                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 isinstance(obj[obj_prop], dict):
+                        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]:
+                                continue
+                            refs = embedded_obj[embed_obj_prop]
+                            enum_vals = enum_prop[embedded_prop][embed_obj_prop]
+                            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
+def artifact_mime_type(instance):
+    """Ensure the 'mime_type' property of artifact objects comes from the
+    Template column in the IANA media type registry.
+    """
+    for key, obj in instance['objects'].items():
+        if ('type' in obj and obj['type'] == 'artifact' and 'mime_type' in obj):
+            if enums.media_types():
+                if obj['mime_type'] not in enums.media_types():
+                    yield JSONError("The 'mime_type' property of object '%s' "
+                                    "('%s') must be an IANA registered MIME "
+                                    "Type of the form 'type/subtype'."
+                                    % (key, obj['mime_type']), instance['id'])
+
+            else:
+                info("Can't reach IANA website; using regex for mime types.")
+                mime_re = re.compile(r'^(application|audio|font|image|message|model'
+                                     '|multipart|text|video)/[a-zA-Z0-9.+_-]+')
+                if not mime_re.match(obj['mime_type']):
+                    yield JSONError("The 'mime_type' property of object '%s' "
+                                    "('%s') should be an IANA MIME Type of the"
+                                    " form 'type/subtype'."
+                                    % (key, obj['mime_type']), instance['id'])
+
+
+@cyber_observable_check
+def character_set(instance):
+    """Ensure certain properties of cyber observable objects come from the IANA
+    Character Set list.
+    """
+    char_re = re.compile(r'^[a-zA-Z0-9_\(\)-]+$')
+    for key, obj in instance['objects'].items():
+        if ('type' in obj and obj['type'] == 'directory' and 'path_enc' in obj):
+            if enums.char_sets():
+                if obj['path_enc'] not in enums.char_sets():
+                    yield JSONError("The 'path_enc' property of object '%s' "
+                                    "('%s') must be an IANA registered "
+                                    "character set."
+                                    % (key, obj['path_enc']), instance['id'])
+            else:
+                info("Can't reach IANA website; using regex for character_set.")
+                if not char_re.match(obj['path_enc']):
+                    yield JSONError("The 'path_enc' property of object '%s' "
+                                    "('%s') must be an IANA registered "
+                                    "character set."
+                                    % (key, obj['path_enc']), instance['id'])
+
+        if ('type' in obj and obj['type'] == 'file' and 'name_enc' in obj):
+            if enums.char_sets():
+                if obj['name_enc'] not in enums.char_sets():
+                    yield JSONError("The 'name_enc' property of object '%s' "
+                                    "('%s') must be an IANA registered "
+                                    "character set."
+                                    % (key, obj['name_enc']), instance['id'])
+            else:
+                info("Can't reach IANA website; using regex for character_set.")
+                if not char_re.match(obj['name_enc']):
+                    yield JSONError("The 'name_enc' property of object '%s' "
+                                    "('%s') must be an IANA registered "
+                                    "character set."
+                                    % (key, obj['name_enc']), instance['id'])
+
+
+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'])
+
+
+@cyber_observable_check
+def software_language(instance):
+    """Ensure the 'language' property of software objects is a valid ISO 639-2
+    language code.
+    """
+    for key, obj in instance['objects'].items():
+        if ('type' in obj and obj['type'] == 'software' and
+                'languages' in obj):
+            for lang in obj['languages']:
+                if lang not in enums.SOFTWARE_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):
+    """Ensure that no custom object types are used, but only the official ones
+    from the specification.
+    """
+    if instance['type'] not in enums.TYPES:
+        yield JSONError("Object type '%s' is not one of those defined in the"
+                        " specification." % instance['type'], instance['id'])
+
+    if has_cyber_observable_data(instance):
+        for key, obj in instance['objects'].items():
+            if 'type' in obj and obj['type'] not in enums.OBSERVABLE_TYPES:
+                yield JSONError("Observable object %s is type '%s' which is "
+                                "not one of those defined in the "
+                                "specification."
+                                % (key, obj['type']), instance['id'])
+
+
+def properties_strict(instance):
+    """Ensure that no custom properties are used, but only the official ones
+    from the specification.
+    """
+    if instance['type'] not in enums.TYPES:
+        return  # only check properties for official objects
+
+    defined_props = enums.PROPERTIES.get(instance['type'], [])
+    for prop in instance.keys():
+        if prop not in defined_props:
+            yield JSONError("Property '%s' is not one of those defined in the"
+                            " specification." % prop, instance['id'])
+
+    if has_cyber_observable_data(instance):
+        for key, obj in instance['objects'].items():
+            type_ = obj.get('type', '')
+            if type_ not in enums.OBSERVABLE_PROPERTIES:
+                continue  # custom observable types handled outside this function
+            observable_props = enums.OBSERVABLE_PROPERTIES.get(type_, [])
+            embedded_props = enums.OBSERVABLE_EMBEDDED_PROPERTIES.get(type_, {})
+            extensions = enums.OBSERVABLE_EXTENSIONS.get(type_, [])
+            for prop in obj.keys():
+                if prop not in observable_props:
+                    yield JSONError("Property '%s' is not one of those defined in the"
+                                    " specification for %s objects."
+                                    % (prop, type_), instance['id'])
+                # Check properties of embedded cyber observable types
+                elif prop in embedded_props:
+                    embedded_prop_keys = embedded_props.get(prop, [])
+                    for embedded_key in obj[prop]:
+                        if isinstance(embedded_key, dict):
+                            for embedded in embedded_key:
+                                if embedded not in embedded_prop_keys:
+                                    yield JSONError("Property '%s' is not one of those defined in the"
+                                                    " specification for the %s property in %s objects."
+                                                    % (embedded, prop, type_), instance['id'])
+                        elif embedded_key not in embedded_prop_keys:
+                            yield JSONError("Property '%s' is not one of those defined in the"
+                                            " specification for the %s property in %s objects."
+                                            % (embedded_key, prop, type_), instance['id'])
+
+            # Check properties of embedded cyber observable types
+            for ext_key in obj.get('extensions', {}):
+                if ext_key not in extensions:
+                    continue  # don't check custom extensions
+                extension_props = enums.OBSERVABLE_EXTENSION_PROPERTIES[ext_key]
+                for ext_prop in obj['extensions'][ext_key]:
+                    if ext_prop not in extension_props:
+                        yield JSONError("Property '%s' is not one of those defined in the"
+                                        " specification for the %s extension in %s objects."
+                                        % (ext_prop, ext_key, type_), instance['id'])
+                    embedded_ext_props = enums.OBSERVABLE_EXTENSION_EMBEDDED_PROPERTIES.get(ext_key, {}).get(ext_prop, [])
+                    if embedded_ext_props:
+                        for embed_ext_prop in obj['extensions'][ext_key].get(ext_prop, []):
+                            if embed_ext_prop not in embedded_ext_props:
+                                yield JSONError("Property '%s' in the %s property of the %s extension "
+                                                "is not one of those defined in the specification."
+                                                % (embed_ext_prop, ext_prop, ext_key), instance['id'])
+
+
+def patterns(instance, options):
+    """Ensure that the syntax of the pattern of an indicator is valid, and that
+    objects and properties referenced by the pattern are valid.
+    """
+    if instance['type'] != 'indicator' or 'pattern' not in instance:
+        return
+
+    pattern = instance['pattern']
+    if not isinstance(pattern, string_types):
+        return  # This error already caught by schemas
+    errors = pattern_validator(pattern)
+
+    # Check pattern syntax
+    if errors:
+        for e in errors:
+            yield PatternError(str(e), instance['id'])
+        return
+
+    p = Pattern(pattern)
+    inspection = p.inspect().comparisons
+    for objtype in inspection:
+        # Check observable object types
+        if objtype in enums.OBSERVABLE_TYPES:
+            pass
+        elif options.strict_types:
+            yield PatternError("'%s' is not a valid STIX observable type"
+                               % objtype, instance['id'])
+        elif (not TYPE_FORMAT_RE.match(objtype) or
+              len(objtype) < 3 or len(objtype) > 250):
+            yield PatternError("'%s' is not a valid observable type name"
+                               % objtype, instance['id'])
+        elif (all(x not in options.disabled for x in ['all', 'format-checks', 'custom-prefix']) and
+              not CUSTOM_TYPE_PREFIX_RE.match(objtype)):
+            yield PatternError("Custom Observable Object type '%s' should start "
+                               "with 'x-' followed by a source unique identifier "
+                               "(like a domain name with dots replaced by "
+                               "hyphens), a hyphen and then the name"
+                               % objtype, instance['id'])
+        elif (all(x not in options.disabled for x in ['all', 'format-checks', 'custom-prefix-lax']) and
+              not CUSTOM_TYPE_LAX_PREFIX_RE.match(objtype)):
+            yield PatternError("Custom Observable Object type '%s' should start "
+                               "with 'x-'" % objtype, instance['id'])
+
+        # Check observable object properties
+        expression_list = inspection[objtype]
+        for exp in expression_list:
+            path = exp[0]
+            # Get the property name without list index, dictionary key, or referenced object property
+            prop = path[0]
+            if objtype in enums.OBSERVABLE_PROPERTIES and prop in enums.OBSERVABLE_PROPERTIES[objtype]:
+                continue
+            elif options.strict_properties:
+                yield PatternError("'%s' is not a valid property for '%s' objects"
+                                   % (prop, objtype), instance['id'])
+            elif not PROPERTY_FORMAT_RE.match(prop):
+                yield PatternError("'%s' is not a valid observable property name"
+                                   % prop, instance['id'])
+            elif (all(x not in options.disabled for x in ['all', 'format-checks', 'custom-prefix']) and
+                  not CUSTOM_PROPERTY_PREFIX_RE.match(prop)):
+                yield PatternError("Cyber Observable Object custom property '%s' "
+                                   "should start with 'x_' followed by a source "
+                                   "unique identifier (like a domain name with "
+                                   "dots replaced by underscores), an "
+                                   "underscore and then the name"
+                                   % prop, instance['id'])
+            elif (all(x not in options.disabled for x in ['all', 'format-checks', 'custom-prefix-lax']) and
+                  not CUSTOM_PROPERTY_LAX_PREFIX_RE.match(prop)):
+                yield PatternError("Cyber Observable Object custom property '%s' "
+                                   "should start with 'x_'" % prop, instance['id'])
+
+
+def language_contents(instance):
+    """Ensure keys in Language Content's 'contents' dictionary are valid
+    language codes, and that the keys in the sub-dictionaries match the rules
+    for object property names.
+    """
+    if instance['type'] != 'language-content' or 'contents' not in instance:
+        return
+
+    for key, value in instance['contents'].items():
+        if key not in enums.LANG_CODES:
+            yield JSONError("Invalid key '%s' in 'contents' property must be"
+                            " an RFC 5646 code" % key, instance['id'])
+        for subkey, subvalue in value.items():
+            if not PROPERTY_FORMAT_RE.match(subkey):
+                yield JSONError("'%s' in '%s' of the 'contents' property is "
+                                "invalid and must match a valid property name"
+                                % (subkey, key), instance['id'], 'observable-dictionary-keys')
+
+
+def list_musts(options):
+    """Construct the list of 'MUST' validators to be run by the validator.
+    """
+    validator_list = [
+        timestamp,
+        timestamp_compare,
+        observable_timestamp_compare,
+        object_marking_circular_refs,
+        granular_markings_circular_refs,
+        marking_selector_syntax,
+        observable_object_references,
+        artifact_mime_type,
+        character_set,
+        language,
+        software_language,
+        patterns,
+        language_contents,
+    ]
+
+    # --strict-types
+    if options.strict_types:
+        validator_list.append(types_strict)
+
+    # --strict-properties
+    if options.strict_properties:
+        validator_list.append(properties_strict)
+
+    return validator_list
diff --git a/stix2validator/v21/shoulds.py b/stix2validator/v21/shoulds.py
new file mode 100644
index 0000000..7f220a6
--- /dev/null
+++ b/stix2validator/v21/shoulds.py
@@ -0,0 +1,1298 @@
+"""Recommended (SHOULD) requirement checking functions
+
+To add a new check:
+- in this module:
+    - define a new function
+    - add the function to CHECKS
+    - add the function to list_shoulds()
+- in utils.py:
+    - add the check code and name to CHECK_CODES
+- in scripts/stix2_validator:
+    - add the check code and name to table
+- in README.rst:
+    - add the check code and name to table
+"""
+
+from collections import Iterable
+from itertools import chain
+import re
+
+from six import string_types
+
+from . import enums
+from ..output import info
+from ..util import cyber_observable_check
+from .errors import JSONError
+from .musts import (CUSTOM_PROPERTY_LAX_PREFIX_RE, CUSTOM_PROPERTY_PREFIX_RE,
+                    CUSTOM_TYPE_LAX_PREFIX_RE, CUSTOM_TYPE_PREFIX_RE)
+
+PROTOCOL_RE = re.compile(r'^[a-zA-Z0-9-]{1,15}$')
+
+
+def custom_prefix_strict(instance):
+    """Ensure custom content follows strict naming style conventions.
+    """
+    for error in chain(custom_object_prefix_strict(instance),
+                       custom_property_prefix_strict(instance),
+                       custom_observable_object_prefix_strict(instance),
+                       custom_object_extension_prefix_strict(instance),
+                       custom_observable_properties_prefix_strict(instance)):
+        yield error
+
+
+def custom_prefix_lax(instance):
+    """Ensure custom content follows lenient naming style conventions
+    for forward-compatibility.
+    """
+    for error in chain(custom_object_prefix_lax(instance),
+                       custom_property_prefix_lax(instance),
+                       custom_observable_object_prefix_lax(instance),
+                       custom_object_extension_prefix_lax(instance),
+                       custom_observable_properties_prefix_lax(instance)):
+        yield error
+
+
+def custom_object_prefix_strict(instance):
+    """Ensure custom objects follow strict naming style conventions.
+    """
+    if (instance['type'] not in enums.TYPES and
+            instance['type'] not in enums.RESERVED_OBJECTS and
+            not CUSTOM_TYPE_PREFIX_RE.match(instance['type'])):
+        yield JSONError("Custom object type '%s' should start with 'x-' "
+                        "followed by a source unique identifier (like a "
+                        "domain name with dots replaced by hyphens), a hyphen "
+                        "and then the name." % instance['type'],
+                        instance['id'], 'custom-prefix')
+
+
+def custom_object_prefix_lax(instance):
+    """Ensure custom objects follow lenient naming style conventions
+    for forward-compatibility.
+    """
+    if (instance['type'] not in enums.TYPES and
+            instance['type'] not in enums.RESERVED_OBJECTS and
+            not CUSTOM_TYPE_LAX_PREFIX_RE.match(instance['type'])):
+        yield 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-prefix-lax')
+
+
+def custom_property_prefix_strict(instance):
+    """Ensure custom properties follow strict naming style conventions.
+
+    Does not check property names in custom objects.
+    """
+    for prop_name in instance.keys():
+        if (instance['type'] in enums.PROPERTIES and
+                prop_name not in enums.PROPERTIES[instance['type']] and
+                prop_name not in enums.RESERVED_PROPERTIES and
+                not CUSTOM_PROPERTY_PREFIX_RE.match(prop_name)):
+
+            yield 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 hyphen), a hyphen and then the name."
+                            % prop_name, instance['id'],
+                            'custom-prefix')
+
+
+def custom_property_prefix_lax(instance):
+    """Ensure custom properties follow lenient naming style conventions
+    for forward-compatibility.
+
+    Does not check property names in custom objects.
+    """
+    for prop_name in instance.keys():
+        if (instance['type'] in enums.PROPERTIES and
+                prop_name not in enums.PROPERTIES[instance['type']] and
+                prop_name not in enums.RESERVED_PROPERTIES and
+                not CUSTOM_PROPERTY_LAX_PREFIX_RE.match(prop_name)):
+
+            yield 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-prefix-lax')
+
+
+def open_vocab_values(instance):
+    """Ensure that the values of all properties which use open vocabularies are
+    in lowercase and use hyphens instead of spaces or underscores as word
+    separators.
+    """
+    if instance['type'] not in enums.VOCAB_PROPERTIES:
+        return
+
+    properties = enums.VOCAB_PROPERTIES[instance['type']]
+    for prop in properties:
+        if prop in instance:
+
+            if type(instance[prop]) is list:
+                values = instance[prop]
+            else:
+                values = [instance[prop]]
+
+            for v in values:
+                if not v.islower() or '_' in v or ' ' in v:
+                    yield JSONError("Open vocabulary value '%s' should be all"
+                                    " lowercase and use hyphens instead of"
+                                    " spaces or underscores as word"
+                                    " separators." % v, instance['id'],
+                                    'open-vocab-format')
+
+
+def kill_chain_phase_names(instance):
+    """Ensure the `kill_chain_name` and `phase_name` properties of
+    `kill_chain_phase` objects follow naming style conventions.
+    """
+    if instance['type'] in enums.KILL_CHAIN_PHASE_USES and 'kill_chain_phases' in instance:
+        for phase in instance['kill_chain_phases']:
+
+            if 'kill_chain_name' not in phase:
+                # Since this field is required, schemas will already catch the error
+                return
+
+            chain_name = phase['kill_chain_name']
+            if not chain_name.islower() or '_' in chain_name or ' ' in chain_name:
+                yield JSONError("kill_chain_name '%s' should be all lowercase"
+                                " and use hyphens instead of spaces or "
+                                "underscores as word separators." % chain_name,
+                                instance['id'], 'kill-chain-names')
+
+            phase_name = phase['phase_name']
+            if not phase_name.islower() or '_' in phase_name or ' ' in phase_name:
+                yield JSONError("phase_name '%s' should be all lowercase and "
+                                "use hyphens instead of spaces or underscores "
+                                "as word separators." % phase_name,
+                                instance['id'], 'kill-chain-names')
+
+
+def check_vocab(instance, vocab, code):
+    """Ensure that the open vocabulary specified by `vocab` is used properly.
+
+    This checks properties of objects specified in the appropriate `_USES`
+    dictionary to determine which properties SHOULD use the given vocabulary,
+    then checks that the values in those properties are from the vocabulary.
+    """
+    vocab_uses = getattr(enums, vocab + "_USES")
+    for k in vocab_uses.keys():
+        if instance['type'] == k:
+            for prop in vocab_uses[k]:
+                if prop not in instance:
+                    continue
+
+                vocab_ov = getattr(enums, vocab + "_OV")
+                if type(instance[prop]) is list:
+                    is_in = set(instance[prop]).issubset(set(vocab_ov))
+                else:
+                    is_in = instance[prop] in vocab_ov
+
+                if not is_in:
+                    vocab_name = vocab.replace('_', '-').lower()
+                    yield JSONError("%s contains a value not in the %s-ov "
+                                    "vocabulary." % (prop, vocab_name),
+                                    instance['id'], code)
+
+
+def vocab_attack_motivation(instance):
+    return check_vocab(instance, "ATTACK_MOTIVATION",
+                       'attack-motivation')
+
+
+def vocab_attack_resource_level(instance):
+    return check_vocab(instance, "ATTACK_RESOURCE_LEVEL",
+                       'attack-resource-level')
+
+
+def vocab_identity_class(instance):
+    return check_vocab(instance, "IDENTITY_CLASS",
+                       'identity-class')
+
+
+def vocab_indicator_types(instance):
+    return check_vocab(instance, "INDICATOR_TYPE",
+                       'indicator-types')
+
+
+def vocab_industry_sector(instance):
+    return check_vocab(instance, "INDUSTRY_SECTOR",
+                       'industry-sector')
+
+
+def vocab_malware_types(instance):
+    return check_vocab(instance, "MALWARE_TYPE",
+                       'malware-types')
+
+
+def vocab_report_types(instance):
+    return check_vocab(instance, "REPORT_TYPE",
+                       'report-types')
+
+
+def vocab_threat_actor_types(instance):
+    return check_vocab(instance, "THREAT_ACTOR_TYPE",
+                       'threat-actor-types')
+
+
+def vocab_threat_actor_role(instance):
+    return check_vocab(instance, "THREAT_ACTOR_ROLE",
+                       'threat-actor-role')
+
+
+def vocab_threat_actor_sophistication_level(instance):
+    return check_vocab(instance, "THREAT_ACTOR_SOPHISTICATION",
+                       'threat-actor-sophistication')
+
+
+def vocab_tool_types(instance):
+    return check_vocab(instance, "TOOL_TYPE",
+                       'tool-types')
+
+
+def vocab_region(instance):
+    return check_vocab(instance, "REGION",
+                       'region')
+
+
+def vocab_marking_definition(instance):
+    """Ensure that the `definition_type` property of `marking-definition`
+    objects is one of the values in the STIX 2.0 specification.
+    """
+    if (instance['type'] == 'marking-definition' and
+            'definition_type' in instance and not
+            instance['definition_type'] in enums.MARKING_DEFINITION_TYPES):
+
+        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):
+    """Ensure that only the relationship types defined in the specification are
+    used.
+    """
+    # Don't check objects that aren't relationships or that are custom objects
+    if (instance['type'] != 'relationship' or
+            instance['type'] not in enums.TYPES):
+        return
+
+    if ('relationship_type' not in instance or 'source_ref' not in instance or
+            'target_ref' not in instance):
+        # Since these fields are required, schemas will already catch the error
+        return
+
+    r_type = instance['relationship_type']
+    try:
+        r_source = re.search(r"(.+)\-\-", instance['source_ref']).group(1)
+        r_target = re.search(r"(.+)\-\-", instance['target_ref']).group(1)
+    except (AttributeError, TypeError):
+        # Schemas already catch errors of these properties not being strings or
+        # not containing the string '--'.
+        return
+
+    if (r_type in enums.COMMON_RELATIONSHIPS or
+            r_source in enums.NON_SDOS or
+            r_target in enums.NON_SDOS):
+        # If all objects can have this relationship type, no more checks needed
+        # Schemas already catch if source/target type cannot have relationship
+        return
+
+    if r_source not in enums.RELATIONSHIPS:
+        return JSONError("'%s' is not a suggested relationship source object "
+                         "for the '%s' relationship." % (r_source, r_type),
+                         instance['id'], 'relationship-types')
+
+    if r_type not in enums.RELATIONSHIPS[r_source]:
+        return JSONError("'%s' is not a suggested relationship type for '%s' "
+                         "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 suggested relationship target object "
+                         "for '%s' objects with the '%s' relationship."
+                         % (r_target, r_source, r_type), instance['id'],
+                         'relationship-types')
+
+
+def valid_hash_value(hashname):
+    """Return true if given value is a valid, recommended hash name according
+    to the STIX 2 specification.
+    """
+    custom_hash_prefix_re = re.compile(r"^x_")
+    if hashname in enums.HASH_ALGO_OV or custom_hash_prefix_re.match(hashname):
+        return True
+    else:
+        return False
+
+
+@cyber_observable_check
+def vocab_hash_algo(instance):
+    """Ensure objects with 'hashes' properties only use values from the
+    hash-algo-ov vocabulary.
+    """
+    for key, obj in instance['objects'].items():
+        if 'type' not in obj:
+            continue
+
+        if obj['type'] == 'file':
+            try:
+                hashes = obj['hashes']
+            except KeyError:
+                pass
+            else:
+                for h in hashes:
+                    if not (valid_hash_value(h)):
+                        yield JSONError("Object '%s' has a 'hashes' dictionary"
+                                        " with a hash of type '%s', which is not a "
+                                        "value in the hash-algo-ov vocabulary nor a "
+                                        "custom value prepended with 'x_'."
+                                        % (key, h), instance['id'], 'hash-algo')
+
+            try:
+                ads = obj['extensions']['ntfs-ext']['alternate_data_streams']
+            except KeyError:
+                pass
+            else:
+                for datastream in ads:
+                    if 'hashes' not in datastream:
+                        continue
+                    for h in datastream['hashes']:
+                        if not (valid_hash_value(h)):
+                            yield JSONError("Object '%s' has an NTFS extension"
+                                            " with an alternate data stream that has a"
+                                            " 'hashes' dictionary with a hash of type "
+                                            "'%s', which is not a value in the "
+                                            "hash-algo-ov vocabulary nor a custom "
+                                            "value prepended with 'x_'."
+                                            % (key, h), instance['id'], 'hash-algo')
+
+            try:
+                head_hashes = obj['extensions']['windows-pebinary-ext']['file_header_hashes']
+            except KeyError:
+                pass
+            else:
+                for h in head_hashes:
+                    if not (valid_hash_value(h)):
+                        yield JSONError("Object '%s' has a Windows PE Binary "
+                                        "File extension with a file header hash of "
+                                        "'%s', which is not a value in the "
+                                        "hash-algo-ov vocabulary nor a custom value "
+                                        "prepended with 'x_'."
+                                        % (key, h), instance['id'], 'hash-algo')
+
+            try:
+                hashes = obj['extensions']['windows-pebinary-ext']['optional_header']['hashes']
+            except KeyError:
+                pass
+            else:
+                for h in hashes:
+                    if not (valid_hash_value(h)):
+                        yield JSONError("Object '%s' has a Windows PE Binary "
+                                        "File extension with an optional header that "
+                                        "has a hash of '%s', which is not a value in "
+                                        "the hash-algo-ov vocabulary nor a custom "
+                                        "value prepended with 'x_'."
+                                        % (key, h), instance['id'], 'hash-algo')
+
+            try:
+                sections = obj['extensions']['windows-pebinary-ext']['sections']
+            except KeyError:
+                pass
+            else:
+                for s in sections:
+                    if 'hashes' not in s:
+                        continue
+                    for h in s['hashes']:
+                        if not (valid_hash_value(h)):
+                            yield JSONError("Object '%s' has a Windows PE "
+                                            "Binary File extension with a section that"
+                                            " has a hash of '%s', which is not a value"
+                                            " in the hash-algo-ov vocabulary nor a "
+                                            "custom value prepended with 'x_'."
+                                            % (key, h), instance['id'], 'hash-algo')
+
+        elif obj['type'] == 'artifact' or obj['type'] == 'x509-certificate':
+            try:
+                hashes = obj['hashes']
+            except KeyError:
+                pass
+            else:
+                for h in hashes:
+                    if not (valid_hash_value(h)):
+                        yield JSONError("Object '%s' has a 'hashes' dictionary"
+                                        " with a hash of type '%s', which is not a "
+                                        "value in the hash-algo-ov vocabulary nor a "
+                                        "custom value prepended with 'x_'."
+                                        % (key, h), instance['id'], 'hash-algo')
+
+
+@cyber_observable_check
+def vocab_windows_pebinary_type(instance):
+    """Ensure file objects with the windows-pebinary-ext extension have a
+    'pe-type' property that is from the windows-pebinary-type-ov vocabulary.
+    """
+    for key, obj in instance['objects'].items():
+        if 'type' in obj and obj['type'] == 'file':
+            try:
+                pe_type = obj['extensions']['windows-pebinary-ext']['pe_type']
+            except KeyError:
+                continue
+            if pe_type not in enums.WINDOWS_PEBINARY_TYPE_OV:
+                yield JSONError("Object '%s' has a Windows PE Binary File "
+                                "extension with a 'pe_type' of '%s', which is not a "
+                                "value in the windows-pebinary-type-ov vocabulary."
+                                % (key, pe_type), instance['id'],
+                                'windows-pebinary-type')
+
+
+@cyber_observable_check
+def vocab_account_type(instance):
+    """Ensure a user-account objects' 'account-type' property is from the
+    account-type-ov vocabulary.
+    """
+    for key, obj in instance['objects'].items():
+        if 'type' in obj and obj['type'] == 'user-account':
+            try:
+                acct_type = obj['account_type']
+            except KeyError:
+                continue
+            if acct_type not in enums.ACCOUNT_TYPE_OV:
+                yield JSONError("Object '%s' is a User Account Object "
+                                "with an 'account_type' of '%s', which is not a "
+                                "value in the account-type-ov vocabulary."
+                                % (key, acct_type), instance['id'], 'account-type')
+
+
+@cyber_observable_check
+def observable_object_keys(instance):
+    """Ensure observable-objects keys are non-negative integers.
+    """
+    digits_re = re.compile(r"^\d+$")
+    for key in instance['objects']:
+        if not digits_re.match(key):
+            yield JSONError("'%s' is not a good key value. Observable Objects "
+                            "should use non-negative integers for their keys."
+                            % key, instance['id'], 'observable-object-keys')
+
+
+def test_dict_keys(item, inst_id):
+    """Recursively generate errors for incorrectly formatted cyber observable
+    dictionary keys.
+    """
+    not_caps_re = re.compile(r"^[^A-Z]+$")
+    for k, v in item.items():
+        if not not_caps_re.match(k):
+            yield JSONError("As a dictionary key, '%s' should be lowercase."
+                            % k, inst_id, 'observable-dictionary-keys')
+
+        if type(v) is dict and k not in enums.OBSERVABLE_DICT_KEY_EXCEPTIONS:
+            for error in test_dict_keys(v, inst_id):
+                yield error
+
+
+@cyber_observable_check
+def observable_dictionary_keys(instance):
+    """Ensure dictionaries in the cyber observable layer have lowercase keys
+    no longer than 30 characters.
+    """
+    for error in test_dict_keys(instance['objects'], instance['id']):
+        yield error
+
+
+@cyber_observable_check
+def custom_observable_object_prefix_strict(instance):
+    """Ensure custom observable objects follow strict naming style conventions.
+    """
+    for key, obj in instance['objects'].items():
+        if ('type' in obj and obj['type'] not in enums.OBSERVABLE_TYPES and
+                obj['type'] not in enums.OBSERVABLE_RESERVED_OBJECTS and
+                not CUSTOM_TYPE_PREFIX_RE.match(obj['type'])):
+            yield JSONError("Custom Observable Object type '%s' should start "
+                            "with 'x-' followed by a source unique identifier "
+                            "(like a domain name with dots replaced by "
+                            "hyphens), a hyphen and then the name."
+                            % obj['type'], instance['id'],
+                            'custom-prefix')
+
+
+@cyber_observable_check
+def custom_observable_object_prefix_lax(instance):
+    """Ensure custom observable objects follow naming style conventions.
+    """
+    for key, obj in instance['objects'].items():
+        if ('type' in obj and obj['type'] not in enums.OBSERVABLE_TYPES and
+                obj['type'] not in enums.OBSERVABLE_RESERVED_OBJECTS and
+                not CUSTOM_TYPE_LAX_PREFIX_RE.match(obj['type'])):
+            yield JSONError("Custom Observable Object type '%s' should start "
+                            "with 'x-'."
+                            % obj['type'], instance['id'],
+                            'custom-prefix-lax')
+
+
+@cyber_observable_check
+def custom_object_extension_prefix_strict(instance):
+    """Ensure custom observable object extensions follow strict naming style
+    conventions.
+    """
+    for key, obj in instance['objects'].items():
+        if not ('extensions' in obj and 'type' in obj and
+                obj['type'] in enums.OBSERVABLE_EXTENSIONS):
+            continue
+        for ext_key in obj['extensions']:
+            if (ext_key not in enums.OBSERVABLE_EXTENSIONS[obj['type']] and
+                    not CUSTOM_TYPE_PREFIX_RE.match(ext_key)):
+                yield JSONError("Custom Cyber Observable Object extension type"
+                                " '%s' should start with 'x-' followed by a source "
+                                "unique identifier (like a domain name with dots "
+                                "replaced by hyphens), a hyphen and then the name."
+                                % ext_key, instance['id'],
+                                'custom-prefix')
+
+
+@cyber_observable_check
+def custom_object_extension_prefix_lax(instance):
+    """Ensure custom observable object extensions follow naming style
+    conventions.
+    """
+    for key, obj in instance['objects'].items():
+        if not ('extensions' in obj and 'type' in obj and
+                obj['type'] in enums.OBSERVABLE_EXTENSIONS):
+            continue
+        for ext_key in obj['extensions']:
+            if (ext_key not in enums.OBSERVABLE_EXTENSIONS[obj['type']] and
+                    not CUSTOM_TYPE_LAX_PREFIX_RE.match(ext_key)):
+                yield JSONError("Custom Cyber Observable Object extension type"
+                                " '%s' should start with 'x-'."
+                                % ext_key, instance['id'],
+                                'custom-prefix-lax')
+
+
+@cyber_observable_check
+def custom_observable_properties_prefix_strict(instance):
+    """Ensure observable object custom properties follow strict naming style
+    conventions.
+    """
+    for key, obj in instance['objects'].items():
+        if 'type' not in obj:
+            continue
+        type_ = obj['type']
+
+        for prop in obj:
+            # Check objects' properties
+            if (type_ in enums.OBSERVABLE_PROPERTIES and
+                prop not in enums.OBSERVABLE_PROPERTIES[type_] and
+                    not CUSTOM_PROPERTY_PREFIX_RE.match(prop)):
+                yield JSONError("Cyber Observable Object custom property '%s' "
+                                "should start with 'x_' followed by a source "
+                                "unique identifier (like a domain name with "
+                                "dots replaced by hyphens), a hyphen and then the"
+                                " name."
+                                % prop, instance['id'],
+                                'custom-prefix')
+            # Check properties of embedded cyber observable types
+            if (type_ in enums.OBSERVABLE_EMBEDDED_PROPERTIES and
+                    prop in enums.OBSERVABLE_EMBEDDED_PROPERTIES[type_]):
+                for embed_prop in obj[prop]:
+                    if isinstance(embed_prop, dict):
+                        for embedded in embed_prop:
+                            if (embedded not in enums.OBSERVABLE_EMBEDDED_PROPERTIES[type_][prop] and
+                                    not CUSTOM_PROPERTY_PREFIX_RE.match(embedded)):
+                                yield JSONError("Cyber Observable Object custom "
+                                                "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 hyphens), a hyphen and "
+                                                "then the name."
+                                                % (embedded, prop, type_), instance['id'],
+                                                'custom-prefix')
+                    elif (embed_prop not in enums.OBSERVABLE_EMBEDDED_PROPERTIES[type_][prop] and
+                            not CUSTOM_PROPERTY_PREFIX_RE.match(embed_prop)):
+                        yield JSONError("Cyber Observable Object custom "
+                                        "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 hyphens), a hyphen and "
+                                        "then the name."
+                                        % (embed_prop, prop, type_), instance['id'],
+                                        'custom-prefix')
+
+        # Check object extensions' properties
+        if (type_ in enums.OBSERVABLE_EXTENSIONS and 'extensions' in obj):
+            for ext_key in obj['extensions']:
+
+                if ext_key in enums.OBSERVABLE_EXTENSIONS[type_]:
+                    for ext_prop in obj['extensions'][ext_key]:
+                        if (ext_prop not in enums.OBSERVABLE_EXTENSION_PROPERTIES[ext_key] and
+                                not CUSTOM_PROPERTY_PREFIX_RE.match(ext_prop)):
+                            yield JSONError("Cyber Observable Object custom "
+                                            "property '%s' in the %s extension "
+                                            "should start with 'x_' followed by a "
+                                            "source unique identifier (like a "
+                                            "domain name with dots replaced by "
+                                            "hyphens), a hyphen and then the name."
+                                            % (ext_prop, ext_key), instance['id'],
+                                            'custom-prefix')
+
+                if ext_key in enums.OBSERVABLE_EXTENSIONS[type_]:
+                    for ext_prop in obj['extensions'][ext_key]:
+                        if (ext_key in enums.OBSERVABLE_EXTENSION_EMBEDDED_PROPERTIES and
+                                ext_prop in enums.OBSERVABLE_EXTENSION_EMBEDDED_PROPERTIES[ext_key]):
+                            for embed_prop in obj['extensions'][ext_key][ext_prop]:
+                                if not (isinstance(embed_prop, Iterable) and not isinstance(embed_prop, string_types)):
+                                    embed_prop = [embed_prop]
+                                for p in embed_prop:
+                                    if (p not in enums.OBSERVABLE_EXTENSION_EMBEDDED_PROPERTIES[ext_key][ext_prop] and
+                                            not CUSTOM_PROPERTY_PREFIX_RE.match(p)):
+                                        yield JSONError("Cyber Observable Object "
+                                                        "custom property '%s' in the %s "
+                                                        "property of the %s extension should "
+                                                        "start with 'x_' followed by a source "
+                                                        "unique identifier (like a domain name"
+                                                        " with dots replaced by hyphens), a "
+                                                        "hyphen and then the name."
+                                                        % (p, ext_prop, ext_key), instance['id'],
+                                                        'custom-prefix')
+
+
+@cyber_observable_check
+def custom_observable_properties_prefix_lax(instance):
+    """Ensure observable object custom properties follow naming style
+    conventions.
+    """
+    for key, obj in instance['objects'].items():
+        if 'type' not in obj:
+            continue
+        type_ = obj['type']
+
+        for prop in obj:
+            # Check objects' properties
+            if (type_ in enums.OBSERVABLE_PROPERTIES and
+                prop not in enums.OBSERVABLE_PROPERTIES[type_] and
+                    not CUSTOM_PROPERTY_LAX_PREFIX_RE.match(prop)):
+                yield JSONError("Cyber Observable Object custom property '%s' "
+                                "should start with 'x_'."
+                                % prop, instance['id'],
+                                'custom-prefix-lax')
+            # Check properties of embedded cyber observable types
+            if (type_ in enums.OBSERVABLE_EMBEDDED_PROPERTIES and
+                    prop in enums.OBSERVABLE_EMBEDDED_PROPERTIES[type_]):
+                for embed_prop in obj[prop]:
+                    if isinstance(embed_prop, dict):
+                        for embedded in embed_prop:
+                            if (embedded not in enums.OBSERVABLE_EMBEDDED_PROPERTIES[type_][prop] and
+                                    not CUSTOM_PROPERTY_LAX_PREFIX_RE.match(embedded)):
+                                yield JSONError("Cyber Observable Object custom "
+                                                "property '%s' in the %s property of "
+                                                "%s object should start with 'x_'."
+                                                % (embedded, prop, type_), instance['id'],
+                                                'custom-prefix-lax')
+                    elif (embed_prop not in enums.OBSERVABLE_EMBEDDED_PROPERTIES[type_][prop] and
+                            not CUSTOM_PROPERTY_LAX_PREFIX_RE.match(embed_prop)):
+                        yield JSONError("Cyber Observable Object custom "
+                                        "property '%s' in the %s property of "
+                                        "%s object should start with 'x_'."
+                                        % (embed_prop, prop, type_), instance['id'],
+                                        'custom-prefix-lax')
+
+        # Check object extensions' properties
+        if (type_ in enums.OBSERVABLE_EXTENSIONS and 'extensions' in obj):
+            for ext_key in obj['extensions']:
+
+                if ext_key in enums.OBSERVABLE_EXTENSIONS[type_]:
+                    for ext_prop in obj['extensions'][ext_key]:
+                        if (ext_prop not in enums.OBSERVABLE_EXTENSION_PROPERTIES[ext_key] and
+                                not CUSTOM_PROPERTY_LAX_PREFIX_RE.match(ext_prop)):
+                            yield JSONError("Cyber Observable Object custom "
+                                            "property '%s' in the %s extension "
+                                            "should start with 'x_'."
+                                            % (ext_prop, ext_key), instance['id'],
+                                            'custom-prefix-lax')
+
+                if ext_key in enums.OBSERVABLE_EXTENSIONS[type_]:
+                    for ext_prop in obj['extensions'][ext_key]:
+                        if (ext_key in enums.OBSERVABLE_EXTENSION_EMBEDDED_PROPERTIES and
+                                ext_prop in enums.OBSERVABLE_EXTENSION_EMBEDDED_PROPERTIES[ext_key]):
+                            for embed_prop in obj['extensions'][ext_key][ext_prop]:
+                                if not (isinstance(embed_prop, Iterable) and not isinstance(embed_prop, string_types)):
+                                    embed_prop = [embed_prop]
+                                for p in embed_prop:
+                                    if (p not in enums.OBSERVABLE_EXTENSION_EMBEDDED_PROPERTIES[ext_key][ext_prop] and
+                                            not CUSTOM_PROPERTY_LAX_PREFIX_RE.match(p)):
+                                        yield JSONError("Cyber Observable Object "
+                                                        "custom property '%s' in the %s "
+                                                        "property of the %s extension should "
+                                                        "start with 'x_'."
+                                                        % (p, ext_prop, ext_key), instance['id'],
+                                                        'custom-prefix-lax')
+
+
+@cyber_observable_check
+def network_traffic_ports(instance):
+    """Ensure network-traffic objects contain both src_port and dst_port.
+    """
+    for key, obj in instance['objects'].items():
+        if ('type' in obj and obj['type'] == 'network-traffic' and
+                ('src_port' not in obj or 'dst_port' not in obj)):
+            yield JSONError("The Network Traffic object '%s' should contain "
+                            "both the 'src_port' and 'dst_port' properties."
+                            % key, instance['id'], 'network-traffic-ports')
+
+
+@cyber_observable_check
+def mime_type(instance):
+    """Ensure the 'mime_type' property of file objects comes from the Template
+    column in the IANA media type registry.
+    """
+    mime_pattern = re.compile(r'^(application|audio|font|image|message|model'
+                              '|multipart|text|video)/[a-zA-Z0-9.+_-]+')
+    for key, obj in instance['objects'].items():
+        if ('type' in obj and obj['type'] == 'file' and 'mime_type' in obj):
+            if enums.media_types():
+                if obj['mime_type'] not in enums.media_types():
+                    yield JSONError("The 'mime_type' property of object '%s' "
+                                    "('%s') should be an IANA registered MIME "
+                                    "Type of the form 'type/subtype'."
+                                    % (key, obj['mime_type']), instance['id'],
+                                    'mime-type')
+            else:
+                info("Can't reach IANA website; using regex for mime types.")
+                if not mime_pattern.match(obj['mime_type']):
+                    yield JSONError("The 'mime_type' property of object '%s' "
+                                    "('%s') should be an IANA MIME Type of the"
+                                    " form 'type/subtype'."
+                                    % (key, obj['mime_type']), instance['id'],
+                                    'mime-type')
+
+
+@cyber_observable_check
+def protocols(instance):
+    """Ensure the 'protocols' property of network-traffic objects contains only
+    values from the IANA Service Name and Transport Protocol Port Number
+    Registry.
+    """
+    for key, obj in instance['objects'].items():
+        if ('type' in obj and obj['type'] == 'network-traffic' and
+                'protocols' in obj):
+            for prot in obj['protocols']:
+                if enums.protocols():
+                    if prot not in enums.protocols():
+                        yield JSONError("The 'protocols' property of object "
+                                        "'%s' contains a value ('%s') not in "
+                                        "IANA Service Name and Transport "
+                                        "Protocol Port Number Registry."
+                                        % (key, prot), instance['id'],
+                                        'protocols')
+                else:
+                    info("Can't reach IANA website; using regex for protocols.")
+                    if not PROTOCOL_RE.match(prot):
+                        yield JSONError("The 'protocols' property of object "
+                                        "'%s' contains a value ('%s') not in "
+                                        "IANA Service Name and Transport "
+                                        "Protocol Port Number Registry."
+                                        % (key, prot), instance['id'],
+                                        'protocols')
+
+
+@cyber_observable_check
+def ipfix(instance):
+    """Ensure the 'ipfix' property of network-traffic objects contains only
+    values from the IANA IP Flow Information Export (IPFIX) Entities Registry.
+    """
+    ipf_pattern = re.compile(r'^[a-z][a-zA-Z0-9]+')
+    for key, obj in instance['objects'].items():
+        if ('type' in obj and obj['type'] == 'network-traffic' and
+                'ipfix' in obj):
+            for ipf in obj['ipfix']:
+                if enums.ipfix():
+                    if ipf not in enums.ipfix():
+                        yield JSONError("The 'ipfix' property of object "
+                                        "'%s' contains a key ('%s') not in "
+                                        "IANA IP Flow Information Export "
+                                        "(IPFIX) Entities Registry."
+                                        % (key, ipf), instance['id'],
+                                        'ipfix')
+                else:
+                    info("Can't reach IANA website; using regex for ipfix.")
+                    if not ipf_pattern.match(ipf):
+                        yield JSONError("The 'ipfix' property of object "
+                                        "'%s' contains a key ('%s') not in "
+                                        "IANA IP Flow Information Export "
+                                        "(IPFIX) Entities Registry."
+                                        % (key, ipf), instance['id'],
+                                        'ipfix')
+
+
+@cyber_observable_check
+def http_request_headers(instance):
+    """Ensure the keys of the 'request_headers' property of the http-request-
+    ext extension of network-traffic objects conform to the format for HTTP
+    request headers. Use a regex because there isn't a definitive source.
+    https://www.iana.org/assignments/message-headers/message-headers.xhtml does
+    not differentiate between request and response headers, and leaves out
+    several common non-standard request fields listed elsewhere.
+    """
+    for key, obj in instance['objects'].items():
+        if ('type' in obj and obj['type'] == 'network-traffic'):
+            try:
+                headers = obj['extensions']['http-request-ext']['request_header']
+            except KeyError:
+                continue
+
+            for hdr in headers:
+                if hdr not in enums.HTTP_REQUEST_HEADERS:
+                    yield JSONError("The 'request_header' property of object "
+                                    "'%s' contains an invalid HTTP request "
+                                    "header ('%s')."
+                                    % (key, hdr), instance['id'],
+                                    'http-request-headers')
+
+
+@cyber_observable_check
+def socket_options(instance):
+    """Ensure the keys of the 'options' property of the socket-ext extension of
+    network-traffic objects are only valid socket options (SO_*).
+    """
+    for key, obj in instance['objects'].items():
+        if ('type' in obj and obj['type'] == 'network-traffic'):
+            try:
+                options = obj['extensions']['socket-ext']['options']
+            except KeyError:
+                continue
+
+            for opt in options:
+                if opt not in enums.SOCKET_OPTIONS:
+                    yield JSONError("The 'options' property of object '%s' "
+                                    "contains a key ('%s') that is not a valid"
+                                    " socket option (SO_*)."
+                                    % (key, opt), instance['id'], 'socket-options')
+
+
+@cyber_observable_check
+def pdf_doc_info(instance):
+    """Ensure the keys of the 'document_info_dict' property of the pdf-ext
+    extension of file objects are only valid PDF Document Information
+    Dictionary Keys.
+    """
+    for key, obj in instance['objects'].items():
+        if ('type' in obj and obj['type'] == 'file'):
+            try:
+                did = obj['extensions']['pdf-ext']['document_info_dict']
+            except KeyError:
+                continue
+
+            for elem in did:
+                if elem not in enums.PDF_DID:
+                    yield JSONError("The 'document_info_dict' property of "
+                                    "object '%s' contains a key ('%s') that is"
+                                    " not a valid PDF Document Information "
+                                    "Dictionary key."
+                                    % (key, elem), instance['id'],
+                                    'pdf-doc-info')
+
+
+def countries(instance):
+    """Ensure that the `country` property of `location` objects is a valid
+    ISO 3166-1 ALPHA-2 Code.
+    """
+    if (instance['type'] == 'location' and 'country' in instance and not
+            instance['country'].upper() in enums.COUNTRY_CODES):
+
+        return JSONError("Location `country` should be a valid ISO 3166-1 "
+                         "ALPHA-2 Code.",
+                         instance['id'], 'marking-definition-type')
+
+
+@cyber_observable_check
+def windows_process_priority_format(instance):
+    """Ensure the 'priority' property of windows-process-ext ends in '_CLASS'.
+    """
+    class_suffix_re = re.compile(r'.+_CLASS$')
+    for key, obj in instance['objects'].items():
+        if 'type' in obj and obj['type'] == 'process':
+            try:
+                priority = obj['extensions']['windows-process-ext']['priority']
+            except KeyError:
+                continue
+            if not class_suffix_re.match(priority):
+                yield JSONError("The 'priority' property of object '%s' should"
+                                " end in '_CLASS'." % key, instance['id'],
+                                'windows-process-priority-format')
+
+
+@cyber_observable_check
+def hash_length(instance):
+    """Ensure keys in 'hashes'-type properties are no more than 30 characters long.
+    """
+    for key, obj in instance['objects'].items():
+        if 'type' not in obj:
+            continue
+
+        if obj['type'] == 'file':
+            try:
+                hashes = obj['hashes']
+            except KeyError:
+                pass
+            else:
+                for h in hashes:
+                    if (len(h) > 30):
+                        yield JSONError("Object '%s' has a 'hashes' dictionary"
+                                        " with a hash of type '%s', which is "
+                                        "longer than 30 characters."
+                                        % (key, h), instance['id'], 'hash-algo')
+
+            try:
+                ads = obj['extensions']['ntfs-ext']['alternate_data_streams']
+            except KeyError:
+                pass
+            else:
+                for datastream in ads:
+                    if 'hashes' not in datastream:
+                        continue
+                    for h in datastream['hashes']:
+                        if (len(h) > 30):
+                            yield JSONError("Object '%s' has an NTFS extension"
+                                            " with an alternate data stream that has a"
+                                            " 'hashes' dictionary with a hash of type "
+                                            "'%s', which is longer than 30 "
+                                            "characters."
+                                            % (key, h), instance['id'], 'hash-algo')
+
+            try:
+                head_hashes = obj['extensions']['windows-pebinary-ext']['file_header_hashes']
+            except KeyError:
+                pass
+            else:
+                for h in head_hashes:
+                    if (len(h) > 30):
+                        yield JSONError("Object '%s' has a Windows PE Binary "
+                                        "File extension with a file header hash of "
+                                        "'%s', which is longer than 30 "
+                                        "characters."
+                                        % (key, h), instance['id'], 'hash-algo')
+
+            try:
+                hashes = obj['extensions']['windows-pebinary-ext']['optional_header']['hashes']
+            except KeyError:
+                pass
+            else:
+                for h in hashes:
+                    if (len(h) > 30):
+                        yield JSONError("Object '%s' has a Windows PE Binary "
+                                        "File extension with an optional header that "
+                                        "has a hash of '%s', which is longer "
+                                        "than 30 characters."
+                                        % (key, h), instance['id'], 'hash-algo')
+
+            try:
+                sections = obj['extensions']['windows-pebinary-ext']['sections']
+            except KeyError:
+                pass
+            else:
+                for s in sections:
+                    if 'hashes' not in s:
+                        continue
+                    for h in s['hashes']:
+                        if (len(h) > 30):
+                            yield JSONError("Object '%s' has a Windows PE "
+                                            "Binary File extension with a section that"
+                                            " has a hash of '%s', which is "
+                                            "longer than 30 characters."
+                                            % (key, h), instance['id'], 'hash-algo')
+
+        elif obj['type'] == 'artifact' or obj['type'] == 'x509-certificate':
+            try:
+                hashes = obj['hashes']
+            except KeyError:
+                pass
+            else:
+                for h in hashes:
+                    if (len(h) > 30):
+                        yield JSONError("Object '%s' has a 'hashes' dictionary"
+                                        " with a hash of type '%s', which is "
+                                        "longer than 30 characters."
+                                        % (key, h), instance['id'], 'hash-algo')
+
+
+def extref_hashes(instance):
+    if 'external_references' in instance:
+        for extref in instance['external_references']:
+            if 'url' in extref and 'hashes' not in extref:
+                src = extref['source_name'] if 'source_name' in extref else ''
+                return JSONError("External reference '%s' has a URL but no hash."
+                                 % (src), instance['id'], 'extref-hashes')
+
+
+def duplicate_ids(instance):
+    """Ensure objects with duplicate IDs have different `modified` timestamps.
+    """
+    if instance['type'] != 'bundle' or 'objects' not in instance:
+        return
+
+    unique_ids = {}
+    for obj in instance['objects']:
+        if 'id' not in obj or 'modified' not in obj:
+            continue
+        elif obj['id'] not in unique_ids:
+            unique_ids[obj['id']] = obj['modified']
+        elif obj['modified'] == unique_ids[obj['id']]:
+            yield JSONError("Duplicate ID '%s' has identical `modified` timestamp."
+                            " If they are different versions of the same object, "
+                            "they should have different `modified` properties."
+                            % obj['id'], instance['id'], 'duplicate-ids')
+
+
+# Mapping of check names to the functions which perform the checks
+CHECKS = {
+    'all': [
+        custom_object_prefix_strict,
+        custom_property_prefix_strict,
+        open_vocab_values,
+        kill_chain_phase_names,
+        observable_object_keys,
+        observable_dictionary_keys,
+        custom_observable_object_prefix_strict,
+        custom_object_extension_prefix_strict,
+        custom_observable_properties_prefix_strict,
+        windows_process_priority_format,
+        hash_length,
+        vocab_marking_definition,
+        relationships_strict,
+        vocab_attack_motivation,
+        vocab_attack_resource_level,
+        vocab_identity_class,
+        vocab_indicator_types,
+        vocab_industry_sector,
+        vocab_malware_types,
+        vocab_report_types,
+        vocab_threat_actor_types,
+        vocab_threat_actor_role,
+        vocab_threat_actor_sophistication_level,
+        vocab_tool_types,
+        vocab_region,
+        vocab_hash_algo,
+        vocab_windows_pebinary_type,
+        vocab_account_type,
+        mime_type,
+        protocols,
+        ipfix,
+        http_request_headers,
+        socket_options,
+        pdf_doc_info,
+        countries,
+        network_traffic_ports,
+        extref_hashes,
+        duplicate_ids,
+    ],
+    'format-checks': [
+        custom_object_prefix_strict,
+        custom_property_prefix_strict,
+        open_vocab_values,
+        kill_chain_phase_names,
+        observable_object_keys,
+        observable_dictionary_keys,
+        custom_observable_object_prefix_strict,
+        custom_object_extension_prefix_strict,
+        custom_observable_properties_prefix_strict,
+        windows_process_priority_format,
+        hash_length,
+    ],
+    'custom-prefix': custom_prefix_strict,
+    'custom-prefix-lax': custom_prefix_lax,
+    'open-vocab-format': open_vocab_values,
+    'kill-chain-names': kill_chain_phase_names,
+    'observable-object-keys': observable_object_keys,
+    'observable-dictionary-keys': observable_dictionary_keys,
+    'windows-process-priority-format': windows_process_priority_format,
+    'hash-length': hash_length,
+    'approved-values': [
+        vocab_marking_definition,
+        relationships_strict,
+        duplicate_ids,
+        vocab_attack_motivation,
+        vocab_attack_resource_level,
+        vocab_identity_class,
+        vocab_indicator_types,
+        vocab_industry_sector,
+        vocab_malware_types,
+        vocab_report_types,
+        vocab_threat_actor_types,
+        vocab_threat_actor_role,
+        vocab_threat_actor_sophistication_level,
+        vocab_tool_types,
+        vocab_region,
+        vocab_hash_algo,
+        vocab_windows_pebinary_type,
+        vocab_account_type,
+        mime_type,
+        protocols,
+        ipfix,
+        http_request_headers,
+        socket_options,
+        pdf_doc_info,
+        countries,
+    ],
+    'marking-definition-type': vocab_marking_definition,
+    'relationship-types': relationships_strict,
+    'duplicate-ids': duplicate_ids,
+    'all-vocabs': [
+        vocab_attack_motivation,
+        vocab_attack_resource_level,
+        vocab_identity_class,
+        vocab_indicator_types,
+        vocab_industry_sector,
+        vocab_malware_types,
+        vocab_report_types,
+        vocab_threat_actor_types,
+        vocab_threat_actor_role,
+        vocab_threat_actor_sophistication_level,
+        vocab_tool_types,
+        vocab_region,
+        vocab_hash_algo,
+        vocab_windows_pebinary_type,
+        vocab_account_type,
+    ],
+    'attack-motivation': vocab_attack_motivation,
+    'attack-resource-level': vocab_attack_resource_level,
+    'identity-class': vocab_identity_class,
+    'indicator-types': vocab_indicator_types,
+    'industry-sector': vocab_industry_sector,
+    'malware-types': vocab_malware_types,
+    'report-types': vocab_report_types,
+    'threat-actor-types': vocab_threat_actor_types,
+    'threat-actor-role': vocab_threat_actor_role,
+    'threat-actor-sophistication': vocab_threat_actor_sophistication_level,
+    'tool-types': vocab_tool_types,
+    'region': vocab_region,
+    'hash-algo': vocab_hash_algo,
+    'windows-pebinary-type': vocab_windows_pebinary_type,
+    'account-type': vocab_account_type,
+    'all-external-sources': [
+        mime_type,
+        protocols,
+        ipfix,
+        http_request_headers,
+        socket_options,
+        pdf_doc_info,
+        countries,
+    ],
+    'mime-type': mime_type,
+    'protocols': protocols,
+    'ipfix': ipfix,
+    'http-request-headers': http_request_headers,
+    'socket-options': socket_options,
+    'pdf-doc-info': pdf_doc_info,
+    'countries': countries,
+    'network-traffic-ports': network_traffic_ports,
+    'extref-hashes': extref_hashes,
+}
+
+
+def list_shoulds(options):
+    """Construct the list of 'SHOULD' validators to be run by the validator.
+    """
+    validator_list = []
+
+    # Default: enable all
+    if not options.disabled and not options.enabled:
+        validator_list.extend(CHECKS['all'])
+        return validator_list
+
+    # --disable
+    # Add SHOULD requirements to the list unless disabled
+    if options.disabled:
+        if 'all' not in options.disabled:
+            if 'format-checks' not in options.disabled:
+                if 'custom-prefix' not in options.disabled:
+                    validator_list.append(CHECKS['custom-prefix'])
+                elif 'custom-prefix-lax' not in options.disabled:
+                    validator_list.append(CHECKS['custom-prefix-lax'])
+                if 'open-vocab-format' not in options.disabled:
+                    validator_list.append(CHECKS['open-vocab-format'])
+                if 'kill-chain-names' not in options.disabled:
+                    validator_list.append(CHECKS['kill-chain-names'])
+                if 'observable-object-keys' not in options.disabled:
+                    validator_list.append(CHECKS['observable-object-keys'])
+                if 'observable-dictionary-keys' not in options.disabled:
+                    validator_list.append(CHECKS['observable-dictionary-keys'])
+                if 'windows-process-priority-format' not in options.disabled:
+                    validator_list.append(CHECKS['windows-process-priority-format'])
+                if 'hash-length' not in options.disabled:
+                    validator_list.append(CHECKS['hash-length'])
+
+            if 'approved-values' not in options.disabled:
+                if 'marking-definition-type' not in options.disabled:
+                    validator_list.append(CHECKS['marking-definition-type'])
+                if 'relationship-types' not in options.disabled:
+                    validator_list.append(CHECKS['relationship-types'])
+                if 'duplicate-ids' not in options.disabled:
+                    validator_list.append(CHECKS['duplicate-ids'])
+                if 'all-vocabs' not in options.disabled:
+                    if 'attack-motivation' not in options.disabled:
+                        validator_list.append(CHECKS['attack-motivation'])
+                    if 'attack-resource-level' not in options.disabled:
+                        validator_list.append(CHECKS['attack-resource-level'])
+                    if 'identity-class' not in options.disabled:
+                        validator_list.append(CHECKS['identity-class'])
+                    if 'indicator-types' not in options.disabled:
+                        validator_list.append(CHECKS['indicator-types'])
+                    if 'industry-sector' not in options.disabled:
+                        validator_list.append(CHECKS['industry-sector'])
+                    if 'malware-types' not in options.disabled:
+                        validator_list.append(CHECKS['malware-types'])
+                    if 'report-types' not in options.disabled:
+                        validator_list.append(CHECKS['report-types'])
+                    if 'threat-actor-types' not in options.disabled:
+                        validator_list.append(CHECKS['threat-actor-types'])
+                    if 'threat-actor-role' not in options.disabled:
+                        validator_list.append(CHECKS['threat-actor-role'])
+                    if 'threat-actor-sophistication' not in options.disabled:
+                        validator_list.append(CHECKS['threat-actor-sophistication'])
+                    if 'tool-types' not in options.disabled:
+                        validator_list.append(CHECKS['tool-types'])
+                    if 'region' not in options.disabled:
+                        validator_list.append(CHECKS['region'])
+                    if 'hash-algo' not in options.disabled:
+                        validator_list.append(CHECKS['hash-algo'])
+                    if 'windows-pebinary-type' not in options.disabled:
+                        validator_list.append(CHECKS['windows-pebinary-type'])
+                    if 'account-type' not in options.disabled:
+                        validator_list.append(CHECKS['account-type'])
+                if 'all-external-sources' not in options.disabled:
+                    if 'mime-type' not in options.disabled:
+                        validator_list.append(CHECKS['mime-type'])
+                    if 'protocols' not in options.disabled:
+                        validator_list.append(CHECKS['protocols'])
+                    if 'ipfix' not in options.disabled:
+                        validator_list.append(CHECKS['ipfix'])
+                    if 'http-request-headers' not in options.disabled:
+                        validator_list.append(CHECKS['http-request-headers'])
+                    if 'socket-options' not in options.disabled:
+                        validator_list.append(CHECKS['socket-options'])
+                    if 'pdf-doc-info' not in options.disabled:
+                        validator_list.append(CHECKS['pdf-doc-info'])
+                    if 'countries' not in options.disabled:
+                        validator_list.append(CHECKS['countries'])
+
+            if 'network-traffic-ports' not in options.disabled:
+                validator_list.append(CHECKS['network-traffic-ports'])
+            if 'extref-hashes' not in options.disabled:
+                validator_list.append(CHECKS['extref-hashes'])
+
+    # --enable
+    if options.enabled:
+        for check in options.enabled:
+            try:
+                if CHECKS[check] in validator_list:
+                    continue
+
+                if type(CHECKS[check]) is list:
+                    validator_list.extend(CHECKS[check])
+                else:
+                    validator_list.append(CHECKS[check])
+            except KeyError:
+                raise JSONError("%s is not a valid check!" % check)
+
+    return validator_list
diff --git a/stix2validator/validator.py b/stix2validator/validator.py
index 5517d1b..8b13dea 100644
--- a/stix2validator/validator.py
+++ b/stix2validator/validator.py
@@ -12,12 +12,15 @@ from jsonschema import exceptions as schema_exceptions
 import simplejson as json
 from six import iteritems, string_types, text_type
 
-from . import musts, output, shoulds
-from .errors import (JSONError, NoJSONFileFoundError, SchemaError,
-                     SchemaInvalidError, ValidationError, pretty_error)
-from .util import ValidationOptions, clear_requests_cache, init_requests_cache
-
-DEFAULT_SCHEMA_DIR = os.path.abspath(os.path.dirname(__file__) + '/schemas/')
+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 .v20 import musts as musts20
+from .v20 import shoulds as shoulds20
+from .v21 import musts as musts21
+from .v21 import shoulds as shoulds21
 
 
 def _is_iterable_non_string(val):
@@ -515,8 +518,7 @@ def find_schema(schema_dir, obj_type):
     """
     schema_filename = obj_type + '.json'
 
-    # If no schema directory given, use default bundled with this package
-    for root, dirnames, filenames in os.walk(schema_dir or DEFAULT_SCHEMA_DIR):
+    for root, dirnames, filenames in os.walk(schema_dir):
         if schema_filename in filenames:
             return os.path.join(root, schema_filename)
 
@@ -541,13 +543,15 @@ def load_schema(schema_path):
     return schema
 
 
-def _get_error_generator(type, obj, schema_dir=None, default='core'):
+def _get_error_generator(type, obj, schema_dir=None, version=DEFAULT_VER, default='core'):
     """Get a generator for validating against the schema for the given object type.
 
     Args:
         type (str): The object type to find the schema for.
         obj: The object to be validated.
         schema_dir (str): The path in which to search for schemas.
+        version (str): The version of the STIX specification to validate
+            against. Only used to find base schemas when schema_dir is None.
         default (str): If the schema for the given type cannot be found, use
             the one with this name instead.
 
@@ -556,6 +560,12 @@ def _get_error_generator(type, obj, schema_dir=None, default='core'):
         appropriate schema, or None if schema_dir is None and the schema
         cannot be found.
     """
+    # If no schema directory given, use default for the given STIX version,
+    # which comes bundled with this package
+    if schema_dir is None:
+        schema_dir = os.path.abspath(os.path.dirname(__file__) + '/schemas-'
+                                     + version + '/')
+
     try:
         schema_path = find_schema(schema_dir, type)
         schema = load_schema(schema_path)
@@ -591,6 +601,32 @@ def _get_error_generator(type, obj, schema_dir=None, default='core'):
     return error_gen
 
 
+def _get_musts(options):
+    """Return the list of 'MUST' validators for the correct version of STIX.
+
+    Args:
+        options: ValidationOptions instance with validation options for this
+            validation run, including the STIX spec version.
+    """
+    if options.version == '2.0':
+        return musts20.list_musts(options)
+    else:
+        return musts21.list_musts(options)
+
+
+def _get_shoulds(options):
+    """Return the list of 'SHOULD' validators for the correct version of STIX.
+
+    Args:
+        options: ValidationOptions instance with validation options for this
+            validation run, including the STIX spec version.
+    """
+    if options.version == '2.0':
+        return shoulds20.list_shoulds(options)
+    else:
+        return shoulds21.list_shoulds(options)
+
+
 def _schema_validate(sdo, options):
     """Set up validation of a single STIX object against its type's schema.
     This does no actual validation; it just returns generators which must be
@@ -613,7 +649,7 @@ def _schema_validate(sdo, options):
         error_prefix = ''
 
     # Get validator for built-in schema
-    base_sdo_errors = _get_error_generator(sdo['type'], sdo)
+    base_sdo_errors = _get_error_generator(sdo['type'], sdo, version=options.version)
     if base_sdo_errors:
         error_gens.append((base_sdo_errors, error_prefix))
 
@@ -627,19 +663,20 @@ def _schema_validate(sdo, options):
     if sdo['type'] == 'observed-data' and 'objects' in sdo:
         # Check if observed data property is in dictionary format
         if not isinstance(sdo['objects'], dict):
-            error_gens.append(([JSONError("Observed Data objects must be in dict format.", error_prefix)],
+            error_gens.append(([schema_exceptions.ValidationError("Observed Data objects must be in dict format.", error_prefix)],
                               error_prefix))
             return error_gens
 
         for key, obj in iteritems(sdo['objects']):
             if 'type' not in obj:
-                error_gens.append(([JSONError("Observable object must contain a 'type' property.", error_prefix)],
+                error_gens.append(([schema_exceptions.ValidationError("Observable object must contain a 'type' property.", error_prefix)],
                                    error_prefix + 'object \'' + key + '\': '))
                 continue
             # Get validator for built-in schemas
             base_obs_errors = _get_error_generator(obj['type'],
                                                    obj,
                                                    None,
+                                                   options.version,
                                                    'cyber-observable-core')
             if base_obs_errors:
                 error_gens.append((base_obs_errors,
@@ -649,6 +686,7 @@ def _schema_validate(sdo, options):
             custom_obs_errors = _get_error_generator(obj['type'],
                                                      obj,
                                                      options.schema_dir,
+                                                     options.version,
                                                      'cyber-observable-core')
             if custom_obs_errors:
                 error_gens.append((custom_obs_errors,
@@ -692,8 +730,8 @@ def validate_instance(instance, options=None):
         error_gens += _schema_validate(instance, options)
 
     # Custom validation
-    must_checks = musts.list_musts(options)
-    should_checks = shoulds.list_shoulds(options)
+    must_checks = _get_musts(options)
+    should_checks = _get_shoulds(options)
     output.info("Running the following additional checks: %s."
                 % ", ".join(x.__name__ for x in chain(must_checks, should_checks)))
     try:


Test patch:
diff --git a/stix2validator/test/__init__.py b/stix2validator/test/__init__.py
index 7db8f4c..e69de29 100644
--- a/stix2validator/test/__init__.py
+++ b/stix2validator/test/__init__.py
@@ -1,50 +0,0 @@
-import os
-import unittest
-
-from .. import ValidationOptions, validate_parsed_json
-
-
-class ValidatorTest(unittest.TestCase):
-    options = ValidationOptions(strict=True)
-    custom_schemas = os.path.abspath(os.path.dirname(__file__) + "/test_schemas")
-
-    def check_ignore(self, instance, code):
-        """Test that the given instance is valid if the given check is ignored.
-
-        Args:
-            instance: The JSON string to be validated.
-            error: The numerical code of the check to be ignored.
-        """
-        self.assertTrueWithOptions(instance, disabled=code)
-
-    def assertTrueWithOptions(self, instance, **kwargs):
-        """Test that the given instance is valid when using the validation
-        options provided by kwargs.
-
-        Args:
-            instance: The JSON string to be validated.
-            kwargs: Any number of keyword arguments to be passed to the
-                    ValidationOptions constructor.
-        """
-        if 'strict' in kwargs:
-            options = ValidationOptions(**kwargs)
-        else:
-            options = ValidationOptions(strict=True, **kwargs)
-        results = validate_parsed_json(instance, options)
-        self.assertTrue(results.is_valid)
-
-    def assertFalseWithOptions(self, instance, **kwargs):
-        """Test that the given instance is NOT valid when using the validation
-        options provided by kwargs.
-
-        Args:
-            instance: The JSON string to be validated.
-            kwargs: Any number of keyword arguments to be passed to the
-                    ValidationOptions constructor.
-        """
-        if 'strict' in kwargs:
-            options = ValidationOptions(**kwargs)
-        else:
-            options = ValidationOptions(strict=True, **kwargs)
-        results = validate_parsed_json(instance, options)
-        self.assertEqual(results.is_valid, False)
diff --git a/stix2validator/test/v20/__init__.py b/stix2validator/test/v20/__init__.py
new file mode 100644
index 0000000..5ab0be0
--- /dev/null
+++ b/stix2validator/test/v20/__init__.py
@@ -0,0 +1,50 @@
+import os
+import unittest
+
+from ... import ValidationOptions, validate_parsed_json
+
+
+class ValidatorTest(unittest.TestCase):
+    options = ValidationOptions(strict=True, version="2.0")
+    custom_schemas = os.path.abspath(os.path.dirname(__file__) + "/test_schemas")
+
+    def check_ignore(self, instance, code):
+        """Test that the given instance is valid if the given check is ignored.
+
+        Args:
+            instance: The JSON string to be validated.
+            error: The numerical code of the check to be ignored.
+        """
+        self.assertTrueWithOptions(instance, disabled=code)
+
+    def assertTrueWithOptions(self, instance, **kwargs):
+        """Test that the given instance is valid when using the validation
+        options provided by kwargs.
+
+        Args:
+            instance: The JSON string to be validated.
+            kwargs: Any number of keyword arguments to be passed to the
+                    ValidationOptions constructor.
+        """
+        if 'strict' in kwargs:
+            options = ValidationOptions(version="2.0", **kwargs)
+        else:
+            options = ValidationOptions(strict=True, version="2.0", **kwargs)
+        results = validate_parsed_json(instance, options)
+        self.assertTrue(results.is_valid)
+
+    def assertFalseWithOptions(self, instance, **kwargs):
+        """Test that the given instance is NOT valid when using the validation
+        options provided by kwargs.
+
+        Args:
+            instance: The JSON string to be validated.
+            kwargs: Any number of keyword arguments to be passed to the
+                    ValidationOptions constructor.
+        """
+        if 'strict' in kwargs:
+            options = ValidationOptions(version="2.0", **kwargs)
+        else:
+            options = ValidationOptions(strict=True, version="2.0", **kwargs)
+        results = validate_parsed_json(instance, options)
+        self.assertEqual(results.is_valid, False)
diff --git a/stix2validator/test/attack_pattern_tests.py b/stix2validator/test/v20/attack_pattern_tests.py
similarity index 98%
rename from stix2validator/test/attack_pattern_tests.py
rename to stix2validator/test/v20/attack_pattern_tests.py
index e40e7f4..66a9414 100644
--- a/stix2validator/test/attack_pattern_tests.py
+++ b/stix2validator/test/v20/attack_pattern_tests.py
@@ -2,7 +2,7 @@ import copy
 import json
 
 from . import ValidatorTest
-from .. import validate_parsed_json, validate_string
+from ... import validate_parsed_json, validate_string
 
 VALID_ATTACK_PATTERN = u"""
 {
diff --git a/stix2validator/test/bundle_tests.py b/stix2validator/test/v20/bundle_tests.py
similarity index 98%
rename from stix2validator/test/bundle_tests.py
rename to stix2validator/test/v20/bundle_tests.py
index ee39d7b..e06539c 100644
--- a/stix2validator/test/bundle_tests.py
+++ b/stix2validator/test/v20/bundle_tests.py
@@ -4,7 +4,7 @@ import json
 import pytest
 
 from . import ValidatorTest
-from .. import ValidationError
+from ... import ValidationError
 
 VALID_BUNDLE = u"""
 {
diff --git a/stix2validator/test/custom_obj_tests.py b/stix2validator/test/v20/custom_obj_tests.py
similarity index 97%
rename from stix2validator/test/custom_obj_tests.py
rename to stix2validator/test/v20/custom_obj_tests.py
index 14b0775..e2a17fa 100644
--- a/stix2validator/test/custom_obj_tests.py
+++ b/stix2validator/test/v20/custom_obj_tests.py
@@ -2,7 +2,7 @@ import copy
 import json
 
 from . import ValidatorTest
-from .. import ValidationError, validate_parsed_json, validate_string
+from ... import ValidationError, validate_parsed_json, validate_string
 
 VALID_CUSTOM_OBJECT = u"""
 {
diff --git a/stix2validator/test/identity_tests.py b/stix2validator/test/v20/identity_tests.py
similarity index 87%
rename from stix2validator/test/identity_tests.py
rename to stix2validator/test/v20/identity_tests.py
index cdadab1..16838f3 100644
--- a/stix2validator/test/identity_tests.py
+++ b/stix2validator/test/v20/identity_tests.py
@@ -1,9 +1,10 @@
 import copy
 import json
 
+from jsonschema import exceptions
+
 from . import ValidatorTest
-from .. import validate_parsed_json, validate_string
-from ..errors import JSONError
+from ... import validate_parsed_json, validate_string
 
 VALID_IDENTITY = u"""
 {
@@ -21,7 +22,7 @@ class IdentityTestCases(ValidatorTest):
     valid_identity = json.loads(VALID_IDENTITY)
 
     def test_invalid_check(self):
-        self.assertRaises(JSONError, self.assertFalseWithOptions,
+        self.assertRaises(exceptions.ValidationError, self.assertFalseWithOptions,
                           self.valid_identity, enabled='abc')
 
     def test_wellformed_identity(self):
diff --git a/stix2validator/test/indicator_tests.py b/stix2validator/test/v20/indicator_tests.py
similarity index 98%
rename from stix2validator/test/indicator_tests.py
rename to stix2validator/test/v20/indicator_tests.py
index 9f33896..1d0877d 100644
--- a/stix2validator/test/indicator_tests.py
+++ b/stix2validator/test/v20/indicator_tests.py
@@ -2,7 +2,7 @@ import copy
 import json
 
 from . import ValidatorTest
-from .. import ValidationOptions, validate_parsed_json, validate_string
+from ... import ValidationOptions, validate_parsed_json, validate_string
 
 VALID_INDICATOR = u"""
 {
@@ -216,7 +216,7 @@ class IndicatorTestCases(ValidatorTest):
         indicator['name'] = 'Foobar'
         objects = [indicator, ADDTNL_INVALID_SCHEMA]
 
-        options = ValidationOptions(schema_dir=self.custom_schemas)
+        options = ValidationOptions(version="2.0", schema_dir=self.custom_schemas)
         results = validate_parsed_json(objects, options)
         assert results[0].is_valid
         assert not results[1].is_valid
diff --git a/stix2validator/test/intrusion_set_tests.py b/stix2validator/test/v20/intrusion_set_tests.py
similarity index 97%
rename from stix2validator/test/intrusion_set_tests.py
rename to stix2validator/test/v20/intrusion_set_tests.py
index 29c7393..f6d07e8 100644
--- a/stix2validator/test/intrusion_set_tests.py
+++ b/stix2validator/test/v20/intrusion_set_tests.py
@@ -2,7 +2,7 @@ import copy
 import json
 
 from . import ValidatorTest
-from .. import validate_parsed_json, validate_string
+from ... import validate_parsed_json, validate_string
 
 VALID_INTRUSION_SET = u"""
 {
diff --git a/stix2validator/test/malware_tests.py b/stix2validator/test/v20/malware_tests.py
similarity index 96%
rename from stix2validator/test/malware_tests.py
rename to stix2validator/test/v20/malware_tests.py
index 5470015..5a1e41f 100644
--- a/stix2validator/test/malware_tests.py
+++ b/stix2validator/test/v20/malware_tests.py
@@ -2,7 +2,7 @@ import copy
 import json
 
 from . import ValidatorTest
-from .. import validate_parsed_json, validate_string
+from ... import validate_parsed_json, validate_string
 
 VALID_MALWARE = u"""
 {
diff --git a/stix2validator/test/marking_definition_tests.py b/stix2validator/test/v20/marking_definition_tests.py
similarity index 98%
rename from stix2validator/test/marking_definition_tests.py
rename to stix2validator/test/v20/marking_definition_tests.py
index fe1b4f9..f4545c9 100644
--- a/stix2validator/test/marking_definition_tests.py
+++ b/stix2validator/test/v20/marking_definition_tests.py
@@ -2,7 +2,7 @@ import copy
 import json
 
 from . import ValidatorTest
-from .. import validate_parsed_json, validate_string
+from ... import validate_parsed_json, validate_string
 
 VALID_MARKING_DEFINITION = u"""
 {
diff --git a/stix2validator/test/v20/misc_tests.py b/stix2validator/test/v20/misc_tests.py
new file mode 100644
index 0000000..ed2c2d0
--- /dev/null
+++ b/stix2validator/test/v20/misc_tests.py
@@ -0,0 +1,133 @@
+from io import open
+import logging
+import os
+import re
+import sys
+
+import pytest
+
+from ... import (ValidationOptions, print_results, run_validation,
+                 validate_file, validate_string)
+from .tool_tests import VALID_TOOL
+
+logging.basicConfig(stream=sys.stdout, level=logging.INFO, format='%(message)s')
+logger = logging.getLogger(__name__)
+
+EXAMPLE = os.path.join(os.path.dirname(os.path.realpath(__file__)),
+                       '..', '..', 'schemas-2.0', 'examples',
+                       'indicator-to-campaign-relationship.json')
+IDENTITY = os.path.join(os.path.dirname(os.path.realpath(__file__)),
+                        'test_examples', 'identity.json')
+IDENTITY_CUSTOM = os.path.join(os.path.dirname(os.path.realpath(__file__)),
+                               'test_examples', 'identity_custom.json')
+INVALID_BRACES = os.path.join(os.path.dirname(os.path.realpath(__file__)),
+                              'test_examples', 'invalid_braces.json')
+INVALID_COMMA = os.path.join(os.path.dirname(os.path.realpath(__file__)),
+                             'test_examples', 'invalid_comma.json')
+INVALID_IDENTITY = os.path.join(os.path.dirname(os.path.realpath(__file__)),
+                                'test_examples', 'invalid_identity.json')
+INVALID_TIMESTAMP = os.path.join(os.path.dirname(os.path.realpath(__file__)),
+                                 'test_examples', 'invalid_timestamp.json')
+
+
+def test_run_validation(caplog):
+    options = ValidationOptions(files=[EXAMPLE], version="2.0")
+    results = run_validation(options)
+    assert results[0].is_valid
+
+    print_results(results)
+    assert 'STIX JSON: Valid' in caplog.text
+
+
+def test_run_validation_nonexistent_file():
+    options = ValidationOptions(files='asdf.json', version="2.0")
+    with pytest.raises(SystemExit):
+        run_validation(options)
+
+
+def test_run_validation_silent(caplog):
+    options = ValidationOptions(files=[EXAMPLE], silent=True, version="2.0")
+    results = run_validation(options)
+    print_results(results)
+    assert caplog.text == ''
+
+
+def test_validate_file(caplog):
+    results = validate_file(EXAMPLE, ValidationOptions(version="2.0"))
+    print_results(results)
+    assert results.is_valid
+
+    print_results(results)
+    assert 'STIX JSON: Valid' in caplog.text
+
+
+def test_validate_file_warning(caplog):
+    results = validate_file(IDENTITY_CUSTOM, ValidationOptions(version="2.0"))
+    assert results.is_valid
+
+    print_results(results)
+    assert re.search("Custom property .+ should have a type that starts with 'x_'", caplog.text)
+
+
+def test_validate_file_invalid_brace(caplog):
+    results = validate_file(INVALID_BRACES, ValidationOptions(version="2.0"))
+    assert not results.is_valid
+
+    print_results(results)
+    assert 'Fatal Error: Invalid JSON input' in caplog.text
+
+
+def test_validate_file_invalid_comma(caplog):
+    results = validate_file(INVALID_COMMA, ValidationOptions(version="2.0"))
+    assert not results.is_valid
+
+    print_results(results)
+    assert 'Fatal Error: Expecting property name' in caplog.text
+
+
+def test_validate_file_invalid_missing_modified(caplog):
+    results = validate_file(INVALID_IDENTITY, ValidationOptions(version="2.0"))
+    assert not results.is_valid
+
+    print_results(results)
+    assert "'modified' is a required property" in caplog.text
+
+
+def test_validate_string(caplog):
+    with open(IDENTITY, encoding='utf-8') as f:
+        results = validate_string(f.read(), ValidationOptions(version="2.0"))
+    assert results.is_valid
+
+    print_results(results)
+    assert 'STIX JSON: Valid' in caplog.text
+
+
+def test_validate_string_warning(caplog):
+    with open(IDENTITY_CUSTOM, encoding='utf-8') as f:
+        results = validate_string(f.read(), ValidationOptions(version="2.0"))
+    assert results.is_valid
+
+    print_results(results)
+    assert re.search("Custom property .+ should have a type that starts with 'x_'", caplog.text)
+
+
+def test_validate_string_invalid_timestamp(caplog):
+    with open(INVALID_TIMESTAMP, encoding='utf-8') as f:
+        results = validate_string(f.read(), ValidationOptions(version="2.0"))
+    assert not results.is_valid
+
+    print_results(results)
+    assert re.search("'modified' .+ must be later or equal to 'created'", caplog.text)
+
+
+def test_print_results_invalid_parameter():
+    with pytest.raises(ValueError) as excinfo:
+        print_results('these results are valid')
+    assert 'Argument to print_results() must be' in str(excinfo)
+
+
+def test_run_validation_stdin(monkeypatch):
+    monkeypatch.setattr(sys.stdin, 'read', lambda: VALID_TOOL)
+    options = ValidationOptions(files=sys.stdin, version="2.0")
+    results = run_validation(options)
+    assert results[0].is_valid
diff --git a/stix2validator/test/v20/multi_obj_tests.py b/stix2validator/test/v20/multi_obj_tests.py
new file mode 100644
index 0000000..633ae5b
--- /dev/null
+++ b/stix2validator/test/v20/multi_obj_tests.py
@@ -0,0 +1,51 @@
+import copy
+import json
+
+from . import ValidatorTest
+from ... import ValidationOptions, validate_parsed_json, validate_string
+
+MULTI_OBJ_JSON = u"""
+[
+    {
+      "type": "attack-pattern",
+      "id": "attack-pattern--0c7b5b88-8ff7-4a4d-aa9d-feb398cd0061",
+      "created": "2016-02-12T08:17:27.000Z",
+      "modified": "2016-02-12T08:17:27.000Z",
+      "name": "Spear Phishing",
+      "description": "...",
+      "external_references": [
+        {
+          "source_name": "capec",
+          "external_id": "CAPEC-463"
+        }
+      ]
+    },
+
+    {
+      "type": "identity",
+      "id": "identity--2d1c6ab3-5e4e-48ac-a32b-f0c01c2836a8",
+      "created": "2014-08-08T15:50:10.983Z",
+      "modified": "2014-08-08T15:50:10.983Z",
+      "name": "ACME Widget, Inc.",
+      "identity_class": "organization"
+    }
+]
+"""
+
+
+class MultiObjTestCases(ValidatorTest):
+    valid_objs = json.loads(MULTI_OBJ_JSON)
+
+    def test_both_valid(self):
+        obj_results = validate_string(MULTI_OBJ_JSON, ValidationOptions(version="2.0"))
+        self.assertEqual(len(obj_results), 2)
+        self.assertTrue(all(r.is_valid for r in obj_results))
+
+    def test_one_valid_one_invalid(self):
+        objs = copy.deepcopy(self.valid_objs)
+        objs[1]["id"] = objs[1]["id"].replace("identity", "abc")
+        obj_results = validate_parsed_json(objs, ValidationOptions(version="2.0"))
+
+        self.assertEqual(len(obj_results), 2)
+        self.assertTrue(obj_results[0].is_valid)
+        self.assertFalse(obj_results[1].is_valid)
diff --git a/stix2validator/test/network_traffic_tests.py b/stix2validator/test/v20/network_traffic_tests.py
similarity index 99%
rename from stix2validator/test/network_traffic_tests.py
rename to stix2validator/test/v20/network_traffic_tests.py
index 939fc91..5cdd82e 100644
--- a/stix2validator/test/network_traffic_tests.py
+++ b/stix2validator/test/v20/network_traffic_tests.py
@@ -2,7 +2,7 @@ import copy
 import json
 
 from . import ValidatorTest
-from .. import validate_string
+from ... import validate_string
 
 VALID_NETWORK_TRAFFIC_DEFINITION = u"""
 {
diff --git a/stix2validator/test/observed_data_tests.py b/stix2validator/test/v20/observed_data_tests.py
similarity index 99%
rename from stix2validator/test/observed_data_tests.py
rename to stix2validator/test/v20/observed_data_tests.py
index e787223..ff989b8 100644
--- a/stix2validator/test/observed_data_tests.py
+++ b/stix2validator/test/v20/observed_data_tests.py
@@ -2,7 +2,7 @@ import copy
 import json
 
 from . import ValidatorTest
-from .. import validate_parsed_json, validate_string
+from ... import validate_parsed_json, validate_string
 
 VALID_OBSERVED_DATA_DEFINITION = u"""
 {
diff --git a/stix2validator/test/relationship_tests.py b/stix2validator/test/v20/relationship_tests.py
similarity index 95%
rename from stix2validator/test/relationship_tests.py
rename to stix2validator/test/v20/relationship_tests.py
index 1ad821f..2f84f84 100644
--- a/stix2validator/test/relationship_tests.py
+++ b/stix2validator/test/v20/relationship_tests.py
@@ -2,7 +2,7 @@ import copy
 import json
 
 from . import ValidatorTest
-from .. import validate_parsed_json, validate_string
+from ... import validate_parsed_json, validate_string
 
 VALID_RELATIONSHIP = u"""
 {
@@ -60,7 +60,7 @@ class RelationshipTestCases(ValidatorTest):
     def test_relationship_types_invalid_type(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['target_ref'] = "campaign--9c1f891b-659a-4f7f-80ea-31b940d417b5"
         relationship['relationship_type'] = "mitigates"
         results = validate_parsed_json(relationship, self.options)
         self.assertEqual(results.is_valid, False)
@@ -80,7 +80,7 @@ class RelationshipTestCases(ValidatorTest):
     def test_relationship_types_valid(self):
         relationship = copy.deepcopy(self.valid_relationship)
         relationship['source_ref'] = "tool--31b940d4-6f7f-459a-80ea-9c1f17b5891b"
-        relationship['target_ref'] = "vulnerability--9c1f891b-459a-6f7f-80ea-31b17b5940d4"
+        relationship['target_ref'] = "vulnerability--9c1f891b-659a-4f7f-80ea-31b17b5940d4"
         relationship['relationship_type'] = "targets"
         results = validate_parsed_json(relationship, self.options)
         self.assertTrue(results.is_valid)
@@ -88,7 +88,7 @@ class RelationshipTestCases(ValidatorTest):
     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['target_ref'] = "campaign--9c1f891b-659a-4f7f-80ea-31b940d417b5"
         relationship['relationship_type'] = "related-to"
         results = validate_parsed_json(relationship, self.options)
         self.assertTrue(results.is_valid)
diff --git a/stix2validator/test/report_tests.py b/stix2validator/test/v20/report_tests.py
similarity index 96%
rename from stix2validator/test/report_tests.py
rename to stix2validator/test/v20/report_tests.py
index eb96e19..046a193 100644
--- a/stix2validator/test/report_tests.py
+++ b/stix2validator/test/v20/report_tests.py
@@ -2,7 +2,7 @@ import copy
 import json
 
 from . import ValidatorTest
-from .. import validate_parsed_json, validate_string
+from ... import validate_parsed_json, validate_string
 
 VALID_REPORT = u"""
 {
diff --git a/stix2validator/test/sighting_tests.py b/stix2validator/test/v20/sighting_tests.py
similarity index 97%
rename from stix2validator/test/sighting_tests.py
rename to stix2validator/test/v20/sighting_tests.py
index 6dfdcc3..0995271 100644
--- a/stix2validator/test/sighting_tests.py
+++ b/stix2validator/test/v20/sighting_tests.py
@@ -2,7 +2,7 @@ import copy
 import json
 
 from . import ValidatorTest
-from .. import validate_string
+from ... import validate_string
 
 VALID_SIGHTING = u"""
 {
diff --git a/stix2validator/test/test_examples/identity.json b/stix2validator/test/v20/test_examples/identity.json
similarity index 100%
rename from stix2validator/test/test_examples/identity.json
rename to stix2validator/test/v20/test_examples/identity.json
diff --git a/stix2validator/test/test_examples/identity_custom.json b/stix2validator/test/v20/test_examples/identity_custom.json
similarity index 100%
rename from stix2validator/test/test_examples/identity_custom.json
rename to stix2validator/test/v20/test_examples/identity_custom.json
diff --git a/stix2validator/test/test_examples/identity_warning.json b/stix2validator/test/v20/test_examples/identity_warning.json
similarity index 100%
rename from stix2validator/test/test_examples/identity_warning.json
rename to stix2validator/test/v20/test_examples/identity_warning.json
diff --git a/stix2validator/test/test_examples/invalid_braces.json b/stix2validator/test/v20/test_examples/invalid_braces.json
similarity index 100%
rename from stix2validator/test/test_examples/invalid_braces.json
rename to stix2validator/test/v20/test_examples/invalid_braces.json
diff --git a/stix2validator/test/test_examples/invalid_comma.json b/stix2validator/test/v20/test_examples/invalid_comma.json
similarity index 100%
rename from stix2validator/test/test_examples/invalid_comma.json
rename to stix2validator/test/v20/test_examples/invalid_comma.json
diff --git a/stix2validator/test/test_examples/invalid_identity.json b/stix2validator/test/v20/test_examples/invalid_identity.json
similarity index 100%
rename from stix2validator/test/test_examples/invalid_identity.json
rename to stix2validator/test/v20/test_examples/invalid_identity.json
diff --git a/stix2validator/test/test_examples/invalid_timestamp.json b/stix2validator/test/v20/test_examples/invalid_timestamp.json
similarity index 100%
rename from stix2validator/test/test_examples/invalid_timestamp.json
rename to stix2validator/test/v20/test_examples/invalid_timestamp.json
diff --git a/stix2validator/test/test_schemas/file.json b/stix2validator/test/v20/test_schemas/file.json
similarity index 100%
rename from stix2validator/test/test_schemas/file.json
rename to stix2validator/test/v20/test_schemas/file.json
diff --git a/stix2validator/test/test_schemas/indicator.json b/stix2validator/test/v20/test_schemas/indicator.json
similarity index 100%
rename from stix2validator/test/test_schemas/indicator.json
rename to stix2validator/test/v20/test_schemas/indicator.json
diff --git a/stix2validator/test/test_schemas/x-foo-bar.json b/stix2validator/test/v20/test_schemas/x-foo-bar.json
similarity index 100%
rename from stix2validator/test/test_schemas/x-foo-bar.json
rename to stix2validator/test/v20/test_schemas/x-foo-bar.json
diff --git a/stix2validator/test/test_schemas/x-new-observable.json b/stix2validator/test/v20/test_schemas/x-new-observable.json
similarity index 100%
rename from stix2validator/test/test_schemas/x-new-observable.json
rename to stix2validator/test/v20/test_schemas/x-new-observable.json
diff --git a/stix2validator/test/test_schemas/x-new-type.json b/stix2validator/test/v20/test_schemas/x-new-type.json
similarity index 100%
rename from stix2validator/test/test_schemas/x-new-type.json
rename to stix2validator/test/v20/test_schemas/x-new-type.json
diff --git a/stix2validator/test/threat_actor_tests.py b/stix2validator/test/v20/threat_actor_tests.py
similarity index 97%
rename from stix2validator/test/threat_actor_tests.py
rename to stix2validator/test/v20/threat_actor_tests.py
index bdf98ed..afc2e90 100644
--- a/stix2validator/test/threat_actor_tests.py
+++ b/stix2validator/test/v20/threat_actor_tests.py
@@ -2,7 +2,7 @@ import copy
 import json
 
 from . import ValidatorTest
-from .. import validate_parsed_json, validate_string
+from ... import validate_parsed_json, validate_string
 
 VALID_THREAT_ACTOR = u"""
 {
diff --git a/stix2validator/test/tool_tests.py b/stix2validator/test/v20/tool_tests.py
similarity index 98%
rename from stix2validator/test/tool_tests.py
rename to stix2validator/test/v20/tool_tests.py
index e68c212..2b056dd 100644
--- a/stix2validator/test/tool_tests.py
+++ b/stix2validator/test/v20/tool_tests.py
@@ -2,7 +2,7 @@ import copy
 import json
 
 from . import ValidatorTest
-from .. import validate_parsed_json, validate_string
+from ... import validate_parsed_json, validate_string
 
 VALID_TOOL = u"""
 {
diff --git a/stix2validator/test/v20/util_tests.py b/stix2validator/test/v20/util_tests.py
new file mode 100644
index 0000000..3d4f53e
--- /dev/null
+++ b/stix2validator/test/v20/util_tests.py
@@ -0,0 +1,49 @@
+from ... import parse_args, validate_string
+from .indicator_tests import VALID_INDICATOR
+
+
+def test_parse_args():
+    args = [
+        '-r',
+        '-s',
+        '/tmp/schemas/',
+        '--version',
+        '2.0',
+        '-q',
+        '-d',
+        'format-checks',
+        '-e',
+        'custom-prefix',
+        '--strict',
+        '--strict-types',
+        '--strict-properties',
+        '--no-cache',
+        '--refresh-cache',
+        '--clear-cache',
+        '/tmp/mystix.json',
+    ]
+    options = parse_args(args, True)
+
+    assert options.version == '2.0'
+    assert options.verbose is False
+    assert options.silent is True
+    assert options.files == ['/tmp/mystix.json']
+    assert options.recursive is True
+    assert options.schema_dir == '/tmp/schemas/'
+    assert options.disabled == ['format-checks']
+    assert options.enabled == ['custom-prefix']
+    assert options.strict is True
+    assert options.strict_types is True
+    assert options.strict_properties is True
+    assert options.no_cache is True
+    assert options.refresh_cache is True
+    assert options.clear_cache is True
+
+
+def test_parse_args_no_files():
+    args = ['--version', '2.0']
+    options = parse_args(args)
+    assert options.files == ""
+
+    results = validate_string(VALID_INDICATOR, options)
+    assert results.is_valid
diff --git a/stix2validator/test/vulnerability_tests.py b/stix2validator/test/v20/vulnerability_tests.py
similarity index 98%
rename from stix2validator/test/vulnerability_tests.py
rename to stix2validator/test/v20/vulnerability_tests.py
index 2010ee6..1d0d0e1 100644
--- a/stix2validator/test/vulnerability_tests.py
+++ b/stix2validator/test/v20/vulnerability_tests.py
@@ -2,7 +2,7 @@ import copy
 import json
 
 from . import ValidatorTest
-from .. import validate_parsed_json, validate_string
+from ... import validate_parsed_json, validate_string
 
 VALID_VULNERABILITY = u"""
 {
diff --git a/stix2validator/test/v21/__init__.py b/stix2validator/test/v21/__init__.py
new file mode 100644
index 0000000..8fa2d01
--- /dev/null
+++ b/stix2validator/test/v21/__init__.py
@@ -0,0 +1,50 @@
+import os
+import unittest
+
+from ... import ValidationOptions, validate_parsed_json
+
+
+class ValidatorTest(unittest.TestCase):
+    options = ValidationOptions(strict=True)
+    custom_schemas = os.path.abspath(os.path.dirname(__file__) + "/test_schemas")
+
+    def check_ignore(self, instance, code):
+        """Test that the given instance is valid if the given check is ignored.
+
+        Args:
+            instance: The JSON string to be validated.
+            error: The numerical code of the check to be ignored.
+        """
+        self.assertTrueWithOptions(instance, disabled=code)
+
+    def assertTrueWithOptions(self, instance, **kwargs):
+        """Test that the given instance is valid when using the validation
+        options provided by kwargs.
+
+        Args:
+            instance: The JSON string to be validated.
+            kwargs: Any number of keyword arguments to be passed to the
+                    ValidationOptions constructor.
+        """
+        if 'strict' in kwargs:
+            options = ValidationOptions(**kwargs)
+        else:
+            options = ValidationOptions(strict=True, **kwargs)
+        results = validate_parsed_json(instance, options)
+        self.assertTrue(results.is_valid)
+
+    def assertFalseWithOptions(self, instance, **kwargs):
+        """Test that the given instance is NOT valid when using the validation
+        options provided by kwargs.
+
+        Args:
+            instance: The JSON string to be validated.
+            kwargs: Any number of keyword arguments to be passed to the
+                    ValidationOptions constructor.
+        """
+        if 'strict' in kwargs:
+            options = ValidationOptions(**kwargs)
+        else:
+            options = ValidationOptions(strict=True, **kwargs)
+        results = validate_parsed_json(instance, options)
+        self.assertEqual(results.is_valid, False)
diff --git a/stix2validator/test/v21/attack_pattern_tests.py b/stix2validator/test/v21/attack_pattern_tests.py
new file mode 100644
index 0000000..2114afb
--- /dev/null
+++ b/stix2validator/test/v21/attack_pattern_tests.py
@@ -0,0 +1,95 @@
+import copy
+import json
+
+from . import ValidatorTest
+from ... import validate_parsed_json, validate_string
+
+VALID_ATTACK_PATTERN = u"""
+{
+  "type": "attack-pattern",
+  "spec_version": "2.1",
+  "id": "attack-pattern--0c7b5b88-8ff7-4a4d-aa9d-feb398cd0061",
+  "created": "2016-02-12T08:17:27.000Z",
+  "modified": "2016-02-12T08:17:27.000Z",
+  "name": "Spear Phishing",
+  "description": "...",
+  "external_references": [
+    {
+      "source_name": "capec",
+      "external_id": "CAPEC-463"
+    }
+  ]
+}
+"""
+
+
+class AttackPatternTestCases(ValidatorTest):
+    valid_attack_pattern = json.loads(VALID_ATTACK_PATTERN)
+
+    def test_wellformed_attack_pattern(self):
+        results = validate_string(VALID_ATTACK_PATTERN, self.options)
+        self.assertTrue(results.is_valid)
+
+    def test_valid_capec_id(self):
+        attack_pattern = copy.deepcopy(self.valid_attack_pattern)
+        ext_refs = attack_pattern['external_references']
+        ext_refs[0]['external_id'] = "CAPEC-abc"
+        results = validate_parsed_json(attack_pattern, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_external_reference_no_external_id(self):
+        attack_pattern = copy.deepcopy(self.valid_attack_pattern)
+        ext_refs = attack_pattern['external_references']
+        del ext_refs[0]['external_id']
+        results = validate_parsed_json(attack_pattern, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_invalid_property_prefix(self):
+        attack_pattern = copy.deepcopy(self.valid_attack_pattern)
+        attack_pattern['x-something'] = "some value"
+        results = validate_parsed_json(attack_pattern, self.options)
+        self.assertEqual(results.is_valid, False)
+
+        self.assertFalseWithOptions(attack_pattern, enabled='custom-prefix-lax')
+
+    def test_invalid_property_prefix_lax(self):
+        attack_pattern = copy.deepcopy(self.valid_attack_pattern)
+        attack_pattern['x_something'] = "some value"
+        results = validate_parsed_json(attack_pattern, self.options)
+        self.assertEqual(results.is_valid, False)
+
+        self.assertTrueWithOptions(attack_pattern, enabled='custom-prefix-lax')
+        self.assertFalseWithOptions(attack_pattern, disabled='custom-prefix-lax')
+        self.check_ignore(attack_pattern, 'custom-prefix')
+
+    def test_valid_property_prefix(self):
+        attack_pattern = copy.deepcopy(self.valid_attack_pattern)
+        attack_pattern['x_source_something'] = "some value"
+        results = validate_parsed_json(attack_pattern, self.options)
+        self.assertTrue(results.is_valid)
+
+    def test_invalid_timestamp(self):
+        attack_pattern = copy.deepcopy(self.valid_attack_pattern)
+        attack_pattern['modified'] = "2016-13-12T08:17:27.000Z"
+        self.assertFalseWithOptions(attack_pattern)
+
+        attack_pattern['modified'] = "2016-03-42T08:17:27.000Z"
+        self.assertFalseWithOptions(attack_pattern)
+
+        attack_pattern['modified'] = "2016-03-00T08:17:27.000Z"
+        self.assertFalseWithOptions(attack_pattern)
+
+        attack_pattern['modified'] = "2016-03-12T99:17:27.000Z"
+        self.assertFalseWithOptions(attack_pattern)
+
+        attack_pattern['modified'] = "2016-03-12T08:99:27.000Z"
+        self.assertFalseWithOptions(attack_pattern)
+
+        attack_pattern['modified'] = "2016-11-31T08:17:27.000Z"
+        self.assertFalseWithOptions(attack_pattern)
+
+        attack_pattern['modified'] = "2017-02-29T08:17:27.000Z"
+        self.assertFalseWithOptions(attack_pattern)
+
+        attack_pattern['modified'] = "2016-02-29T08:17:27.000Z"
+        self.assertTrueWithOptions(attack_pattern)
diff --git a/stix2validator/test/v21/bundle_tests.py b/stix2validator/test/v21/bundle_tests.py
new file mode 100644
index 0000000..9267b5b
--- /dev/null
+++ b/stix2validator/test/v21/bundle_tests.py
@@ -0,0 +1,67 @@
+import copy
+import json
+
+import pytest
+
+from . import ValidatorTest
+from ... import ValidationError
+
+VALID_BUNDLE = u"""
+{
+  "type": "bundle",
+  "id": "bundle--44af6c39-c09b-49c5-9de2-394224b04982",
+  "objects": [
+    {
+      "type": "identity",
+      "spec_version": "2.1",
+      "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"
+    }
+  ]
+}
+"""
+
+
+class BundleTestCases(ValidatorTest):
+    valid_bundle = json.loads(VALID_BUNDLE)
+
+    def test_wellformed_bundle(self):
+        self.assertTrueWithOptions(self.valid_bundle)
+
+    def test_bundle_object_categories(self):
+        bundle = copy.deepcopy(self.valid_bundle)
+        bundle['identities'] = bundle['objects']
+        del bundle['objects']
+        self.assertFalseWithOptions(bundle)
+
+    def test_bundle_created(self):
+        bundle = copy.deepcopy(self.valid_bundle)
+        bundle['created'] = "2016-08-22T14:09:00.123456Z"
+        self.assertFalseWithOptions(bundle)
+
+    def test_bundle_version(self):
+        bundle = copy.deepcopy(self.valid_bundle)
+        bundle['version'] = 1
+        self.assertFalseWithOptions(bundle)
+
+    def test_bundle_duplicate_ids(self):
+        bundle = copy.deepcopy(self.valid_bundle)
+        bundle['objects'].append(bundle['objects'][0].copy())
+        self.assertFalseWithOptions(bundle)
+
+        bundle['objects'][1]['modified'] = "2017-06-22T14:09:00.123Z"
+        self.assertTrueWithOptions(bundle)
+
+    def test_silent_and_verbose(self):
+        bundle = json.loads(VALID_BUNDLE)
+        with pytest.raises(SystemExit):
+            self.assertFalseWithOptions(bundle, silent=True, verbose=True)
+
+    def test_bundle_sdo_missing_type(self):
+        bundle = copy.deepcopy(self.valid_bundle)
+        del bundle['objects'][0]['type']
+        with pytest.raises(ValidationError):
+            self.assertFalseWithOptions(bundle)
diff --git a/stix2validator/test/v21/custom_obj_tests.py b/stix2validator/test/v21/custom_obj_tests.py
new file mode 100644
index 0000000..e240168
--- /dev/null
+++ b/stix2validator/test/v21/custom_obj_tests.py
@@ -0,0 +1,82 @@
+import copy
+import json
+
+from . import ValidatorTest
+from ... import ValidationError, validate_parsed_json, validate_string
+
+VALID_CUSTOM_OBJECT = u"""
+{
+  "type": "x-example-com-customobject",
+  "spec_version": "2.1",
+  "id": "x-example-com-customobject--4527e5de-8572-446a-a57a-706f15467461",
+  "created": "2016-08-01T00:00:00.000Z",
+  "modified": "2016-08-01T00:00:00.000Z",
+  "some_custom_stuff": 14,
+  "other_custom_stuff": "hello"
+}
+"""
+
+
+class CustomObjectTestCases(ValidatorTest):
+    valid_custom_object = json.loads(VALID_CUSTOM_OBJECT)
+
+    def test_wellformed_custom_object(self):
+        results = validate_string(VALID_CUSTOM_OBJECT, self.options)
+        self.assertTrue(results.is_valid)
+
+    def test_no_type(self):
+        custom_obj = copy.deepcopy(self.valid_custom_object)
+        del custom_obj['type']
+        self.assertRaises(ValidationError, validate_parsed_json, custom_obj, self.options)
+
+    def test_no_id(self):
+        custom_obj = copy.deepcopy(self.valid_custom_object)
+        del custom_obj['id']
+        results = validate_parsed_json(custom_obj, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_no_created(self):
+        custom_obj = copy.deepcopy(self.valid_custom_object)
+        del custom_obj['created']
+        results = validate_parsed_json(custom_obj, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_no_modified(self):
+        custom_obj = copy.deepcopy(self.valid_custom_object)
+        del custom_obj['modified']
+        results = validate_parsed_json(custom_obj, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_invalid_type_name(self):
+        custom_obj = copy.deepcopy(self.valid_custom_object)
+        custom_obj['type'] = "corpo_ration"
+        custom_obj['id'] = "corpo_ration--4527e5de-8572-446a-a57a-706f15467461"
+        results = validate_parsed_json(custom_obj, self.options)
+        self.assertEqual(results.is_valid, False)
+
+        custom_obj['type'] = "corpor@tion"
+        custom_obj['id'] = "corpor@tion--4527e5de-8572-446a-a57a-706f15467461"
+        results = validate_parsed_json(custom_obj, self.options)
+        self.assertEqual(results.is_valid, False)
+
+        self.assertFalseWithOptions(custom_obj, enabled='custom-prefix-lax')
+        self.assertFalseWithOptions(custom_obj, disabled='custom-prefix-lax')
+
+    def test_invalid_type_name_lax(self):
+        custom_obj = copy.deepcopy(self.valid_custom_object)
+        custom_obj['type'] = "x-corporation"
+        custom_obj['id'] = "x-corporation--4527e5de-8572-446a-a57a-706f15467461"
+        results = validate_parsed_json(custom_obj, self.options)
+        self.assertEqual(results.is_valid, False)
+
+        self.assertTrueWithOptions(custom_obj, enabled='custom-prefix-lax')
+        self.check_ignore(custom_obj, 'custom-prefix')
+
+    def test_valid_type_name(self):
+        custom_obj = copy.deepcopy(self.valid_custom_object)
+        custom_obj['type'] = "x-corp-oration"
+        custom_obj['id'] = "x-corp-oration--4527e5de-8572-446a-a57a-706f15467461"
+        self.assertTrueWithOptions(custom_obj, strict=True, strict_properties=True)
+
+    def test_strict_types(self):
+        self.assertFalseWithOptions(self.valid_custom_object, strict_types=True)
diff --git a/stix2validator/test/v21/identity_tests.py b/stix2validator/test/v21/identity_tests.py
new file mode 100644
index 0000000..6f23322
--- /dev/null
+++ b/stix2validator/test/v21/identity_tests.py
@@ -0,0 +1,45 @@
+import copy
+import json
+
+from jsonschema import exceptions
+
+from . import ValidatorTest
+from ... import validate_parsed_json, validate_string
+
+VALID_IDENTITY = u"""
+{
+  "type": "identity",
+  "spec_version": "2.1",
+  "id": "identity--2d1c6ab3-5e4e-48ac-a32b-f0c01c2836a8",
+  "created": "2014-08-08T15:50:10.983Z",
+  "modified": "2014-08-08T15:50:10.983Z",
+  "name": "ACME Widget, Inc.",
+  "identity_class": "organization"
+}
+"""
+
+
+class IdentityTestCases(ValidatorTest):
+    valid_identity = json.loads(VALID_IDENTITY)
+
+    def test_invalid_check(self):
+        self.assertRaises(exceptions.ValidationError, self.assertFalseWithOptions,
+                          self.valid_identity, enabled='abc')
+
+    def test_wellformed_identity(self):
+        results = validate_string(VALID_IDENTITY, self.options)
+        self.assertTrue(results.is_valid)
+
+    def test_vocab_identity_class(self):
+        identity = copy.deepcopy(self.valid_identity)
+        identity['identity_class'] = "corporation"
+        results = validate_parsed_json(identity, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_vocab_industry_sector(self):
+        identity = copy.deepcopy(self.valid_identity)
+        identity['sectors'] = ["something"]
+        results = validate_parsed_json(identity, self.options)
+        self.assertEqual(results.is_valid, False)
+
+        self.check_ignore(identity, 'industry-sector')
diff --git a/stix2validator/test/v21/indicator_tests.py b/stix2validator/test/v21/indicator_tests.py
new file mode 100644
index 0000000..a4d3950
--- /dev/null
+++ b/stix2validator/test/v21/indicator_tests.py
@@ -0,0 +1,220 @@
+import copy
+import json
+
+from . import ValidatorTest
+from ... import ValidationOptions, validate_parsed_json, validate_string
+
+VALID_INDICATOR = u"""
+{
+    "type": "indicator",
+    "spec_version": "2.1",
+    "id": "indicator--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f",
+    "created_by_ref": "source--f431f809-377b-45e0-aa1c-6a4751cae5ff",
+    "created": "2016-04-06T20:03:48.000Z",
+    "modified": "2016-04-06T20:03:48.000Z",
+    "indicator_types": ["malicious-activity"],
+    "name": "Poison Ivy Malware",
+    "description": "This file is part of Poison Ivy",
+    "pattern": "[file:hashes.'SHA-256' = 'aec070645fe53ee3b3763059376134f058cc337247c978add178b6ccdfb0019f']",
+    "valid_from": "2016-04-06T20:03:48Z"
+}
+"""
+
+ADDTNL_INVALID_SCHEMA = {
+    "type": "x-foo-bar",
+    "id": "x-type--353ed279-5f4f-4a79-bffc-b2e2ed08ea1f",
+    "created": "2016-04-06T20:03:48.000Z",
+    "modified": "2016-04-06T20:03:48.000Z",
+}
+
+
+class IndicatorTestCases(ValidatorTest):
+    valid_indicator = json.loads(VALID_INDICATOR)
+
+    def test_wellformed_indicator(self):
+        results = validate_string(VALID_INDICATOR, self.options)
+        self.assertTrue(results.is_valid)
+
+    def test_modified_before_created(self):
+        indicator = copy.deepcopy(self.valid_indicator)
+        indicator['modified'] = "2001-04-06T20:03:48Z"
+        results = validate_parsed_json(indicator, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_invalid_lang(self):
+        indicator = copy.deepcopy(self.valid_indicator)
+        indicator['lang'] = "foo"
+        self.assertFalseWithOptions(indicator)
+
+        indicator['lang'] = "en"
+        self.assertTrueWithOptions(indicator)
+
+    def test_custom_property_name_invalid_character(self):
+        indicator = copy.deepcopy(self.valid_indicator)
+        indicator['my_new_property!'] = "abc123"
+        results = validate_parsed_json(indicator, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_custom_property_name_short(self):
+        indicator = copy.deepcopy(self.valid_indicator)
+        indicator['mp'] = "abc123"
+        results = validate_parsed_json(indicator, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_custom_property_name_long(self):
+        indicator = copy.deepcopy(self.valid_indicator)
+        long_property_name = 'my_new_property_' * 16
+        indicator[long_property_name] = "abc123"
+        results = validate_parsed_json(indicator, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_custom_property_name_strict(self):
+        indicator = copy.deepcopy(self.valid_indicator)
+        indicator['foobar'] = "abc123"
+        self.assertFalseWithOptions(indicator, strict_properties=True)
+
+    def test_empty_list(self):
+        indicator = copy.deepcopy(self.valid_indicator)
+        indicator['my_new_property'] = []
+        results = validate_parsed_json(indicator, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_id_type(self):
+        indicator = copy.deepcopy(self.valid_indicator)
+        indicator['id'] = "something--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f"
+        results = validate_parsed_json(indicator, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_reserved_property_severity(self):
+        indicator = copy.deepcopy(self.valid_indicator)
+        indicator['severity'] = "Something"
+        results = validate_parsed_json(indicator, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_reserved_property_action(self):
+        indicator = copy.deepcopy(self.valid_indicator)
+        indicator['action'] = "Something"
+        results = validate_parsed_json(indicator, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_reserved_property_usernames(self):
+        indicator = copy.deepcopy(self.valid_indicator)
+        indicator['usernames'] = "Something"
+        results = validate_parsed_json(indicator, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_reserved_property_phone_numbers(self):
+        indicator = copy.deepcopy(self.valid_indicator)
+        indicator['phone_numbers'] = "Something"
+        results = validate_parsed_json(indicator, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_reserved_object_type_incident(self):
+        indicator = copy.deepcopy(self.valid_indicator)
+        indicator['type'] = "incident"
+        results = validate_parsed_json(indicator, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_reserved_object_type_infrastructure(self):
+        indicator = copy.deepcopy(self.valid_indicator)
+        indicator['type'] = "infrastructure"
+        results = validate_parsed_json(indicator, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_vocab_indicator_types(self):
+        indicator = copy.deepcopy(self.valid_indicator)
+        indicator['indicator_types'] = ["suspicious"]
+        results = validate_parsed_json(indicator, self.options)
+        self.assertEqual(results.is_valid, False)
+
+        self.check_ignore(indicator, 'indicator-types')
+
+    def test_invalid_pattern(self):
+        indicator = copy.deepcopy(self.valid_indicator)
+        indicator['pattern'] = """[file:hashes."SHA-256" = 'aec070645fe53ee3b3763059376134f058cc337247c978add178b6ccdfb0019f']"""
+        self.assertFalseWithOptions(indicator)
+
+    def test_pattern_custom_invalid_format(self):
+        indicator = copy.deepcopy(self.valid_indicator)
+        indicator['pattern'] = """[ab:yz = 'something']"""
+        self.assertFalseWithOptions(indicator)
+
+    def test_pattern_custom_object_noprefix(self):
+        indicator = copy.deepcopy(self.valid_indicator)
+        indicator['pattern'] = """[foo:name = 'something']"""
+        self.assertFalseWithOptions(indicator)
+
+        self.check_ignore(indicator, 'custom-prefix,custom-prefix-lax')
+        self.assertFalseWithOptions(indicator, disabled='custom-prefix')
+
+    def test_pattern_custom_object_prefix_strict(self):
+        indicator = copy.deepcopy(self.valid_indicator)
+        indicator['pattern'] = """[x-x-foo:x_x_name = 'something']"""
+        self.assertTrueWithOptions(indicator)
+
+        self.assertFalseWithOptions(indicator, strict_types=True)
+
+    def test_pattern_custom_object_prefix_lax(self):
+        indicator = copy.deepcopy(self.valid_indicator)
+        indicator['pattern'] = """[x-foo:x_name = 'something']"""
+        self.check_ignore(indicator, 'custom-prefix')
+
+    def test_pattern_custom_property_prefix_strict(self):
+        indicator = copy.deepcopy(self.valid_indicator)
+        indicator['pattern'] = """[file:x_x_name = 'something']"""
+        self.assertTrueWithOptions(indicator)
+
+        self.assertFalseWithOptions(indicator, strict_properties=True)
+
+    def test_pattern_list_object_property(self):
+        indicator = copy.deepcopy(self.valid_indicator)
+        indicator['pattern'] = """[windows-registry-key:values[*].data='badstuff']"""
+        self.assertTrueWithOptions(indicator)
+
+    def test_pattern_with_escaped_slashes(self):
+        indicator = copy.deepcopy(self.valid_indicator)
+        indicator['pattern'] = "[windows-registry-key:key LIKE 'HKEY_LOCAL_MACHINE\\\\Foo\\\\Bar%']"
+        self.assertTrueWithOptions(indicator)
+
+    def test_additional_schemas(self):
+        indicator = copy.deepcopy(self.valid_indicator)
+        self.assertFalseWithOptions(indicator, schema_dir=self.custom_schemas)
+
+        indicator['name'] = "Foobar"
+        self.assertTrueWithOptions(indicator, schema_dir=self.custom_schemas)
+
+    def test_additional_schemas_custom_type(self):
+        # no schema exists for this type
+        new_obj = {
+            "type": "x-type",
+            "spec_version": "2.1",
+            "id": "x-type--353ed279-5f4f-4a79-bffc-b2e2ed08ea1f",
+            "created": "2016-04-06T20:03:48.000Z",
+            "modified": "2016-04-06T20:03:48.000Z",
+            "property1": 10,
+            "property2": "fizzbuzz"
+        }
+        self.assertFalseWithOptions(new_obj, schema_dir=self.custom_schemas)
+
+        # properties are wrong types (str vs int)
+        new_obj['type'] = 'x-new-type'
+        new_obj['id'] = 'x-new-type--353ed279-5f4f-4a79-bffc-b2e2ed08ea1f'
+        self.assertFalseWithOptions(new_obj, schema_dir=self.custom_schemas)
+
+        # now they're valid
+        new_obj['property1'] = 'fizzbuzz'
+        new_obj['property2'] = 10
+        self.assertTrueWithOptions(new_obj, schema_dir=self.custom_schemas)
+
+    def test_additional_schemas_custom_type_invalid_schema(self):
+        self.assertFalseWithOptions(ADDTNL_INVALID_SCHEMA, schema_dir=self.custom_schemas)
+
+    def test_validate_parsed_json_list_additional_invalid_schema(self):
+        indicator = copy.deepcopy(self.valid_indicator)
+        indicator['name'] = 'Foobar'
+        objects = [indicator, ADDTNL_INVALID_SCHEMA]
+
+        options = ValidationOptions(schema_dir=self.custom_schemas)
+        results = validate_parsed_json(objects, options)
+        assert results[0].is_valid
+        assert not results[1].is_valid
diff --git a/stix2validator/test/v21/intrusion_set_tests.py b/stix2validator/test/v21/intrusion_set_tests.py
new file mode 100644
index 0000000..2ce49d2
--- /dev/null
+++ b/stix2validator/test/v21/intrusion_set_tests.py
@@ -0,0 +1,62 @@
+import copy
+import json
+
+from . import ValidatorTest
+from ... import validate_parsed_json, validate_string
+
+VALID_INTRUSION_SET = u"""
+{
+  "type": "intrusion-set",
+  "spec_version": "2.1",
+  "id": "intrusion-set--4e78f46f-a023-4e5f-bc24-71b3ca22ec29",
+  "created_by_ref": "identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
+  "created": "2016-04-06T20:03:48.000Z",
+  "modified": "2016-04-06T20:03:48.000Z",
+  "name": "Bobcat Breakin",
+  "description": "Incidents usually feature a shared TTP of a bobcat being released within the building containing network access, scaring users to leave their computers without locking them first. Still determining where the threat actors are getting the bobcats.",
+  "aliases": ["Zookeeper"],
+  "goals": ["acquisition-theft", "harassment", "damage"]
+}
+"""  # noqa
+
+
+class IntrusionSetTestCases(ValidatorTest):
+    valid_intrusion_set = json.loads(VALID_INTRUSION_SET)
+
+    def test_wellformed_intrusion_set(self):
+        results = validate_string(VALID_INTRUSION_SET, self.options)
+        self.assertTrue(results.is_valid)
+
+    def test_country(self):
+        intrusion_set = copy.deepcopy(self.valid_intrusion_set)
+        intrusion_set['country'] = "USA"
+        results = validate_parsed_json(intrusion_set, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_vocab_attack_motivation(self):
+        intrusion_set = copy.deepcopy(self.valid_intrusion_set)
+        intrusion_set['primary_motivation'] = "selfishness"
+        results = validate_parsed_json(intrusion_set, self.options)
+        self.assertEqual(results.is_valid, False)
+
+        self.check_ignore(intrusion_set, 'attack-motivation')
+
+    def test_vocab_attack_resource_level(self):
+        intrusion_set = copy.deepcopy(self.valid_intrusion_set)
+        intrusion_set['resource_level'] = "high"
+        results = validate_parsed_json(intrusion_set, self.options)
+        self.assertEqual(results.is_valid, False)
+
+        self.check_ignore(intrusion_set, 'attack-resource-level')
+
+    def test_invalid_seen_time(self):
+        intrusion_set = copy.deepcopy(self.valid_intrusion_set)
+        intrusion_set['first_seen'] = "2016-04-06T20:06:37.000Z"
+        intrusion_set['last_seen'] = "2016-04-06T20:06:37.000Z"
+        self.assertTrueWithOptions(intrusion_set)
+
+        intrusion_set['last_seen'] = "2016-01-01T00:00:00.000Z"
+        self.assertFalseWithOptions(intrusion_set)
+
+        intrusion_set['last_seen'] = "2016-05-07T20:06:37.000Z"
+        self.assertTrueWithOptions(intrusion_set)
diff --git a/stix2validator/test/v21/language_content_tests.py b/stix2validator/test/v21/language_content_tests.py
new file mode 100644
index 0000000..3df606f
--- /dev/null
+++ b/stix2validator/test/v21/language_content_tests.py
@@ -0,0 +1,47 @@
+# coding: utf-8
+
+import copy
+import json
+
+from . import ValidatorTest
+from ... import validate_string
+
+VALID_LANGUAGE_CONTENT = u"""
+{
+  "type": "language-content",
+  "id": "language-content--fa8daccd-4df2-4e83-93a4-e1990a2355c2",
+  "spec_version": "2.1",
+  "created": "2018-02-08T21:31:22.007Z",
+  "modified": "2018-02-08T21:31:22.007Z",
+  "object_ref": "identity--2d1c6ab3-5e4e-48ac-a32b-f0c01c2836a8",
+  "object_modified": "2014-08-08T15:50:10.983Z",
+  "contents": {
+    "es": {
+      "identity_class": "organización"
+    }
+  }
+}
+"""
+
+
+class LanguageContentTestCases(ValidatorTest):
+    valid_language_content = json.loads(VALID_LANGUAGE_CONTENT)
+
+    def test_wellformed_language_content(self):
+        self.assertTrueWithOptions(self.valid_language_content)
+        results = validate_string(VALID_LANGUAGE_CONTENT, self.options)
+        self.assertTrue(results.is_valid)
+
+    def test_invalid_contents_key(self):
+        lang_content = copy.deepcopy(self.valid_language_content)
+        lang_content['contents']['español'] = {
+            "identity_class": u"organización"
+        }
+        self.assertFalseWithOptions(lang_content)
+
+    def test_invalid_contents_subkey(self):
+        lang_content = copy.deepcopy(self.valid_language_content)
+        lang_content['contents']['es'] = {
+            "a": "boo"
+        }
+        self.assertFalseWithOptions(lang_content)
diff --git a/stix2validator/test/v21/location_tests.py b/stix2validator/test/v21/location_tests.py
new file mode 100644
index 0000000..737e3e7
--- /dev/null
+++ b/stix2validator/test/v21/location_tests.py
@@ -0,0 +1,42 @@
+import copy
+import json
+
+from . import ValidatorTest
+from ... import validate_string
+
+VALID_LOCATION = u"""
+{
+  "type": "location",
+  "spec_version": "2.1",
+  "id": "location--a6e9345f-5a15-4c29-8bb3-7dcc5d168d64",
+  "created_by_ref": "identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
+  "created": "2016-04-06T20:03:00.000Z",
+  "modified": "2016-04-06T20:03:00.000Z",
+  "region": "south-eastern-asia",
+  "country": "th",
+  "administrative_area": "Tak",
+  "postal_code": "63170"
+}
+"""
+
+
+class LocationTestCases(ValidatorTest):
+    valid_location = json.loads(VALID_LOCATION)
+
+    def test_wellformed_location(self):
+        results = validate_string(VALID_LOCATION, self.options)
+        self.assertTrue(results.is_valid)
+
+    def test_vocab_region(self):
+        location = copy.deepcopy(self.valid_location)
+        location['region'] = 'global'
+        self.assertFalseWithOptions(location, strict=True)
+
+        self.check_ignore(location, 'region')
+
+    def test_vocab_country(self):
+        location = copy.deepcopy(self.valid_location)
+        location['country'] = 'xx'
+        self.assertFalseWithOptions(location, strict=True)
+
+        self.check_ignore(location, 'countries')
diff --git a/stix2validator/test/v21/malware_tests.py b/stix2validator/test/v21/malware_tests.py
new file mode 100644
index 0000000..5c70516
--- /dev/null
+++ b/stix2validator/test/v21/malware_tests.py
@@ -0,0 +1,52 @@
+import copy
+import json
+
+from . import ValidatorTest
+from ... import validate_parsed_json, validate_string
+
+VALID_MALWARE = u"""
+{
+  "type": "malware",
+  "spec_version": "2.1",
+  "id": "malware--0c7b5b88-8ff7-4a4d-aa9d-feb398cd0061",
+  "created": "2016-05-12T08:17:27.000Z",
+  "modified": "2016-05-12T08:17:27.000Z",
+  "name": "Cryptolocker",
+  "description": "...",
+  "malware_types": ["ransomware"]
+}
+"""
+
+
+class MalwareTestCases(ValidatorTest):
+    valid_malware = json.loads(VALID_MALWARE)
+
+    def test_wellformed_malware(self):
+        results = validate_string(VALID_MALWARE, self.options)
+        self.assertTrue(results.is_valid)
+
+    def test_vocab_malware_label(self):
+        malware = copy.deepcopy(self.valid_malware)
+        malware['malware_types'] += "something"
+        results = validate_parsed_json(malware, self.options)
+        self.assertEqual(results.is_valid, False)
+
+        self.check_ignore(malware, 'malware-types')
+
+    def test_open_vocab_format_uppercase(self):
+        malware = copy.deepcopy(self.valid_malware)
+        malware['malware_types'] += "Ransomware"
+        self.assertFalseWithOptions(malware,
+                                    disabled='malware-types')
+
+    def test_open_vocab_format_underscore(self):
+        malware = copy.deepcopy(self.valid_malware)
+        malware['malware_types'] += "ransom_ware"
+        self.assertFalseWithOptions(malware,
+                                    disabled='malware-types')
+
+    def test_open_vocab_format_space(self):
+        malware = copy.deepcopy(self.valid_malware)
+        malware['malware_types'] += "ransom ware"
+        self.assertFalseWithOptions(malware,
+                                    disabled='malware-types')
diff --git a/stix2validator/test/v21/marking_definition_tests.py b/stix2validator/test/v21/marking_definition_tests.py
new file mode 100644
index 0000000..0882df4
--- /dev/null
+++ b/stix2validator/test/v21/marking_definition_tests.py
@@ -0,0 +1,79 @@
+import copy
+import json
+
+from . import ValidatorTest
+from ... import validate_parsed_json, validate_string
+
+VALID_MARKING_DEFINITION = u"""
+{
+  "type": "marking-definition",
+  "spec_version": "2.1",
+  "id": "marking-definition--34098fce-860f-48ae-8e50-ebd3cc5e41da",
+  "created": "2017-01-20T00:00:00.000Z",
+  "definition_type": "tlp",
+  "definition": {
+    "tlp": "green"
+  }
+}
+"""
+
+
+class MarkingDefinitionTestCases(ValidatorTest):
+    valid_marking_definition = json.loads(VALID_MARKING_DEFINITION)
+
+    def test_wellformed_marking_definition(self):
+        results = validate_string(VALID_MARKING_DEFINITION, self.options)
+        self.assertTrue(results.is_valid)
+
+    def test_vocab_marking_definition_label(self):
+        marking_definition = copy.deepcopy(self.valid_marking_definition)
+        marking_definition['definition_type'] = "something"
+        results = validate_parsed_json(marking_definition, self.options)
+        self.assertEqual(results.is_valid, False)
+
+        self.check_ignore(marking_definition, 'marking-definition-type')
+
+    def test_lax_option(self):
+        marking_definition = copy.deepcopy(self.valid_marking_definition)
+        marking_definition['definition_type'] = "something"
+        self.assertFalseWithOptions(marking_definition)
+        self.assertTrueWithOptions(marking_definition, strict=False)
+
+    def test_object_marking_ref_circular_ref(self):
+        marking_definition = copy.deepcopy(self.valid_marking_definition)
+        marking_definition['object_marking_refs'] = ["marking-definition--34098fce-860f-48ae-8e50-ebd3cc5e41da"]
+        self.assertFalseWithOptions(marking_definition)
+
+    def test_object_marking_ref_invalid_type(self):
+        marking_definition = copy.deepcopy(self.valid_marking_definition)
+        marking_definition['object_marking_refs'] = ["indicator--44098fce-860f-48ae-8e50-ebd3cc5e41da"]
+        self.assertFalseWithOptions(marking_definition)
+
+    def test_granular_marking_circular_ref(self):
+        marking_definition = copy.deepcopy(self.valid_marking_definition)
+        marking_definition['granular_markings'] = [{
+            "marking_ref": "marking-definition--34098fce-860f-48ae-8e50-ebd3cc5e41da",
+            "selectors": ["created"]
+        }]
+        self.assertFalseWithOptions(marking_definition)
+
+    def test_granular_marking_invalid_selector(self):
+        marking_definition = copy.deepcopy(self.valid_marking_definition)
+        marking_definition['granular_markings'] = [{
+            "marking_ref": "marking-definition--4478bf48-9af2-4afa-9fc5-7075f6af04af",
+            "selectors": ["[0]"]
+        }]
+        self.assertFalseWithOptions(marking_definition)
+
+    def test_granular_marking_invalid_marking_ref_type(self):
+        marking_definition = copy.deepcopy(self.valid_marking_definition)
+        marking_definition['granular_markings'] = [{
+            "marking_ref": "indicator--3478bf48-9af2-4afa-9fc5-7075f6af04af",
+            "selectors": ["created"]
+        }]
+        self.assertFalseWithOptions(marking_definition)
+
+    def test_marking_definition_invalid_definition(self):
+        marking_definition = copy.deepcopy(self.valid_marking_definition)
+        marking_definition['definition']['tlp'] = 21
+        self.assertFalseWithOptions(marking_definition)
diff --git a/stix2validator/test/misc_tests.py b/stix2validator/test/v21/misc_tests.py
similarity index 93%
rename from stix2validator/test/misc_tests.py
rename to stix2validator/test/v21/misc_tests.py
index d97582e..cd55aca 100644
--- a/stix2validator/test/misc_tests.py
+++ b/stix2validator/test/v21/misc_tests.py
@@ -6,15 +6,15 @@ import sys
 
 import pytest
 
-from .. import (ValidationOptions, print_results, run_validation,
-                validate_file, validate_string)
+from ... import (ValidationOptions, print_results, run_validation,
+                 validate_file, validate_string)
 from .tool_tests import VALID_TOOL
 
 logging.basicConfig(stream=sys.stdout, level=logging.INFO, format='%(message)s')
 logger = logging.getLogger(__name__)
 
 EXAMPLE = os.path.join(os.path.dirname(os.path.realpath(__file__)),
-                       '..', 'schemas', 'examples',
+                       '..', '..', 'schemas-2.1', 'examples',
                        'indicator-to-campaign-relationship.json')
 IDENTITY = os.path.join(os.path.dirname(os.path.realpath(__file__)),
                         'test_examples', 'identity.json')
@@ -116,7 +116,7 @@ def test_validate_string_invalid_timestamp(caplog):
     assert not results.is_valid
 
     print_results(results)
-    assert re.search("'modified' .+ must be later or equal to 'created'", caplog.text)
+    assert re.search("'modified' .+ must be later than or equal to 'created'", caplog.text)
 
 
 def test_print_results_invalid_parameter():
diff --git a/stix2validator/test/multi_obj_tests.py b/stix2validator/test/v21/multi_obj_tests.py
similarity index 92%
rename from stix2validator/test/multi_obj_tests.py
rename to stix2validator/test/v21/multi_obj_tests.py
index 3d25a3b..0975590 100644
--- a/stix2validator/test/multi_obj_tests.py
+++ b/stix2validator/test/v21/multi_obj_tests.py
@@ -2,12 +2,13 @@ import copy
 import json
 
 from . import ValidatorTest
-from .. import validate_parsed_json, validate_string
+from ... import validate_parsed_json, validate_string
 
 MULTI_OBJ_JSON = u"""
 [
     {
       "type": "attack-pattern",
+      "spec_version": "2.1",
       "id": "attack-pattern--0c7b5b88-8ff7-4a4d-aa9d-feb398cd0061",
       "created": "2016-02-12T08:17:27.000Z",
       "modified": "2016-02-12T08:17:27.000Z",
@@ -23,6 +24,7 @@ MULTI_OBJ_JSON = u"""
 
     {
       "type": "identity",
+      "spec_version": "2.1",
       "id": "identity--2d1c6ab3-5e4e-48ac-a32b-f0c01c2836a8",
       "created": "2014-08-08T15:50:10.983Z",
       "modified": "2014-08-08T15:50:10.983Z",
diff --git a/stix2validator/test/v21/network_traffic_tests.py b/stix2validator/test/v21/network_traffic_tests.py
new file mode 100644
index 0000000..7d65fdb
--- /dev/null
+++ b/stix2validator/test/v21/network_traffic_tests.py
@@ -0,0 +1,149 @@
+import copy
+import json
+
+from . import ValidatorTest
+from ... import validate_string
+
+VALID_NETWORK_TRAFFIC_DEFINITION = u"""
+{
+  "type": "observed-data",
+  "spec_version": "2.1",
+  "id": "observed-data--b67d30ff-02ac-498a-92f9-32f845f448cf",
+  "created_by_ref": "identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
+  "created": "2016-04-06T19:58:16.000Z",
+  "modified": "2016-04-06T19:58:16.000Z",
+  "first_observed": "2015-12-21T19:00:00Z",
+  "last_observed": "2015-12-21T19:00:00Z",
+  "number_observed": 50,
+  "objects": {
+    "0": {
+      "type": "ipv4-addr",
+      "value": "203.0.113.5"
+    },
+    "1": {
+      "type": "network-traffic",
+      "src_ref": "0",
+      "src_port": 24678,
+      "dst_port": 80,
+      "protocols": [
+        "ipv4",
+        "tcp"
+      ],
+      "src_byte_count": 147600,
+      "src_packets": 100
+    }
+  },
+  "granular_markings": [
+    {
+      "marking_ref": "marking-definition--34098fce-860f-48ae-8e50-ebd3cc5e41da",
+      "selectors": [ "objects.0.type" ]
+    }
+  ]
+}
+"""
+
+
+class ObservedDataTestCases(ValidatorTest):
+    valid_net_traffic = json.loads(VALID_NETWORK_TRAFFIC_DEFINITION)
+
+    def test_wellformed_network_traffic(self):
+        results = validate_string(VALID_NETWORK_TRAFFIC_DEFINITION,
+                                  self.options)
+        self.assertTrue(results.is_valid)
+
+    def test_network_traffic_required_fields(self):
+        net_traffic = copy.deepcopy(self.valid_net_traffic)
+        del net_traffic['objects']['1']['src_ref']
+        self.assertFalseWithOptions(net_traffic)
+
+        net_traffic = copy.deepcopy(self.valid_net_traffic)
+        del net_traffic['objects']['1']['protocols']
+        self.assertFalseWithOptions(net_traffic)
+
+    def test_network_traffic_ports(self):
+        net_traffic = copy.deepcopy(self.valid_net_traffic)
+        del net_traffic['objects']['1']['src_port']
+        del net_traffic['objects']['1']['dst_port']
+        self.assertFalseWithOptions(net_traffic)
+
+        net_traffic['objects']['1']['src_port'] = 3372
+        self.assertFalseWithOptions(net_traffic)
+
+        self.check_ignore(net_traffic, 'network-traffic-ports')
+
+        net_traffic['objects']['1']['dst_port'] = 80
+        self.assertTrueWithOptions(net_traffic)
+
+        net_traffic['objects']['1']['src_port'] = 9999999
+        self.assertFalseWithOptions(net_traffic)
+
+    def test_network_traffic_protocols(self):
+        net_traffic = copy.deepcopy(self.valid_net_traffic)
+        net_traffic['objects']['1']['protocols'].append('foo_bar')
+        self.assertFalseWithOptions(net_traffic)
+        self.check_ignore(net_traffic, 'protocols')
+
+        net_traffic['objects']['1']['protocols'][2] = 'https'
+        self.assertTrueWithOptions(net_traffic)
+
+    def test_network_traffic_ipfix(self):
+        net_traffic = copy.deepcopy(self.valid_net_traffic)
+        net_traffic['objects']['1']['ipfix'] = {
+            "minimumIpTotalLength": 32,
+            "maximumIpTotalLength": 2556,
+            "Foo": "bar"
+        }
+        self.assertFalseWithOptions(net_traffic)
+        self.check_ignore(net_traffic, 'ipfix')
+
+    def test_network_traffic_http_request_header(self):
+        net_traffic = copy.deepcopy(self.valid_net_traffic)
+        net_traffic['objects']['1']['extensions'] = {
+            "http-request-ext": {
+                "request_method": "get",
+                "request_value": "/download.html",
+                "request_version": "http/1.1",
+                "request_header": {
+                    "Accept-Encoding": "gzip,deflate",
+                    "Host": "www.example.com",
+                    "x-foobar": "something"
+                }
+            }
+        }
+        self.assertFalseWithOptions(net_traffic)
+        self.check_ignore(net_traffic, 'http-request-headers')
+
+    def test_network_traffic_socket_options(self):
+        net_traffic = copy.deepcopy(self.valid_net_traffic)
+        net_traffic['objects']['1']['extensions'] = {
+            "socket-ext": {
+                "address_family": "AF_INET",
+                "socket_type": "SOCK_STREAM",
+                "options": {
+                    "foo": "bar"
+                }
+            }
+        }
+        self.assertFalseWithOptions(net_traffic)
+        self.check_ignore(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(net_traffic)
+
+        del net_traffic['objects']['1']['end']
+        self.assertTrueWithOptions(net_traffic)
+
+    def test_invalid_start_end_time(self):
+        net_traffic = copy.deepcopy(self.valid_net_traffic)
+        net_traffic['objects']['1']['start'] = "2016-04-06T20:06:37.000Z"
+        net_traffic['objects']['1']['end'] = "2016-04-06T20:06:37.000Z"
+        self.assertFalseWithOptions(net_traffic)
+
+        net_traffic['objects']['1']['end'] = "2016-01-01T00:00:00.000Z"
+        self.assertFalseWithOptions(net_traffic)
+
+        net_traffic['objects']['1']['end'] = "2016-05-07T20:06:37.000Z"
+        self.assertTrueWithOptions(net_traffic)
diff --git a/stix2validator/test/v21/observed_data_tests.py b/stix2validator/test/v21/observed_data_tests.py
new file mode 100644
index 0000000..709bbef
--- /dev/null
+++ b/stix2validator/test/v21/observed_data_tests.py
@@ -0,0 +1,877 @@
+import copy
+import json
+
+from . import ValidatorTest
+from ... import validate_parsed_json, validate_string
+
+VALID_OBSERVED_DATA_DEFINITION = u"""
+{
+  "type": "observed-data",
+  "spec_version": "2.1",
+  "id": "observed-data--b67d30ff-02ac-498a-92f9-32f845f448cf",
+  "created_by_ref": "identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
+  "created": "2016-04-06T19:58:16.000Z",
+  "modified": "2016-04-06T19:58:16.000Z",
+  "first_observed": "2015-12-21T19:00:00Z",
+  "last_observed": "2015-12-21T19:00:00Z",
+  "number_observed": 50,
+  "objects": {
+    "0": {
+      "type": "file",
+      "name": "foo.zip",
+      "hashes": {
+        "MD5": "B365B9A80A06906FC9B400C06C33FF43"
+      },
+      "mime_type": "application/zip",
+      "extensions": {
+        "archive-ext": {
+          "contains_refs": [
+            "1"
+          ]
+        }
+      }
+    },
+    "1": {
+      "type": "file",
+      "hashes": {
+        "MD5": "A2FD2B3F4D5A1BD5E7D283299E01DCE9"
+      },
+      "name": "qwerty.dll"
+    }
+  },
+  "granular_markings": [
+    {
+      "marking_ref": "marking-definition--34098fce-860f-48ae-8e50-ebd3cc5e41da",
+      "selectors": [ "objects.0.type" ]
+    }
+  ]
+}
+"""
+
+
+class ObservedDataTestCases(ValidatorTest):
+    valid_observed_data = json.loads(VALID_OBSERVED_DATA_DEFINITION)
+
+    def test_wellformed_observed_data(self):
+        results = validate_string(VALID_OBSERVED_DATA_DEFINITION, self.options)
+        self.assertTrue(results.is_valid)
+
+    def test_number_observed(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['number_observed'] = -1
+        self.assertFalseWithOptions(observed_data)
+
+    def test_selector_invalid_property(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['granular_markings'][0]['selectors'][0] = "foobar"
+        self.assertFalseWithOptions(observed_data)
+
+    def test_selector_invalid_index(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['granular_markings'][0]['selectors'] = [
+            "objects.0.extensions.archive-ext.contains_refs.[5]"
+        ]
+        self.assertFalseWithOptions(observed_data)
+
+    def test_selector_invalid_list(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['granular_markings'][0]['selectors'] = [
+          "objects.[0].extensions"
+        ]
+        self.assertFalseWithOptions(observed_data)
+
+    def test_selector_invalid_property2(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['granular_markings'][0]['selectors'] = [
+          "objects.[0].extensions.archive-ext.contains_refs.[0].type"
+        ]
+        self.assertFalseWithOptions(observed_data)
+
+    def test_selectors_multiple(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['granular_markings'][0]['selectors'] = [
+          "objects.0.extensions.archive-ext.contains_refs.[5]",
+          "objects.0.addons",
+          "objects.9"
+        ]
+        results = validate_parsed_json(observed_data, self.options)
+        self.assertTrue(len(results.errors) == 3)
+        self.assertFalse(results.is_valid)
+
+    def test_dict_key_uppercase(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['0']['x_s_dicts'] = {
+            'FOOBAR': {
+                "foo": "bar"
+            }
+        }
+        results = validate_parsed_json(observed_data, self.options)
+        self.assertTrue(len(results.errors) == 1)
+        self.assertFalse(results.is_valid)
+
+        self.check_ignore(observed_data, 'observable-dictionary-keys')
+
+    def test_dict_key_length(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['0']['x_s_dicts'] = {
+            'foofoobarfoofoobarbarfoofoobarbarbar': {
+                "foo": "bar"
+            }
+        }
+        # STIX 2.1 removed dictionary key minimum length
+        self.assertTrueWithOptions(observed_data)
+
+    def test_vocab_account_type(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['2'] = {
+            "type": "user-account",
+            "user_id": "1001",
+            "account_login": "bwayne",
+            "account_type": "superhero"
+        }
+        self.assertFalseWithOptions(observed_data)
+
+        self.check_ignore(observed_data, 'account-type')
+
+    def test_vocab_windows_pebinary_type(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['0']['extensions']['windows-pebinary-ext'] = {
+            "pe_type": "elf",
+            "machine_hex": "014c",
+        }
+        self.assertFalseWithOptions(observed_data)
+
+        self.check_ignore(observed_data, 'windows-pebinary-type')
+
+    def test_vocab_encryption_algo(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['2'] = {
+            "type": "artifact",
+            "mime_type": "application/zip",
+            "payload_bin": "VBORw0KGgoAAAANSUhEUgAAADI==",
+            "encryption_algorithm": "foo",
+            "decryption_key": "My voice is my passport"
+        }
+        self.assertFalseWithOptions(observed_data)
+
+        observed_data['objects']['2']['encryption_algorithm'] = "mime-type-indicated"
+        self.assertTrueWithOptions(observed_data)
+
+    def test_vocab_file_hashes(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['0']['hashes'] = {
+            "something": "foobar"
+        }
+        self.assertFalseWithOptions(observed_data)
+
+        self.check_ignore(observed_data, 'hash-algo')
+
+    def test_vocab_artifact_hashes(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['2'] = {
+            "type": "artifact",
+            "url": "http://www.example.com/file.txt",
+            "hashes": {
+                "foo": "B4D33B0C7306351B9ED96578465C5579"
+            }
+        }
+        self.assertFalseWithOptions(observed_data)
+
+        self.check_ignore(observed_data, 'hash-algo')
+
+    def test_vocab_certificate_hashes(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['2'] = {
+            "type": "x509-certificate",
+            "hashes": {
+                "foo": "B4D33B0C7306351B9ED96578465C5579"
+            }
+        }
+        self.assertFalseWithOptions(observed_data)
+
+        self.check_ignore(observed_data, 'hash-algo')
+
+    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",
+                    "entropy": 0.061089,
+                    "hashes": {
+                        "foo": "1C19FC56AEF2048C1CD3A5E67B099350"
+                    }
+                }
+            ]
+        }
+        self.assertFalseWithOptions(observed_data)
+
+        self.check_ignore(observed_data, 'hash-algo')
+
+    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"
+                }
+            }
+        }
+        self.assertFalseWithOptions(observed_data)
+
+        self.check_ignore(observed_data, 'hash-algo')
+
+        observed_data['objects']['0']['extensions']['windows-pebinary-ext']['optional_header']['hashes'] = {
+            "x_foo": "1C19FC56AEF2048C1CD3A5E67B099350"
+        }
+        self.assertTrueWithOptions(observed_data)
+
+    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"
+            }
+        }
+        self.assertFalseWithOptions(observed_data)
+
+        self.check_ignore(observed_data, 'hash-algo')
+
+    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"
+            },
+            "optional_header": {
+                "hashes": {
+                    "foo": "1C19FC56AEF2048C1CD3A5E67B099350"
+                }
+            }
+        }
+        results = validate_parsed_json(observed_data, self.options)
+        self.assertTrue(len(results.errors) == 2)
+        self.assertFalse(results.is_valid)
+
+        self.check_ignore(observed_data, 'hash-algo')
+
+    def test_vocab_ntfs_alternate_data_streams_hashes(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['0']['extensions']['ntfs-ext'] = {
+            "alternate_data_streams": [
+                  {
+                      "name": "second.stream",
+                      "size": 25536,
+                      "hashes": {
+                          "foo": "B4D33B0C7306351B9ED96578465C5579"
+                      }
+                  }
+              ]
+        }
+        self.assertFalseWithOptions(observed_data)
+
+        self.check_ignore(observed_data, 'hash-algo')
+
+    def test_observable_object_keys(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['abc'] = {
+            "type": "x509-certificate",
+            "hashes": {
+                "MD5": "B4D33B0C7306351B9ED96578465C5579"
+            }
+        }
+        self.assertFalseWithOptions(observed_data)
+
+        self.check_ignore(observed_data, 'observable-object-keys')
+
+    def test_observable_object_no_type(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        del observed_data['objects']['0']['type']
+        self.assertFalseWithOptions(observed_data)
+
+    def test_observable_object_types(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['0']['type'] = "x--foo"
+        self.assertFalseWithOptions(observed_data)
+        observed_data['objects']['0']['type'] = "FOO"
+        self.assertFalseWithOptions(observed_data)
+        observed_data['objects']['0']['type'] = "a"
+        self.assertFalseWithOptions(observed_data)
+        observed_data['objects']['0']['type'] = "foo"
+        self.assertFalseWithOptions(observed_data)
+
+        self.check_ignore(observed_data, 'custom-prefix,custom-prefix-lax')
+
+        observed_data['objects']['0']['type'] = "x-c-foo"
+        self.assertTrueWithOptions(observed_data)
+        self.assertFalseWithOptions(observed_data, strict_types=True, strict_properties=True)
+
+    def test_observable_object_types_prefix_lax(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['0']['type'] = "foo"
+        self.assertFalseWithOptions(observed_data,
+                                    disabled='custom-prefix')
+        observed_data['objects']['0']['type'] = "x-foo"
+        self.assertFalseWithOptions(observed_data)
+        self.check_ignore(observed_data,
+                          'custom-prefix')
+        self.assertFalseWithOptions(observed_data,
+                                    disabled='custom-prefix-lax')
+
+    def test_observable_object_extensions(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['0']['extensions']['foobar'] = {
+            "foo": "bar"
+        }
+        self.assertFalseWithOptions(observed_data)
+        self.assertFalseWithOptions(observed_data)
+        self.assertFalseWithOptions(observed_data, strict_properties=True)
+
+        self.check_ignore(observed_data,
+                          'custom-prefix,custom-prefix-lax')
+
+    def test_observable_object_extensions_prefix_lax(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['0']['extensions']['foobar'] = {
+            "foo": "bar"
+        }
+        self.assertFalseWithOptions(observed_data,
+                                    disabled='custom-prefix')
+
+        del observed_data['objects']['0']['extensions']['foobar']
+        observed_data['objects']['0']['extensions']['x-foobar'] = {
+            "foo": "bar"
+        }
+        self.assertFalseWithOptions(observed_data)
+        self.check_ignore(observed_data,
+                          'custom-prefix')
+        self.assertFalseWithOptions(observed_data,
+                                    disabled='custom-prefix-lax')
+
+    def test_observable_object_custom_properties(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['0']['foo'] = "bar"
+        self.assertFalseWithOptions(observed_data)
+
+        self.check_ignore(observed_data,
+                          'custom-prefix,custom-prefix-lax')
+        self.assertFalseWithOptions(observed_data,
+                                    disabled='custom-prefix-lax')
+
+    def test_observable_object_custom_properties_lax(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['0']['foo'] = "bar"
+        self.assertFalseWithOptions(observed_data,
+                                    disabled='custom-prefix')
+
+        del observed_data['objects']['0']['foo']
+        observed_data['objects']['0']['x_foo'] = "bar"
+        self.check_ignore(observed_data,
+                          'custom-prefix')
+        self.assertFalseWithOptions(observed_data,
+                                    disabled='custom-prefix-lax')
+
+    def test_observable_object_custom_properties_strict(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['0']['x_x_foo'] = "bar"
+        self.assertFalseWithOptions(observed_data, strict_properties=True)
+
+    def test_observable_object_extension_custom_properties(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['0']['extensions']['archive-ext']['foo'] = "bar"
+        self.assertFalseWithOptions(observed_data)
+
+        self.check_ignore(observed_data, 'custom-prefix,custom-prefix-lax')
+
+    def test_observable_object_extension_custom_properties_lax(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['0']['extensions']['archive-ext']['foo'] = "bar"
+        self.assertFalseWithOptions(observed_data,
+                                    disabled='custom-prefix')
+
+        del observed_data['objects']['0']['extensions']['archive-ext']['foo']
+        observed_data['objects']['0']['extensions']['archive-ext']['x_foo'] = "bar"
+        self.check_ignore(observed_data, 'custom-prefix')
+        self.assertFalseWithOptions(observed_data,
+                                    disabled='custom-prefix-lax')
+        self.assertFalseWithOptions(observed_data,
+                                    disabled='custom-prefix',
+                                    strict_properties=True)
+
+    def test_observable_object_embedded_custom_properties(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['2'] = {
+            "type": "x509-certificate",
+            "x509_v3_extensions": {
+              "issuer_alternative_name": "Example Corp",
+              "foo": "bar"
+            }
+        }
+        self.assertFalseWithOptions(observed_data)
+
+        self.check_ignore(observed_data, 'custom-prefix,custom-prefix-lax')
+
+    def test_observable_object_embedded_custom_properties_lax(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['2'] = {
+            "type": "x509-certificate",
+            "x509_v3_extensions": {
+              "issuer_alternative_name": "Example Corp",
+              "foo": "bar"
+            }
+        }
+        self.assertFalseWithOptions(observed_data,
+                                    disabled='custom-prefix')
+
+        del observed_data['objects']['2']['x509_v3_extensions']['foo']
+        observed_data['objects']['2']['x509_v3_extensions']['x_foo'] = "bar"
+        self.check_ignore(observed_data, 'custom-prefix')
+        self.assertFalseWithOptions(observed_data,
+                                    disabled='custom-prefix-lax')
+        self.assertFalseWithOptions(observed_data,
+                                    disabled='custom-prefix',
+                                    strict_properties=True)
+
+    def test_observable_object_embedded_dict_custom_properties(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['2'] = {
+            "type": "windows-registry-key",
+            "key": "hkey_local_machine\\system\\bar\\foo",
+            "values": [
+                {
+                    "name": "Foo",
+                    "data": "qwerty",
+                    "data_type": "REG_SZ",
+                    "foo": "buzz"
+                }
+            ]
+        }
+        self.assertFalseWithOptions(observed_data)
+
+        self.check_ignore(observed_data,
+                          'custom-prefix,custom-prefix-lax')
+
+    def test_observable_object_embedded_dict_custom_properties_lax(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['2'] = {
+            "type": "windows-registry-key",
+            "key": "hkey_local_machine\\system\\bar\\foo",
+            "values": [
+                {
+                    "name": "Foo",
+                    "data": "qwerty",
+                    "data_type": "REG_SZ",
+                    "foo": "buzz"
+                }
+            ]
+        }
+        self.assertFalseWithOptions(observed_data,
+                                    disabled='custom-prefix')
+
+        del observed_data['objects']['2']['values'][0]['foo']
+        observed_data['objects']['2']['values'][0]['x_foo'] = "bar"
+        self.check_ignore(observed_data, 'custom-prefix')
+        self.assertFalseWithOptions(observed_data,
+                                    disabled='custom-prefix-lax')
+        self.assertFalseWithOptions(observed_data,
+                                    disabled='custom-prefix',
+                                    strict_properties=True)
+
+    def test_observable_object_extension_embedded_custom_properties(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['0']['extensions']['ntfs-ext'] = {
+            "alternate_data_streams": [
+                  {
+                      "name": "second.stream",
+                      "size": 25536,
+                      "foo": "bar"
+                  }
+              ]
+        }
+        self.assertFalseWithOptions(observed_data)
+
+        self.check_ignore(observed_data,
+                          'custom-prefix,custom-prefix-lax')
+
+    def test_observable_object_extension_embedded_custom_properties_lax(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['0']['extensions']['ntfs-ext'] = {
+            "alternate_data_streams": [
+                  {
+                      "name": "second.stream",
+                      "size": 25536,
+                      "foo": "bar"
+                  }
+              ]
+        }
+        self.assertFalseWithOptions(observed_data,
+                                    disabled='custom-prefix')
+
+        del observed_data['objects']['0']['extensions']['ntfs-ext']['alternate_data_streams'][0]['foo']
+        observed_data['objects']['0']['extensions']['ntfs-ext']['alternate_data_streams'][0]['x_foo'] = "bar"
+        self.check_ignore(observed_data,
+                          'custom-prefix')
+        self.assertFalseWithOptions(observed_data,
+                                    disabled='custom-prefix-lax')
+        self.assertFalseWithOptions(observed_data,
+                                    disabled='custom-prefix',
+                                    strict_properties=True)
+
+    def test_observable_object_property_reference(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['2'] = {
+          "type": "directory",
+          "path": "C:\\Windows\\System32",
+          "contains_refs": ['0']
+        }
+        self.assertTrueWithOptions(observed_data)
+
+        observed_data['objects']['2']['contains_refs'] = ['999']
+        self.assertFalseWithOptions(observed_data)
+
+        observed_data['objects']['3'] = {
+          "type": "ipv4-addr",
+          "value": "203.0.113.1"
+        }
+        observed_data['objects']['2']['contains_refs'] = ['3']
+        self.assertFalseWithOptions(observed_data)
+
+    def test_observable_object_embedded_property_reference(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['0']['extensions']['archive-ext']['contains_refs'][0] = '999'
+        self.assertFalseWithOptions(observed_data)
+
+        observed_data['objects']['2'] = {
+          "type": "directory",
+          "path": "C:\\Windows\\System32",
+          "contains_refs": ['0']
+        }
+        observed_data['objects']['0']['extensions']['archive-ext']['contains_refs'][0] = '2'
+        self.assertFalseWithOptions(observed_data)
+
+    def test_observable_object_reserved_property(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['0']['type'] = 'action'
+        self.assertFalseWithOptions(observed_data)
+
+        observed_data['objects']['0']['type'] = 'file'
+        observed_data['objects']['0']['action'] = True
+        self.assertFalseWithOptions(observed_data)
+
+    def test_windows_registry_key_truncated(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['2'] = {
+            "type": "windows-registry-key",
+            "key": "HKLM\\system\\bar\\foo"
+        }
+        self.assertFalseWithOptions(observed_data)
+
+        observed_data['objects']['2']['key'] = "hkey_local_machine\\system\\bar\\foo"
+        self.assertTrueWithOptions(observed_data)
+
+    def test_vocab_windows_process_priority(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['2'] = {
+            "type": "process",
+            "pid": 314,
+            "extensions": {
+                "windows-process-ext": {
+                    "aslr_enabled": True,
+                    "dep_enabled": True,
+                    "priority": "HIGH_PRIORITY",
+                    "owner_sid": "S-1-5-21-186985262-1144665072-74031268-1309"
+                }
+            }
+        }
+        self.assertFalseWithOptions(observed_data)
+
+        observed_data['objects']['2']['extensions']['windows-process-ext']['priority'] = 'HIGH_PRIORITY_CLASS'
+        self.assertTrueWithOptions(observed_data)
+
+        self.check_ignore(observed_data, 'windows-process-priority-format')
+
+    def test_file_mime_type(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['0']['mime_type'] = "bla"
+        self.assertFalseWithOptions(observed_data)
+
+        self.check_ignore(observed_data, 'mime-type')
+
+    def test_artifact_mime_type(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['2'] = {
+            "type": "artifact",
+            "url": "http://www.example.com/file.txt",
+            "hashes": {
+                "MD5": "B4D33B0C7306351B9ED96578465C5579"
+            },
+            "mime_type": "bla/blabla"
+        }
+        self.assertFalseWithOptions(observed_data)
+
+        observed_data['objects']['2']['mime_type'] = "text/plain"
+        self.assertTrueWithOptions(observed_data)
+
+        del observed_data['objects']['2']['url']
+        self.assertFalseWithOptions(observed_data)
+
+    def test_file_character_set(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['0']['name_enc'] = "bla.bla.bla"
+        self.assertFalseWithOptions(observed_data)
+
+        observed_data['objects']['0']['name_enc'] = "ISO-8859-2"
+        self.assertTrueWithOptions(observed_data)
+
+    def test_directory_character_set(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['2'] = {
+          "type": "directory",
+          "path": "C:\\Windows\\System32",
+          "path_enc": "bla.bla.bla"
+        }
+        self.assertFalseWithOptions(observed_data)
+
+        observed_data['objects']['2']['path_enc'] = "US-ASCII"
+        self.assertTrueWithOptions(observed_data)
+
+    def test_pdf_doc_info(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['2'] = {
+            "type": "file",
+            "name": "foo.pdf",
+            "extensions": {
+                "pdf-ext": {
+                    "version": "1.7",
+                    "document_info_dict": {
+                        "Title": "Sample document",
+                        "foo": "bar"
+                    }
+                }
+            }
+        }
+        self.assertFalseWithOptions(observed_data)
+        self.check_ignore(observed_data, 'pdf-doc-info')
+
+    def test_software_language(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['2'] = {
+            "type": "software",
+            "name": "word",
+            "languages": ["bbb"]
+        }
+        self.assertFalseWithOptions(observed_data)
+
+        observed_data['objects']['2']['languages'][0] = 'eng'
+        self.assertTrueWithOptions(observed_data)
+
+    def test_email_address_invalid_value(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['2'] = {
+            "type": "email-addr",
+            "value": "John Doe <jdoe@example.com>",
+            "display_name": "John Doe"
+        }
+        self.assertFalseWithOptions(observed_data)
+
+        observed_data['objects']['2']['value'] = 'jdoe@example.com'
+        self.assertTrueWithOptions(observed_data)
+
+    def test_email_message_multipart(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['2'] = {
+          "type": "email-addr",
+          "value": "jdoe@example.com",
+          "display_name": "John Doe"
+        }
+        observed_data['objects']['3'] = {
+          "type": "email-addr",
+          "value": "mary@example.com",
+          "display_name": "Mary Smith"
+        }
+        observed_data['objects']['4'] = {
+            "type": "email-message",
+            "is_multipart": False,
+            "from_ref": "2",
+            "to_refs": ["3"],
+            "date": "1997-11-21T15:55:06Z",
+            "subject": "Saying Hello",
+            "body_multipart": [
+                {
+                    "content_type": "text/plain; charset=utf-8",
+                    "content_disposition": "inline",
+                    "body": "Cats are funny!"
+                },
+                {
+                    "content_type": "image/png",
+                    "content_disposition": "attachment; filename=\"tabby.png\"",
+                },
+                {
+                    "content_type": "application/zip",
+                    "content_disposition": "attachment; filename=\"tabby_pics.zip\"",
+                }
+            ]
+
+        }
+        self.assertFalseWithOptions(observed_data)
+
+        del observed_data['objects']['4']['body_multipart']
+        observed_data['objects']['4']['body'] = "Hello World"
+        self.assertTrueWithOptions(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(observed_data)
+
+        observed_data['objects']['2']['body_multipart'][1]['body_raw_ref'] = "0"
+        self.assertTrueWithOptions(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(observed_data)
+
+        del observed_data['objects']['2']['url']
+        self.assertTrueWithOptions(observed_data)
+
+        observed_data['objects']['2']['payload_bin'] = "failing test"
+        self.assertFalseWithOptions(observed_data)
+
+    def test_file_invalid_is_encrypted(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['2'] = {
+            "type": "artifact",
+            "mime_type": "application/zip",
+            "payload_bin": "VBORw0KGgoAAAANSUhEUgAAADI==",
+            "decryption_key": "My voice is my passport"
+        }
+        self.assertFalseWithOptions(observed_data)
+
+    def test_hash_length(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        hash_name = "abcdefghijklmnopqrstuvwxyz0123456789"
+        observed_data['objects']['0']['hashes'][hash_name] = "8D98A25E9D0662B1F4CA3BF22D6F53E9"
+        self.assertFalseWithOptions(observed_data)
+
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        hash_name = "MD"
+        observed_data['objects']['0']['hashes'][hash_name] = "8D98A25E9D0662B1F4CA3BF22D6F53E9"
+        self.assertFalseWithOptions(observed_data)
+
+    def test_invalid_accessed_timestamp(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['1']['created'] = "2016-11-31T08:17:27.000000Z"
+        self.assertFalseWithOptions(observed_data)
+
+    def test_invalid_extension_timestamp(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['1']['extensions'] = {'windows-pebinary-ext': {
+            "pe_type": "dll",
+            "time_date_stamp": "2016-11-31T08:17:27Z",
+        }}
+        self.assertFalseWithOptions(observed_data)
+
+    def test_invalid_observable_embedded_timestamp(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['2'] = {
+            "type": "x509-certificate",
+            "x509_v3_extensions": {
+              "private_key_usage_period_not_before": "2016-11-31T08:17:27.000000Z"
+            }
+        }
+        self.assertFalseWithOptions(observed_data)
+
+    def test_additional_schemas_custom_observable(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['2'] = {
+            "type": "x-new-observable",
+            "foo": 100
+        }
+        self.assertFalseWithOptions(observed_data, schema_dir=self.custom_schemas)
+
+        observed_data['objects']['2']['foo'] = 'something'
+        self.assertTrueWithOptions(observed_data, schema_dir=self.custom_schemas)
+
+    def test_additional_schemas_custom_extension(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['1']['extensions'] = {'x-example-com-foobar-ext': {
+            "bar_value": "something",
+        }}
+        self.assertFalseWithOptions(observed_data, schema_dir=self.custom_schemas)
+
+        observed_data['objects']['1']['extensions']['x-example-com-foobar-ext']['foo_value'] = 'something else'
+        self.assertTrueWithOptions(observed_data, schema_dir=self.custom_schemas)
+
+    def test_invalid_objects_property(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects'] = [
+            {
+                "type": "windows-registry-key",
+                "key": "HKEY_LOCAL_MACHINE\\SYSTEM\\ControlSet001\\Services\\WSALG2"
+            }
+            ]
+        self.assertFalseWithOptions(observed_data)
+
+    def test_url(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['2'] = {
+            "type": "url",
+            "value": "foo",
+        }
+        self.assertFalseWithOptions(observed_data)
+
+        observed_data['objects']['2']['value'] = "http://www.example.com/file.txt"
+        self.assertTrueWithOptions(observed_data)
+
+    def test_url_in_artifact(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['objects']['2'] = {
+            "type": "artifact",
+            "url": "foo",
+            "hashes": {
+                "MD5": "B4D33B0C7306351B9ED96578465C5579"
+            },
+            "mime_type": "text/plain"
+        }
+        self.assertFalseWithOptions(observed_data)
+
+        observed_data['objects']['2']['url'] = "http://www.example.com/file.txt"
+        self.assertTrueWithOptions(observed_data)
+
+    def test_invalid_seen_time(self):
+        observed_data = copy.deepcopy(self.valid_observed_data)
+        observed_data['first_observed'] = "2016-04-06T20:06:37.000Z"
+        observed_data['last_observed'] = "2016-04-06T20:06:37.000Z"
+        self.assertTrueWithOptions(observed_data)
+
+        observed_data['last_observed'] = "2016-01-01T00:00:00.000Z"
+        self.assertFalseWithOptions(observed_data)
+
+        observed_data['last_observed'] = "2016-05-07T20:06:37.000Z"
+        self.assertTrueWithOptions(observed_data)
diff --git a/stix2validator/test/v21/relationship_tests.py b/stix2validator/test/v21/relationship_tests.py
new file mode 100644
index 0000000..43f2f99
--- /dev/null
+++ b/stix2validator/test/v21/relationship_tests.py
@@ -0,0 +1,112 @@
+import copy
+import json
+
+from . import ValidatorTest
+from ... import validate_parsed_json, validate_string
+
+VALID_RELATIONSHIP = u"""
+{
+    "type": "relationship",
+    "spec_version": "2.1",
+    "id": "relationship--44298a74-ba52-4f0c-87a3-1824e67d7fad",
+    "created_by_ref": "identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
+    "created": "2016-04-06T20:06:37.000Z",
+    "modified": "2016-04-06T20:06:37.000Z",
+    "source_ref": "indicator--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f",
+    "target_ref": "malware--31b940d4-6f7f-459a-80ea-9c1f17b5891b",
+    "relationship_type": "indicates"
+}
+"""
+
+
+class RelationshipTestCases(ValidatorTest):
+    valid_relationship = json.loads(VALID_RELATIONSHIP)
+
+    def test_wellformed_relationship(self):
+        results = validate_string(VALID_RELATIONSHIP, self.options)
+        self.assertTrue(results.is_valid)
+
+    def test_relationship_type(self):
+        relationship = copy.deepcopy(self.valid_relationship)
+        relationship['relationship_type'] = "SOMETHING"
+        results = validate_parsed_json(relationship, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_source_relationship(self):
+        relationship = copy.deepcopy(self.valid_relationship)
+        relationship['source_ref'] = "relationship--31b940d4-6f7f-459a-80ea-9c1f17b5891b"
+        results = validate_parsed_json(relationship, self.options)
+        self.assertEqual(results.is_valid, False)
+        self.assertEqual(len(results.errors), 1)
+
+    def test_source_sighting(self):
+        relationship = copy.deepcopy(self.valid_relationship)
+        relationship['source_ref'] = "sighting--31b940d4-6f7f-459a-80ea-9c1f17b5891b"
+        results = validate_parsed_json(relationship, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_target_bundle(self):
+        relationship = copy.deepcopy(self.valid_relationship)
+        relationship['target_ref'] = "bundle--31b940d4-6f7f-459a-80ea-9c1f17b5891b"
+        results = validate_parsed_json(relationship, self.options)
+        self.assertEqual(results.is_valid, False)
+        self.assertEqual(len(results.errors), 1)
+
+    def test_target_marking_definition(self):
+        relationship = copy.deepcopy(self.valid_relationship)
+        relationship['target_ref'] = "marking-definition--31b940d4-6f7f-459a-80ea-9c1f17b5891b"
+        results = validate_parsed_json(relationship, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_relationship_types_invalid_type(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'] = "mitigates"
+        results = validate_parsed_json(relationship, self.options)
+        self.assertEqual(results.is_valid, False)
+
+        self.check_ignore(relationship, 'relationship-types')
+
+    def test_relationship_types_invalid_source(self):
+        relationship = copy.deepcopy(self.valid_relationship)
+        relationship['source_ref'] = "identity--b5437038-eb96-4652-88bc-5f94993b7326"
+        self.assertFalseWithOptions(relationship)
+
+    def test_relationship_types_invalid_target(self):
+        relationship = copy.deepcopy(self.valid_relationship)
+        relationship['target_ref'] = "report--af0976b2-e8f3-4646-8026-1cf4d0ce4d8a"
+        self.assertFalseWithOptions(relationship)
+
+    def test_relationship_types_valid(self):
+        relationship = copy.deepcopy(self.valid_relationship)
+        relationship['source_ref'] = "tool--31b940d4-6f7f-459a-80ea-9c1f17b5891b"
+        relationship['target_ref'] = "vulnerability--9c1f891b-459a-6f7f-80ea-31b17b5940d4"
+        relationship['relationship_type'] = "targets"
+        results = validate_parsed_json(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"
+        results = validate_parsed_json(relationship, self.options)
+        self.assertTrue(results.is_valid)
+
+    def test_missing_required(self):
+        relationship = copy.deepcopy(self.valid_relationship)
+        del relationship['relationship_type']
+        self.assertFalseWithOptions(relationship)
+
+    def test_invalid_stop_time(self):
+        relationship = copy.deepcopy(self.valid_relationship)
+        relationship['start_time'] = "2016-04-06T20:06:37.000Z"
+        relationship['stop_time'] = "2016-04-06T20:06:37.000Z"
+        self.assertFalseWithOptions(relationship)
+
+        relationship['stop_time'] = "2016-01-01T00:00:00.000Z"
+        self.assertFalseWithOptions(relationship)
+
+        relationship['stop_time'] = "2016-05-07T20:06:37.000Z"
+        self.assertTrueWithOptions(relationship)
diff --git a/stix2validator/test/v21/report_tests.py b/stix2validator/test/v21/report_tests.py
new file mode 100644
index 0000000..68d85d6
--- /dev/null
+++ b/stix2validator/test/v21/report_tests.py
@@ -0,0 +1,46 @@
+import copy
+import json
+
+from . import ValidatorTest
+from ... import validate_parsed_json, validate_string
+
+VALID_REPORT = u"""
+{
+  "type": "report",
+  "spec_version": "2.1",
+  "id": "report--84e4d88f-44ea-4bcd-bbf3-b2c1c320bcb3",
+  "created_by_ref": "identity--a463ffb3-1bd9-4d94-b02d-74e4f1658283",
+  "created": "2015-12-21T19:59:11.000Z",
+  "modified": "2016-05-21T19:59:11.000Z",
+  "published": "2016-05-21T19:59:11Z",
+  "name": "The Black Vine Cyberespionage Group",
+  "description": "A simple report with an indicator and campaign",
+  "report_types": ["campaign"],
+  "object_refs": [
+    "indicator--26ffb872-1dd9-446e-b6f5-d58527e5b5d2",
+    "campaign--83422c77-904c-4dc1-aff5-5c38f3a2c55c",
+    "relationship--f82356ae-fe6c-437c-9c24-6b64314ae68a"
+  ]
+}
+"""
+
+
+class IdentityTestCases(ValidatorTest):
+    valid_report = json.loads(VALID_REPORT)
+
+    def test_wellformed_report(self):
+        results = validate_string(VALID_REPORT, self.options)
+        self.assertTrue(results.is_valid)
+
+    def test_vocab_report_type(self):
+        report = copy.deepcopy(self.valid_report)
+        report['report_types'] = ["something"]
+        results = validate_parsed_json(report, self.options)
+        self.assertEqual(results.is_valid, False)
+
+        self.check_ignore(report, 'report-types')
+
+    def test_invalid_timestamp(self):
+        report = copy.deepcopy(self.valid_report)
+        report['published'] = "2016-11-31T08:17:27.000000Z"
+        self.assertFalseWithOptions(report)
diff --git a/stix2validator/test/v21/sighting_tests.py b/stix2validator/test/v21/sighting_tests.py
new file mode 100644
index 0000000..65cb77f
--- /dev/null
+++ b/stix2validator/test/v21/sighting_tests.py
@@ -0,0 +1,54 @@
+import copy
+import json
+
+from . import ValidatorTest
+from ... import validate_string
+
+VALID_SIGHTING = u"""
+{
+  "type": "sighting",
+  "spec_version": "2.1",
+  "id": "sighting--6b0e3956-95f3-4c04-a882-116832996da0",
+  "created": "2016-08-22T14:09:00.123Z",
+  "modified": "2016-08-22T14:09:00.123Z",
+  "first_seen": "2016-08-22T14:09:00.123456Z",
+  "sighting_of_ref": "malware--36ffb872-1dd9-446e-b6f5-d58527e5b5d2",
+  "observed_data_refs": ["observed-data--b67d30ff-02ac-498a-92f9-32f845f448cf"],
+  "where_sighted_refs": ["identity--f431f809-377b-45e0-aa1c-6a4751cae5ff"]
+}
+"""
+
+
+class IdentityTestCases(ValidatorTest):
+    valid_sighting = json.loads(VALID_SIGHTING)
+
+    def test_wellformed_report(self):
+        results = validate_string(VALID_SIGHTING, self.options)
+        self.assertTrue(results.is_valid)
+
+    def test_sighting_of_ref(self):
+        sighting = copy.deepcopy(self.valid_sighting)
+        sighting['sighting_of_ref'] = "bundle--36ffb872-1dd9-446e-b6f5-d58527e5b5d2"
+        self.assertFalseWithOptions(sighting)
+
+    def test_observed_data_refs(self):
+        sighting = copy.deepcopy(self.valid_sighting)
+        sighting['observed_data_refs'].append("tool--36ffb872-1dd9-446e-b6f5-d58527e5b5d2")
+        self.assertFalseWithOptions(sighting)
+
+    def test_where_sighted_refs(self):
+        sighting = copy.deepcopy(self.valid_sighting)
+        sighting['where_sighted_refs'].append("tool--36ffb872-1dd9-446e-b6f5-d58527e5b5d2")
+        self.assertFalseWithOptions(sighting)
+
+    def test_invalid_seen_time(self):
+        sighting = copy.deepcopy(self.valid_sighting)
+        sighting['first_seen'] = "2016-04-06T20:06:37.000Z"
+        sighting['last_seen'] = "2016-04-06T20:06:37.000Z"
+        self.assertFalseWithOptions(sighting)
+
+        sighting['last_seen'] = "2016-01-01T00:00:00.000Z"
+        self.assertFalseWithOptions(sighting)
+
+        sighting['last_seen'] = "2016-05-07T20:06:37.000Z"
+        self.assertTrueWithOptions(sighting)
diff --git a/stix2validator/test/v21/test_examples/identity.json b/stix2validator/test/v21/test_examples/identity.json
new file mode 100644
index 0000000..33d4010
--- /dev/null
+++ b/stix2validator/test/v21/test_examples/identity.json
@@ -0,0 +1,9 @@
+{
+  "type": "identity",
+  "spec_version": "2.1",
+  "id": "identity--8c6af861-7b20-41ef-9b59-6344fd872a8f",
+  "created": "2016-08-08T15:50:10.983Z",
+  "modified": "2016-08-08T15:50:10.983Z",
+  "name": "Franistan Intelligence",
+  "identity_class": "organization"
+}
diff --git a/stix2validator/test/v21/test_examples/identity_custom.json b/stix2validator/test/v21/test_examples/identity_custom.json
new file mode 100644
index 0000000..b80c9f3
--- /dev/null
+++ b/stix2validator/test/v21/test_examples/identity_custom.json
@@ -0,0 +1,16 @@
+{
+  "type": "bundle",
+  "id": "bundle--d6a999f2-849c-4d1b-aba4-4445c4444444",
+  "objects": [
+    {
+      "type": "identity",
+      "spec_version": "2.1",
+      "id": "identity--8c6af861-7b20-41ef-9b59-6344fd872a8f",
+      "created": "2016-08-08T15:50:10.983Z",
+      "modified": "2016-08-08T15:50:10.983Z",
+      "name": "Franistan Intelligence",
+      "identity_class": "organization",
+      "foo": "bar"
+    }
+  ]
+}
diff --git a/stix2validator/test/v21/test_examples/identity_warning.json b/stix2validator/test/v21/test_examples/identity_warning.json
new file mode 100644
index 0000000..8456ff3
--- /dev/null
+++ b/stix2validator/test/v21/test_examples/identity_warning.json
@@ -0,0 +1,9 @@
+{
+  "type": "identity",
+ "spec_version": "2.1",
+  "id": "identity--8c6af861-7b20-41ef-9b59-6344fd872a8f",
+  "created": "2016-08-08T15:50:10.983Z",
+  "modified": "2016-08-08T15:50:10.983Z",
+  "name": "Franistan Intelligence",
+  "identity_class": "organization"
+}
diff --git a/stix2validator/test/v21/test_examples/invalid_braces.json b/stix2validator/test/v21/test_examples/invalid_braces.json
new file mode 100644
index 0000000..d31428c
--- /dev/null
+++ b/stix2validator/test/v21/test_examples/invalid_braces.json
@@ -0,0 +1,9 @@
+<{
+  "type": "identity",
+  "spec_version": "2.1",
+  "id": "identity--8c6af861-7b20-41ef-9b59-6344fd872a8f",
+  "created": "2016-08-08T15:50:10.983Z",
+  "modified": "2016-08-08T15:50:10.983Z",
+  "name": "Franistan Intelligence",
+  "identity_class": "organization"
+}>
diff --git a/stix2validator/test/v21/test_examples/invalid_comma.json b/stix2validator/test/v21/test_examples/invalid_comma.json
new file mode 100644
index 0000000..7fb5052
--- /dev/null
+++ b/stix2validator/test/v21/test_examples/invalid_comma.json
@@ -0,0 +1,9 @@
+{
+  "type": "identity",
+  "spec_version": "2.1",
+  "id": "identity--8c6af861-7b20-41ef-9b59-6344fd872a8f",
+  "created": "2016-08-08T15:50:10.983Z",
+  "modified": "2016-08-08T15:50:10.983Z",
+  "name": "Franistan Intelligence",
+  "identity_class": "organization",
+}
diff --git a/stix2validator/test/v21/test_examples/invalid_identity.json b/stix2validator/test/v21/test_examples/invalid_identity.json
new file mode 100644
index 0000000..8f23cd5
--- /dev/null
+++ b/stix2validator/test/v21/test_examples/invalid_identity.json
@@ -0,0 +1,8 @@
+{
+  "type": "identity",
+  "spec_version": "2.1",
+  "id": "identity--8c6af861-7b20-41ef-9b59-6344fd872a8f",
+  "created": "2016-08-08T15:50:10.983Z",
+  "name": "Franistan Intelligence",
+  "identity_class": "organization"
+}
diff --git a/stix2validator/test/v21/test_examples/invalid_timestamp.json b/stix2validator/test/v21/test_examples/invalid_timestamp.json
new file mode 100644
index 0000000..7041ccd
--- /dev/null
+++ b/stix2validator/test/v21/test_examples/invalid_timestamp.json
@@ -0,0 +1,9 @@
+{
+  "type": "identity",
+  "spec_version": "2.1",
+  "id": "identity--8c6af861-7b20-41ef-9b59-6344fd872a8f",
+  "created": "2016-08-08T15:50:10.983Z",
+  "modified": "2015-08-08T15:50:10.983Z",
+  "name": "Franistan Intelligence",
+  "identity_class": "organization"
+}
diff --git a/stix2validator/test/v21/test_schemas/file.json b/stix2validator/test/v21/test_schemas/file.json
new file mode 100644
index 0000000..0cca60e
--- /dev/null
+++ b/stix2validator/test/v21/test_schemas/file.json
@@ -0,0 +1,20 @@
+{
+    "properties": {
+        "extensions": {
+            "patternProperties": {
+                "^x-example-com-foobar-ext$": {
+                    "type": "object",
+                    "properties": {
+                        "foo_value": {
+                            "type": "string"
+                        },
+                        "bar_value": {
+                            "type": "string"
+                        }
+                    },
+                    "required": ["foo_value"]
+                }
+            }
+        }
+    }
+}
diff --git a/stix2validator/test/v21/test_schemas/indicator.json b/stix2validator/test/v21/test_schemas/indicator.json
new file mode 100644
index 0000000..dac2297
--- /dev/null
+++ b/stix2validator/test/v21/test_schemas/indicator.json
@@ -0,0 +1,8 @@
+{
+    "properties": {
+        "name": {
+            "type": "string",
+            "pattern": "^Foobar$"
+        }
+    }
+}
diff --git a/stix2validator/test/v21/test_schemas/x-foo-bar.json b/stix2validator/test/v21/test_schemas/x-foo-bar.json
new file mode 100644
index 0000000..e61ebcb
--- /dev/null
+++ b/stix2validator/test/v21/test_schemas/x-foo-bar.json
@@ -0,0 +1,1 @@
+{a
diff --git a/stix2validator/test/v21/test_schemas/x-new-observable.json b/stix2validator/test/v21/test_schemas/x-new-observable.json
new file mode 100644
index 0000000..4f2b36e
--- /dev/null
+++ b/stix2validator/test/v21/test_schemas/x-new-observable.json
@@ -0,0 +1,7 @@
+{
+    "properties": {
+        "foo": {
+            "type": "string"
+        }
+    }
+}
diff --git a/stix2validator/test/v21/test_schemas/x-new-type.json b/stix2validator/test/v21/test_schemas/x-new-type.json
new file mode 100644
index 0000000..df609c7
--- /dev/null
+++ b/stix2validator/test/v21/test_schemas/x-new-type.json
@@ -0,0 +1,10 @@
+{
+    "properties": {
+        "property1": {
+            "type": "string"
+        },
+        "property2": {
+            "type": "integer"
+        }
+    }
+}
diff --git a/stix2validator/test/v21/threat_actor_tests.py b/stix2validator/test/v21/threat_actor_tests.py
new file mode 100644
index 0000000..0c7aeb5
--- /dev/null
+++ b/stix2validator/test/v21/threat_actor_tests.py
@@ -0,0 +1,68 @@
+import copy
+import json
+
+from . import ValidatorTest
+from ... import validate_parsed_json, validate_string
+
+VALID_THREAT_ACTOR = u"""
+{
+  "type": "threat-actor",
+  "spec_version": "2.1",
+  "id": "threat-actor--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f",
+  "created_by_ref": "identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
+  "created": "2016-04-06T20:03:48.000Z",
+  "modified": "2016-04-06T20:03:48.000Z",
+  "threat_actor_types": ["hacker"],
+  "name": "Evil Org",
+  "description": "The Evil Org threat actor group"
+}
+"""
+
+
+class ThreatActorTestCases(ValidatorTest):
+    valid_threat_actor = json.loads(VALID_THREAT_ACTOR)
+
+    def test_wellformed_threat_actor(self):
+        results = validate_string(VALID_THREAT_ACTOR, self.options)
+        self.assertTrue(results.is_valid)
+
+    def test_vocab_attack_motivation(self):
+        threat_actor = copy.deepcopy(self.valid_threat_actor)
+        threat_actor['primary_motivation'] = "selfishness"
+        results = validate_parsed_json(threat_actor, self.options)
+        self.assertEqual(results.is_valid, False)
+
+        self.check_ignore(threat_actor, 'attack-motivation')
+
+    def test_vocab_attack_resource_level(self):
+        threat_actor = copy.deepcopy(self.valid_threat_actor)
+        threat_actor['resource_level'] = "high"
+        results = validate_parsed_json(threat_actor, self.options)
+        self.assertEqual(results.is_valid, False)
+
+        self.check_ignore(threat_actor, 'attack-resource-level')
+
+    def test_vocab_threat_actor_type(self):
+        threat_actor = copy.deepcopy(self.valid_threat_actor)
+        threat_actor['threat_actor_types'] += ["anonymous"]
+        results = validate_parsed_json(threat_actor, self.options)
+        self.assertEqual(results.is_valid, False)
+
+        self.check_ignore(threat_actor, 'threat-actor-types')
+
+    def test_vocab_threat_actor_role(self):
+        threat_actor = copy.deepcopy(self.valid_threat_actor)
+        threat_actor['roles'] = ["contributor"]
+        results = validate_parsed_json(threat_actor, self.options)
+        self.assertEqual(results.is_valid, False)
+
+        self.check_ignore(threat_actor, 'threat-actor-role')
+
+    def test_vocab_threat_actor_sophistication_level(self):
+        threat_actor = copy.deepcopy(self.valid_threat_actor)
+        threat_actor['sophistication'] = "high"
+        results = validate_parsed_json(threat_actor, self.options)
+        self.assertEqual(results.is_valid, False)
+
+        self.check_ignore(threat_actor,
+                          'threat-actor-sophistication')
diff --git a/stix2validator/test/v21/tool_tests.py b/stix2validator/test/v21/tool_tests.py
new file mode 100644
index 0000000..baa27a7
--- /dev/null
+++ b/stix2validator/test/v21/tool_tests.py
@@ -0,0 +1,81 @@
+import copy
+import json
+
+from . import ValidatorTest
+from ... import validate_parsed_json, validate_string
+
+VALID_TOOL = u"""
+{
+  "type": "tool",
+  "spec_version": "2.1",
+  "id": "tool--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f",
+  "created_by_ref": "identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
+  "created": "2016-04-06T20:03:48.000Z",
+  "modified": "2016-04-06T20:03:48.000Z",
+  "name": "VNC",
+  "tool_types": ["remote-access"],
+  "kill_chain_phases": [
+    {
+      "kill_chain_name": "lockheed-martin-cyber-kill-chain",
+      "phase_name": "command-and-control"
+    }
+  ]
+}
+"""
+
+
+class ToolTestCases(ValidatorTest):
+    valid_tool = json.loads(VALID_TOOL)
+
+    def test_wellformed_tool(self):
+        results = validate_string(VALID_TOOL, self.options)
+        self.assertTrue(results.is_valid)
+
+    def test_vocab_tool_type(self):
+        tool = copy.deepcopy(self.valid_tool)
+        tool['tool_types'] += ["multi-purpose"]
+        results = validate_parsed_json(tool, self.options)
+        self.assertEqual(results.is_valid, False)
+
+        self.check_ignore(tool, 'tool-types')
+
+    def test_kill_chain_name(self):
+        tool = copy.deepcopy(self.valid_tool)
+        tool['kill_chain_phases'][0]['kill_chain_name'] = "Something"
+        results = validate_parsed_json(tool, self.options)
+        self.assertEqual(results.is_valid, False)
+
+        tool['kill_chain_phases'][0]['kill_chain_name'] = "some thing"
+        results = validate_parsed_json(tool, self.options)
+        self.assertEqual(results.is_valid, False)
+
+        tool['kill_chain_phases'][0]['kill_chain_name'] = "some_thing"
+        results = validate_parsed_json(tool, self.options)
+        self.assertEqual(results.is_valid, False)
+
+        self.check_ignore(tool, 'kill-chain-names')
+
+    def test_kill_chain_phase_name(self):
+        tool = copy.deepcopy(self.valid_tool)
+        tool['kill_chain_phases'][0]['phase_name'] = "Something"
+        results = validate_parsed_json(tool, self.options)
+        self.assertEqual(results.is_valid, False)
+
+        tool['kill_chain_phases'][0]['phase_name'] = "some thing"
+        results = validate_parsed_json(tool, self.options)
+        self.assertEqual(results.is_valid, False)
+
+        tool['kill_chain_phases'][0]['phase_name'] = "some_thing"
+        results = validate_parsed_json(tool, self.options)
+        self.assertEqual(results.is_valid, False)
+
+        self.check_ignore(tool, 'kill-chain-names')
+
+    def test_format_and_value_checks(self):
+        tool = copy.deepcopy(self.valid_tool)
+        tool['kill_chain_phases'][0]['phase_name'] = "Something_invalid"
+        tool['tool_types'] += ["something-not-in-vocab"]
+
+        self.assertFalseWithOptions(tool, disabled='1')
+        self.assertFalseWithOptions(tool, disabled='2')
+        self.assertTrueWithOptions(tool, disabled='1,2')
diff --git a/stix2validator/test/util_tests.py b/stix2validator/test/v21/util_tests.py
similarity index 96%
rename from stix2validator/test/util_tests.py
rename to stix2validator/test/v21/util_tests.py
index 4bce298..905b089 100644
--- a/stix2validator/test/util_tests.py
+++ b/stix2validator/test/v21/util_tests.py
@@ -1,4 +1,4 @@
-from .. import parse_args, validate_string
+from ... import parse_args, validate_string
 from .indicator_tests import VALID_INDICATOR
 
 
diff --git a/stix2validator/test/v21/vulnerability_tests.py b/stix2validator/test/v21/vulnerability_tests.py
new file mode 100644
index 0000000..752ca1a
--- /dev/null
+++ b/stix2validator/test/v21/vulnerability_tests.py
@@ -0,0 +1,81 @@
+import copy
+import json
+
+from . import ValidatorTest
+from ... import validate_parsed_json, validate_string
+
+VALID_VULNERABILITY = u"""
+{
+  "type": "vulnerability",
+  "spec_version": "2.1",
+  "id": "vulnerability--0c7b5b88-8ff7-4a4d-aa9d-feb398cd0061",
+  "created": "2016-05-12T08:17:27.000Z",
+  "modified": "2016-05-12T08:17:27.000Z",
+  "name": "CVE-2016-1234",
+  "external_references": [
+    {
+      "source_name": "cve",
+      "external_id": "CVE-2016-1234"
+    }
+  ]
+}
+"""
+
+
+class VulnerabilityTestCases(ValidatorTest):
+    valid_vulnerability = json.loads(VALID_VULNERABILITY)
+
+    def test_wellformed_vulnerability(self):
+        results = validate_string(VALID_VULNERABILITY, self.options)
+        self.assertTrue(results.is_valid)
+
+    def test_incorrect_cve_format(self):
+        vulnerability = copy.deepcopy(self.valid_vulnerability)
+        ext_refs = vulnerability['external_references']
+        ext_refs[0]['external_id'] = "2016-1234"
+        results = validate_parsed_json(vulnerability, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_incorrect_cve_format_number(self):
+        vulnerability = copy.deepcopy(self.valid_vulnerability)
+        ext_refs = vulnerability['external_references']
+        ext_refs[0]['external_id'] = "CVE-20161234"
+        results = validate_parsed_json(vulnerability, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_incorrect_cve_format_letter(self):
+        vulnerability = copy.deepcopy(self.valid_vulnerability)
+        ext_refs = vulnerability['external_references']
+        ext_refs[0]['external_id'] = "CVE-2016-abc"
+        results = validate_parsed_json(vulnerability, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_incorrect_cve_format_lowercase(self):
+        vulnerability = copy.deepcopy(self.valid_vulnerability)
+        ext_refs = vulnerability['external_references']
+        ext_refs[0]['external_id'] = "cve-2016-1234"
+        results = validate_parsed_json(vulnerability, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_incorrect_cve_source_name(self):
+        vulnerability = copy.deepcopy(self.valid_vulnerability)
+        ext_refs = vulnerability['external_references']
+        ext_refs[0]['source_name'] = "CVE"
+        results = validate_parsed_json(vulnerability, self.options)
+        self.assertEqual(results.is_valid, False)
+
+    def test_url_no_hash(self):
+        vulnerability = copy.deepcopy(self.valid_vulnerability)
+        ext_refs = vulnerability['external_references']
+        ext_refs[0]['source_name'] = "veris"
+        ext_refs[0]['url'] = "https://github.com/vz-risk/VCDB/blob/master/" \
+                             "data/json/0001AA7F-C601-424A-B2B8-BE6C9F5164E7.json"
+        ext_refs[0]['external_id'] = "0001AA7F-C601-424A-B2B8-BE6C9F5164E7"
+        self.assertFalseWithOptions(vulnerability)
+
+        self.check_ignore(vulnerability, 'extref-hashes')
+
+        ext_refs[0]['hashes'] = {
+            "SHA-256": "6db12788c37247f2316052e142f42f4b259d6561751e5f401a1ae2a6df9c674b"
+        }
+        self.assertTrueWithOptions(vulnerability)