Repository: oasis-open/cti-stix-validator (python)

Issue:
pattern_lang has wrong error
The property pattern_lang has been removed from the Indicator, but it still says:
{217} pattern_lang contains a value not in the pattern-lang-ov vocabulary.

Pull request:
Update to more current version of RC3
- Remove 'pattern_lang'
- Remove location attributes
- Switch to 'objects' in bundles instead of separate properties for each object type
- Prevent marking circular definitions
- Ensure marking selectors refer to items actually in the object
- Produce warnings/errors for _all_ instances of certain categories instead of just the first one



Closes #6.

Patch:
diff --git a/README.rst b/README.rst
index 267888e..4fde284 100644
--- a/README.rst
+++ b/README.rst
@@ -137,9 +137,6 @@ Enabling supersedes disabling. Simultaneously enabling and disabling the same ch
 |  216   | malware-label               | certain property values are from the   |
 |        |                             | malware_label vocabulary               |
 +--------+-----------------------------+----------------------------------------+
-|  217   | pattern-lang                | certain property values are from the   |
-|        |                             | pattern_lang vocabulary                |
-+--------+-----------------------------+----------------------------------------+
 |  218   | report-label                | certain property values are from the   |
 |        |                             | report_label vocabulary                |
 +--------+-----------------------------+----------------------------------------+
diff --git a/stix2validator/__init__.py b/stix2validator/__init__.py
index 3fafd02..a244e68 100644
--- a/stix2validator/__init__.py
+++ b/stix2validator/__init__.py
@@ -380,15 +380,16 @@ def validate_file(fn, options=None):
     results = ValidationResults(fn=fn)
     output.info("Performing JSON schema validation on %s" % fn)
 
-    with open(fn) as instance_file:
-        instance = json.load(instance_file)
-
     if not options:
         options = ValidationOptions(files=fn)
 
     try:
+        with open(fn) as instance_file:
+            instance = json.load(instance_file)
+
         if options.files:
             results = schema_validate(instance, options)
+
     except SchemaInvalidError as ex:
         results.fatal = ValidationErrorResults(ex)
         msg = ("File '{fn}' was schema-invalid. No further validation "
diff --git a/stix2validator/enums.py b/stix2validator/enums.py
index d66dd61..be3ffe2 100644
--- a/stix2validator/enums.py
+++ b/stix2validator/enums.py
@@ -86,22 +86,16 @@ MALWARE_LABEL_OV = [
     "virus",
     "worm"
 ]
-PATTERN_LANG_OV = [
-    "stix",
-    "openioc",
-    "snort",
-    "yara"
-]
 REPORT_LABEL_OV = [
     "threat-report",
     "attack-pattern",
     "campaign",
+    "identity",
     "indicator",
     "malware",
     "observed-data",
     "threat-actor",
     "tool",
-    "victim-target",
     "vulnerability"
 ]
 THREAT_ACTOR_LABEL_OV = [
@@ -174,9 +168,6 @@ INDUSTRY_SECTOR_USES = {
 MALWARE_LABEL_USES = {
     "malware": ["labels"]
 }
-PATTERN_LANG_USES = {
-    "indicator": ["pattern_lang"]
-}
 REPORT_LABEL_USES = {
     "report": ["labels"]
 }
@@ -299,8 +290,6 @@ PROPERTIES = {
         'description',
         'identity_class',
         'sectors',
-        'regions',
-        'nationalities',
         'contact_information'
     ],
     "indicator": [
@@ -318,8 +307,6 @@ PROPERTIES = {
         'name',
         'description',
         'pattern',
-        'pattern_lang',
-        'pattern_lang_version',
         'valid_from',
         'valid_from_precision',
         'valid_until',
@@ -346,9 +333,7 @@ PROPERTIES = {
         'goals',
         'resource_level',
         'primary_motivation',
-        'secondary_motivations',
-        'region',
-        'country'
+        'secondary_motivations'
     ],
     "malware": [
         'type',
@@ -459,22 +444,7 @@ PROPERTIES = {
         'type',
         'id',
         'spec_version',
-        'attack_patterns',
-        'campaigns',
-        'courses_of_action',
-        'identities',
-        'indicators',
-        'intrusion_sets',
-        'malware',
-        'marking_definitions',
-        'observed_data',
-        'relationships',
-        'reports',
-        'sightings',
-        'threat_actors',
-        'tools',
-        'vulnerabilities',
-        'custom_objects'
+        'objects'
     ],
     "relationship": [
         'type',
@@ -651,8 +621,7 @@ VOCAB_PROPERTIES = {
         'sectors'
     ],
     "indicator": [
-        'labels',
-        'pattern_lang'
+        'labels'
     ],
     "intrusion-set": [
         'resource_level',
diff --git a/stix2validator/scripts/stix2_validator.py b/stix2validator/scripts/stix2_validator.py
index e69f35c..ec927f6 100644
--- a/stix2validator/scripts/stix2_validator.py
+++ b/stix2validator/scripts/stix2_validator.py
@@ -51,8 +51,6 @@ the validator performs, along with the code to use with the --enable or
 |      |                             | industry_sector vocabulary             |
 | 216  | malware-label               | certain property values are from the   |
 |      |                             | malware_label vocabulary               |
-| 217  | pattern-lang                | certain property values are from the   |
-|      |                             | pattern_lang vocabulary                |
 | 218  | report-label                | certain property values are from the   |
 |      |                             | report_label vocabulary                |
 | 219  | threat-actor-label          | certain property values are from the   |
diff --git a/stix2validator/validators.py b/stix2validator/validators.py
index d77dfed..31df7b9 100644
--- a/stix2validator/validators.py
+++ b/stix2validator/validators.py
@@ -5,6 +5,7 @@
 import os
 import re
 from collections import deque
+from types import GeneratorType
 
 # external
 from jsonschema import Draft4Validator
@@ -129,25 +130,100 @@ def timestamp_precision(instance):
 
         ts_field = precision_matches.group(1)
         if ts_field not in instance:
-            return JSONError("There is no corresponding '%s' field for %s"
-                             % (ts_field, prop_name), instance['id'])
-
-        pattern = ""
-        if instance[prop_name] == 'year':
-            pattern = "^[0-9]{4}-01-01T00:00:00(\\.0+)?Z$"
-        elif instance[prop_name] == 'month':
-            pattern = "^[0-9]{4}-[0-9]{2}-01T00:00:00(\\.0+)?Z$"
-        elif instance[prop_name] == 'day':
-            pattern = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T00:00:00(\\.0+)?Z$"
-        elif instance[prop_name] == 'hour':
-            pattern = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:00:00(\\.0+)?Z$"
-        elif instance[prop_name] == 'minute':
-            pattern = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:00(\\.0+)?Z$"
-
-        if not re.match(pattern, instance[ts_field]):
-            return JSONError("%s timestamp is not the correct format for '%s' "
-                             "precision." % (ts_field, instance[prop_name]),
-                             instance['id'])
+            yield JSONError("There is no corresponding '%s' field for %s"
+                            % (ts_field, prop_name), instance['id'])
+        else:
+            pattern = ""
+            if instance[prop_name] == 'year':
+                pattern = "^[0-9]{4}-01-01T00:00:00(\\.0+)?Z$"
+            elif instance[prop_name] == 'month':
+                pattern = "^[0-9]{4}-[0-9]{2}-01T00:00:00(\\.0+)?Z$"
+            elif instance[prop_name] == 'day':
+                pattern = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T00:00:00(\\.0+)?Z$"
+            elif instance[prop_name] == 'hour':
+                pattern = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:00:00(\\.0+)?Z$"
+            elif instance[prop_name] == 'minute':
+                pattern = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:00(\\.0+)?Z$"
+
+            if not re.match(pattern, instance[ts_field]):
+                yield JSONError("%s timestamp is not the correct format for '%s' "
+                                "precision." % (ts_field, instance[prop_name]),
+                                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
+
+    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 = re.match(r"\[(\d+)\]", segmt)
+                if index_match:
+                    try:
+                        idx = int(index_match.group(1))
+                        obj = obj[idx]
+                    except IndexError as e:
+                        yield JSONError("'%s' is not a valid selector because"
+                                        " %s is not a valid index."
+                                        % (selector, idx), instance['id'])
+                    except KeyError as e:
+                        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 as e:
+                        yield JSONError("'%s' is not a valid selector because"
+                                        " '%s' is not a property."
+                                        % (selector, segmt), instance['id'])
+                prev_segmt = segmt
 
 
 # Checks for SHOULD Requirements
@@ -158,11 +234,11 @@ def custom_object_prefix_strict(instance):
     if (instance['type'] not in enums.TYPES and
             instance['type'] not in enums.RESERVED_OBJECTS and
             not re.match("^x\-.+\-.+$", instance['type'])):
-        return JSONError("Custom object type '%s' should start with 'x-' "
-                         "followed by a source unique identifier (like a"
-                         "domain name with dots replaced by dashes), a dash "
-                         "and then the name." % instance['type'],
-                         instance['id'], 'custom-object-prefix')
+        yield JSONError("Custom object type '%s' should start with 'x-' "
+                        "followed by a source unique identifier (like a"
+                        "domain name with dots replaced by dashes), a dash "
+                        "and then the name." % instance['type'],
+                        instance['id'], 'custom-object-prefix')
 
 
 def custom_object_prefix_lax(instance):
@@ -172,10 +248,10 @@ def custom_object_prefix_lax(instance):
     if (instance['type'] not in enums.TYPES and
             instance['type'] not in enums.RESERVED_OBJECTS and
             not re.match("^x\-.+$", instance['type'])):
-        return JSONError("Custom object type '%s' should start with 'x-' in "
-                         "order to be compatible with future versions of the "
-                         "STIX 2 specification." % instance['type'],
-                         instance['id'], 'custom-object-prefix')
+        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-object-prefix')
 
 
 def custom_property_prefix_strict(instance):
@@ -189,12 +265,12 @@ def custom_property_prefix_strict(instance):
                 prop_name not in enums.RESERVED_PROPERTIES and
                 not re.match("^x_.+_.+$", prop_name)):
 
-            return JSONError("Custom property '%s' should have a type that "
-                             "starts with 'x_' followed by a source unique "
-                             "identifier (like a domain name with dots "
-                             "replaced by dashes), a dash and then the name." %
-                             prop_name, instance['id'],
-                             'custom-property-prefix')
+            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 dashes), a dash and then the name." %
+                            prop_name, instance['id'],
+                            'custom-property-prefix')
 
 
 def custom_property_prefix_lax(instance):
@@ -209,11 +285,11 @@ def custom_property_prefix_lax(instance):
                 prop_name not in enums.RESERVED_PROPERTIES and
                 not re.match("^x_.+$", prop_name)):
 
-            return JSONError("Custom property '%s' should have a type that "
-                             "starts with 'x_' in order to be compatible with "
-                             "future versions of the STIX 2 specification." %
-                             prop_name, instance['id'],
-                             'custom-property-prefix')
+            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-property-prefix')
 
 
 def open_vocab_values(instance):
@@ -235,11 +311,11 @@ def open_vocab_values(instance):
 
             for v in values:
                 if not v.islower() or '_' in v or ' ' in v:
-                    return JSONError("Open vocabulary value '%s' should be all"
-                                     " lowercase and use dashes instead of"
-                                     " spaces or underscores as word"
-                                     " separators." % v, instance['id'],
-                                     'open-vocab-format')
+                    yield JSONError("Open vocabulary value '%s' should be all"
+                                    " lowercase and use dashes instead of"
+                                    " spaces or underscores as word"
+                                    " separators." % v, instance['id'],
+                                    'open-vocab-format')
 
 
 def kill_chain_phase_names(instance):
@@ -255,23 +331,23 @@ def kill_chain_phase_names(instance):
 
             chain_name = phase['kill_chain_name']
             if not chain_name.islower() or '_' in chain_name or ' ' in chain_name:
-                return JSONError("kill_chain_name '%s' should be all lowercase"
-                                 " and use dashes instead of spaces or "
-                                 "underscores as word separators." % chain_name,
-                                 instance['id'], 'kill-chain-names')
+                yield JSONError("kill_chain_name '%s' should be all lowercase"
+                                " and use dashes 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:
-                return JSONError("phase_name '%s' should be all lowercase and "
-                                 "use dashes instead of spaces or underscores "
-                                 "as word separators." % phase_name,
-                                 instance['id'], 'kill-chain-names')
+                yield JSONError("phase_name '%s' should be all lowercase and "
+                                "use dashes 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.
 
-    It checks properties of objects specified in the appropriate `_USES`
+    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.
     """
@@ -290,9 +366,9 @@ def check_vocab(instance, vocab, code):
 
                 if not is_in:
                     vocab_name = vocab.replace('_', '-').lower()
-                    return JSONError("%s contains a value not in the %s-ov "
-                                     "vocabulary." % (prop, vocab_name),
-                                     instance['id'], code)
+                    yield JSONError("%s contains a value not in the %s-ov "
+                                    "vocabulary." % (prop, vocab_name),
+                                    instance['id'], code)
 
 
 def vocab_attack_motivation(instance):
@@ -325,11 +401,6 @@ def vocab_malware_label(instance):
                        'malware-label')
 
 
-def vocab_pattern_lang(instance):
-    return check_vocab(instance, "PATTERN_LANG",
-                       'pattern-lang')
-
-
 def vocab_report_label(instance):
     return check_vocab(instance, "REPORT_LABEL",
                        'report-label')
@@ -435,7 +506,6 @@ CHECK_CODES = {
     '214': 'indicator-label',
     '215': 'industry-sector',
     '216': 'malware-label',
-    '217': 'pattern-lang',
     '218': 'report-label',
     '219': 'threat-actor-label',
     '220': 'threat-actor-role',
@@ -458,7 +528,6 @@ CHECKS = {
         vocab_indicator_label,
         vocab_industry_sector,
         vocab_malware_label,
-        vocab_pattern_lang,
         vocab_report_label,
         vocab_threat_actor_label,
         vocab_threat_actor_role,
@@ -486,7 +555,6 @@ CHECKS = {
         vocab_indicator_label,
         vocab_industry_sector,
         vocab_malware_label,
-        vocab_pattern_lang,
         vocab_report_label,
         vocab_threat_actor_label,
         vocab_threat_actor_role,
@@ -502,7 +570,6 @@ CHECKS = {
         vocab_indicator_label,
         vocab_industry_sector,
         vocab_malware_label,
-        vocab_pattern_lang,
         vocab_report_label,
         vocab_threat_actor_label,
         vocab_threat_actor_role,
@@ -516,7 +583,6 @@ CHECKS = {
     'indicator-label': vocab_indicator_label,
     'industry-sector': vocab_industry_sector,
     'malware-label': vocab_malware_label,
-    'pattern-lang': vocab_pattern_lang,
     'report-label': vocab_report_label,
     'threat-actor-label': vocab_threat_actor_label,
     'threat-actor-role': vocab_threat_actor_role,
@@ -544,7 +610,10 @@ class CustomDraft4Validator(Draft4Validator):
         validator_list = [
             modified_created,
             version,
-            timestamp_precision
+            timestamp_precision,
+            object_marking_circular_refs,
+            granular_markings_circular_refs,
+            marking_selector_syntax
         ]
 
         # --strict-types
@@ -598,8 +667,6 @@ class CustomDraft4Validator(Draft4Validator):
                             validator_list.append(CHECKS['industry-sector'])
                         if 'malware-label' not in options.disabled:
                             validator_list.append(CHECKS['malware-label'])
-                        if 'pattern-lang' not in options.disabled:
-                            validator_list.append(CHECKS['pattern-lang'])
                         if 'report-label' not in options.disabled:
                             validator_list.append(CHECKS['report-label'])
                         if 'threat-actor-label' not in options.disabled:
@@ -655,7 +722,10 @@ class CustomDraft4Validator(Draft4Validator):
         # Perform validation
         for v_function in validators:
             result = v_function(instance)
-            if result is not None:
+            if isinstance(result, GeneratorType):
+                for x in result:
+                    yield x
+            elif result is not None:
                 yield result
 
         # Validate any child STIX objects


Test patch:
diff --git a/stix2validator/test/bundle_tests.py b/stix2validator/test/bundle_tests.py
new file mode 100644
index 0000000..f7da349
--- /dev/null
+++ b/stix2validator/test/bundle_tests.py
@@ -0,0 +1,53 @@
+import unittest
+import copy
+import json
+from . import ValidatorTest
+
+VALID_BUNDLE = """
+{
+  "type": "bundle",
+  "id": "bundle--44af6c39-c09b-49c5-9de2-394224b04982",
+  "spec_version": "2.0",
+  "objects": [
+    {
+      "type": "identity",
+      "id": "identity--8ae20dde-83d4-4218-88fd-41ef0dabf9d1",
+      "created": "2016-08-22T14:09:00.123456Z",
+      "modified": "2016-08-22T14:09:00.123456Z",
+      "name": "mitre.org",
+      "version": 1,
+      "identity_class": "organization"
+    }
+  ]
+}
+"""
+
+
+class BundleTestCases(ValidatorTest):
+    valid_bundle = json.loads(VALID_BUNDLE)
+
+    def test_wellformed_bundle(self):
+        self.assertTrueWithOptions(VALID_BUNDLE)
+
+    def test_bundle_object_categories(self):
+        bundle = copy.deepcopy(self.valid_bundle)
+        bundle['identities'] = bundle['objects']
+        del bundle['objects']
+        bundle = json.dumps(bundle)
+        self.assertFalseWithOptions(bundle)
+
+    def test_bundle_created(self):
+        bundle = copy.deepcopy(self.valid_bundle)
+        bundle['created'] = "2016-08-22T14:09:00.123456Z"
+        bundle = json.dumps(bundle)
+        self.assertFalseWithOptions(bundle)
+
+    def test_bundle_version(self):
+        bundle = copy.deepcopy(self.valid_bundle)
+        bundle['version'] = 1
+        bundle = json.dumps(bundle)
+        self.assertFalseWithOptions(bundle)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/stix2validator/test/identity_tests.py b/stix2validator/test/identity_tests.py
index e335f62..3e09aea 100644
--- a/stix2validator/test/identity_tests.py
+++ b/stix2validator/test/identity_tests.py
@@ -12,8 +12,7 @@ VALID_IDENTITY = """
   "created": "2014-08-08T15:50:10.983464Z",
   "modified": "2014-08-08T15:50:10.983464Z",
   "name": "ACME Widget, Inc.",
-  "identity_class": "organization",
-  "nationalities": ["us"]
+  "identity_class": "organization"
 }
 """
 
@@ -25,13 +24,6 @@ class IdentityTestCases(ValidatorTest):
         results = validate_string(VALID_IDENTITY, self.options)
         self.assertTrue(results.is_valid)
 
-    def test_invalid_nationality(self):
-        identity = copy.deepcopy(self.valid_identity)
-        identity['nationalities'] = ["USA"]
-        identity = json.dumps(identity)
-        results = validate_string(identity, self.options)
-        self.assertEqual(results.is_valid, False)
-
     def test_vocab_identity_class(self):
         identity = copy.deepcopy(self.valid_identity)
         identity['identity_class'] = "corporation"
diff --git a/stix2validator/test/indicator_tests.py b/stix2validator/test/indicator_tests.py
index 897cfa0..74dea64 100644
--- a/stix2validator/test/indicator_tests.py
+++ b/stix2validator/test/indicator_tests.py
@@ -16,8 +16,6 @@ VALID_INDICATOR = """
     "name": "Poison Ivy Malware",
     "description": "This file is part of Poison Ivy",
     "pattern": "file-object.hashes.md5 = '3773a88f65a5e780c8dff9cdc3a056f3'",
-    "pattern_lang": "stix",
-    "pattern_lang_version": "1.0",
     "valid_from": "2016-04-06T20:03:48Z",
     "valid_from_precision": "full"
 }
@@ -52,14 +50,6 @@ class IndicatorTestCases(ValidatorTest):
         results = validate_string(indicator, self.options)
         self.assertEqual(results.is_valid, False)
 
-    # TODO: Update this test once RC3 has settled and schemas are updated
-    # def test_cybox_version(self):
-    #     indicator = copy.deepcopy(self.valid_indicator)
-    #     indicator['pattern_lang_version'] = "2.0"
-    #     indicator = json.dumps(indicator)
-    #     results = validate_string(indicator, self.options)
-    #     self.assertEqual(results.is_valid, False)
-
     def test_custom_property_name_invalid_character(self):
         indicator = copy.deepcopy(self.valid_indicator)
         indicator['my_new_property!'] = "abc123"
@@ -244,15 +234,6 @@ class IndicatorTestCases(ValidatorTest):
 
         self.check_ignore(indicator, 'indicator-label')
 
-    def test_vocab_pattern_lang(self):
-        indicator = copy.deepcopy(self.valid_indicator)
-        indicator['pattern_lang'] = "something"
-        indicator = json.dumps(indicator)
-        results = validate_string(indicator, self.options)
-        self.assertEqual(results.is_valid, False)
-
-        self.check_ignore(indicator, 'pattern-lang')
-
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/stix2validator/test/marking_definition_tests.py b/stix2validator/test/marking_definition_tests.py
index b9bffde..1960d6d 100644
--- a/stix2validator/test/marking_definition_tests.py
+++ b/stix2validator/test/marking_definition_tests.py
@@ -17,7 +17,7 @@ VALID_MARKING_DEFINITION = """
 """
 
 
-class Marking_definitionTestCases(ValidatorTest):
+class MarkingDefinitionTestCases(ValidatorTest):
     valid_marking_definition = json.loads(VALID_MARKING_DEFINITION)
 
     def test_wellformed_marking_definition(self):
@@ -40,6 +40,21 @@ class Marking_definitionTestCases(ValidatorTest):
         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"]
+        marking_definition = json.dumps(marking_definition)
+        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"]
+        }]
+        marking_definition = json.dumps(marking_definition)
+        self.assertFalseWithOptions(marking_definition)
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/stix2validator/test/observed_data_tests.py b/stix2validator/test/observed_data_tests.py
new file mode 100644
index 0000000..425739a
--- /dev/null
+++ b/stix2validator/test/observed_data_tests.py
@@ -0,0 +1,105 @@
+import unittest
+import copy
+import json
+from . import ValidatorTest
+from .. import validate_string
+
+VALID_OBSERVED_DATA_DEFINITION = """
+{
+  "type": "observed-data",
+  "id": "observed-data--b67d30ff-02ac-498a-92f9-32f845f448cf",
+  "created_by_ref": "identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
+  "created": "2016-04-06T19:58:16Z",
+  "modified": "2016-04-06T19:58:16Z",
+  "version": 1,
+  "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": [
+            "0",
+            "1",
+            "2"
+          ],
+          "version": "5.0"
+        }
+      }
+    }
+  },
+  "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
+        observed_data = json.dumps(observed_data)
+        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"
+        observed_data = json.dumps(observed_data)
+        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]"
+        ]
+        observed_data = json.dumps(observed_data)
+        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"
+        ]
+        observed_data = json.dumps(observed_data)
+        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"
+        ]
+        observed_data = json.dumps(observed_data)
+        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"
+        ]
+        observed_data = json.dumps(observed_data)
+        results = validate_string(observed_data, self.options)
+        self.assertTrue(len(results.errors) == 3)
+        self.assertFalse(results.is_valid)
+
+
+if __name__ == "__main__":
+    unittest.main()