Repository: oasis-open/cti-python-stix2
Pull request #375 (2020-03-19 20:15:03)

Issue 365
#365 Type and property names must begin with a letter 
#368 Custom property names must match [a-z0-9_]


Patch:
diff --git a/stix2/base.py b/stix2/base.py
index 4248075..e018059 100644
--- a/stix2/base.py
+++ b/stix2/base.py
@@ -2,6 +2,7 @@
 
 import copy
 import datetime as dt
+import re
 import uuid
 
 import simplejson as json
@@ -15,7 +16,9 @@ from .exceptions import (
     MissingPropertiesError, MutuallyExclusivePropertiesError,
 )
 from .markings.utils import validate
-from .utils import NOW, find_property_index, format_datetime, get_timestamp
+from .utils import (
+    NOW, PREFIX_21_REGEX, find_property_index, format_datetime, get_timestamp,
+)
 from .utils import new_version as _new_version
 from .utils import revoke as _revoke
 
@@ -76,6 +79,11 @@ def get_required_properties(properties):
 class _STIXBase(Mapping):
     """Base class for STIX object types"""
 
+    def get_class_version(self):
+        module_name = self.__class__.__module__
+        module_parts = module_name.split(".")
+        return module_parts[1]
+
     def object_properties(self):
         props = set(self._properties.keys())
         custom_props = list(set(self._inner.keys()) - props)
@@ -161,8 +169,21 @@ class _STIXBase(Mapping):
             extra_kwargs = list(set(kwargs) - set(self._properties))
             if extra_kwargs:
                 raise ExtraPropertiesError(cls, extra_kwargs)
-        if custom_props:
+        else:
+            # because allow_custom is true, any extra kwargs are custom
+            extra_kwargs = list(set(kwargs) - set(self._properties))
+
+        if custom_props or extra_kwargs:
             self._allow_custom = True
+            if self.get_class_version() == "v21":
+                all_custom_prop_names = extra_kwargs
+                all_custom_prop_names.extend(list(custom_props.keys()))
+                for prop_name in all_custom_prop_names:
+                    if not re.match(PREFIX_21_REGEX, prop_name):
+                        raise InvalidValueError(
+                            self.__class__, prop_name,
+                            reason="Property name '%s' must begin with an alpha character." % prop_name,
+                        )
 
         # Remove any keyword arguments whose value is None or [] (i.e. empty list)
         setting_kwargs = {}
diff --git a/stix2/core.py b/stix2/core.py
index b03e3d7..055a4a6 100644
--- a/stix2/core.py
+++ b/stix2/core.py
@@ -10,7 +10,9 @@ import stix2
 from .base import _Observable, _STIXBase
 from .exceptions import ParseError
 from .markings import _MarkingsMixin
-from .utils import SCO21_EXT_REGEX, TYPE_REGEX, _get_dict
+from .utils import (
+    EXT_21_REGEX, PREFIX_21_REGEX, TYPE_21_REGEX, TYPE_REGEX, _get_dict,
+)
 
 STIX2_OBJ_MAPS = {}
 
@@ -210,6 +212,7 @@ def _register_object(new_type, version=None):
             None, use latest version.
 
     """
+
     if version:
         v = 'v' + version.replace('.', '')
     else:
@@ -229,6 +232,36 @@ def _register_marking(new_marking, version=None):
             None, use latest version.
 
     """
+
+    type = new_marking._type
+
+    if version == "2.0":
+        if not re.match(TYPE_REGEX, type):
+            raise ValueError(
+                "Invalid marking type name '%s': must only contain the "
+                "characters a-z (lowercase ASCII), 0-9, and hyphen (-)." %
+                type,
+            )
+    else:  # 2.1+
+        if not re.match(TYPE_21_REGEX, type):
+            raise ValueError(
+                "Invalid marking type name '%s': must only contain the "
+                "characters a-z (lowercase ASCII), 0-9, and hyphen (-) "
+                "and must begin with an a-z character" % type,
+            )
+
+    if len(type) < 3 or len(type) > 250:
+        raise ValueError(
+            "Invalid marking type name '%s': must be between 3 and 250 characters." % type,
+        )
+
+    properties = new_marking._properties
+
+    if version == "2.1":
+        for prop_name, prop_value in properties.items():
+            if not re.match(PREFIX_21_REGEX, prop_name):
+                raise ValueError("Property name '%s' must begin with an alpha character." % prop_name)
+
     if version:
         v = 'v' + version.replace('.', '')
     else:
@@ -248,6 +281,7 @@ def _register_observable(new_observable, version=None):
             None, use latest version.
 
     """
+
     if version:
         v = 'v' + version.replace('.', '')
     else:
@@ -274,6 +308,7 @@ def _register_observable_extension(
     obs_class = observable if isinstance(observable, type) else \
         type(observable)
     ext_type = new_extension._type
+    properties = new_extension._properties
 
     if not issubclass(obs_class, _Observable):
         raise ValueError("'observable' must be a valid Observable class!")
@@ -286,11 +321,12 @@ def _register_observable_extension(
                 ext_type,
             )
     else:  # 2.1+
-        if not re.match(SCO21_EXT_REGEX, ext_type):
+        if not re.match(EXT_21_REGEX, ext_type):
             raise ValueError(
                 "Invalid extension type name '%s': must only contain the "
-                "characters a-z (lowercase ASCII), 0-9, hyphen (-), and end "
-                "with '-ext'." % ext_type,
+                "characters a-z (lowercase ASCII), 0-9, hyphen (-), "
+                "must begin with an a-z character"
+                "and end with '-ext'." % ext_type,
             )
 
     if len(ext_type) < 3 or len(ext_type) > 250:
@@ -305,6 +341,11 @@ def _register_observable_extension(
             ext_type,
         )
 
+    if version == "2.1":
+        for prop_name, prop_value in properties.items():
+            if not re.match(PREFIX_21_REGEX, prop_name):
+                raise ValueError("Property name '%s' must begin with an alpha character." % prop_name)
+
     v = 'v' + version.replace('.', '')
 
     try:
diff --git a/stix2/custom.py b/stix2/custom.py
index f3c89cf..bd2fe1d 100644
--- a/stix2/custom.py
+++ b/stix2/custom.py
@@ -8,18 +8,30 @@ from .core import (
     STIXDomainObject, _register_marking, _register_object,
     _register_observable, _register_observable_extension,
 )
-from .utils import TYPE_REGEX, get_class_hierarchy_names
+from .utils import (
+    PREFIX_21_REGEX, TYPE_21_REGEX, TYPE_REGEX, get_class_hierarchy_names,
+)
 
 
 def _custom_object_builder(cls, type, properties, version):
     class _CustomObject(cls, STIXDomainObject):
 
-        if not re.match(TYPE_REGEX, type):
-            raise ValueError(
-                "Invalid type name '%s': must only contain the "
-                "characters a-z (lowercase ASCII), 0-9, and hyphen (-)." % type,
-            )
-        elif len(type) < 3 or len(type) > 250:
+        if version == "2.0":
+            if not re.match(TYPE_REGEX, type):
+                raise ValueError(
+                    "Invalid type name '%s': must only contain the "
+                    "characters a-z (lowercase ASCII), 0-9, and hyphen (-)." %
+                    type,
+                )
+        else:  # 2.1+
+            if not re.match(TYPE_21_REGEX, type):
+                raise ValueError(
+                    "Invalid type name '%s': must only contain the "
+                    "characters a-z (lowercase ASCII), 0-9, and hyphen (-) "
+                    "and must begin with an a-z character" % type,
+                )
+
+        if len(type) < 3 or len(type) > 250:
             raise ValueError(
                 "Invalid type name '%s': must be between 3 and 250 characters." % type,
             )
@@ -27,6 +39,11 @@ def _custom_object_builder(cls, type, properties, version):
         if not properties or not isinstance(properties, list):
             raise ValueError("Must supply a list, containing tuples. For example, [('property1', IntegerProperty())]")
 
+        if version == "2.1":
+            for prop_name, prop in properties:
+                if not re.match(PREFIX_21_REGEX, prop_name):
+                    raise ValueError("Property name '%s' must begin with an alpha character" % prop_name)
+
         _type = type
         _properties = OrderedDict(properties)
 
@@ -39,6 +56,7 @@ def _custom_object_builder(cls, type, properties, version):
 
 
 def _custom_marking_builder(cls, type, properties, version):
+
     class _CustomMarking(cls, _STIXBase):
 
         if not properties or not isinstance(properties, list):
@@ -61,12 +79,22 @@ def _custom_observable_builder(cls, type, properties, version, id_contrib_props=
 
     class _CustomObservable(cls, _Observable):
 
-        if not re.match(TYPE_REGEX, type):
-            raise ValueError(
-                "Invalid observable type name '%s': must only contain the "
-                "characters a-z (lowercase ASCII), 0-9, and hyphen (-)." % type,
-            )
-        elif len(type) < 3 or len(type) > 250:
+        if version == "2.0":
+            if not re.match(TYPE_REGEX, type):
+                raise ValueError(
+                    "Invalid observable type name '%s': must only contain the "
+                    "characters a-z (lowercase ASCII), 0-9, and hyphen (-)." %
+                    type,
+                )
+        else:  # 2.1+
+            if not re.match(TYPE_21_REGEX, type):
+                raise ValueError(
+                    "Invalid observable type name '%s': must only contain the "
+                    "characters a-z (lowercase ASCII), 0-9, and hyphen (-) "
+                    "and must begin with an a-z character" % type,
+                )
+
+        if len(type) < 3 or len(type) > 250:
             raise ValueError("Invalid observable type name '%s': must be between 3 and 250 characters." % type)
 
         if not properties or not isinstance(properties, list):
@@ -89,7 +117,9 @@ def _custom_observable_builder(cls, type, properties, version, id_contrib_props=
         else:
             # If using STIX2.1 (or newer...), check properties ending in "_ref/s" are ReferenceProperties
             for prop_name, prop in properties:
-                if prop_name.endswith('_ref') and ('ReferenceProperty' not in get_class_hierarchy_names(prop)):
+                if not re.match(PREFIX_21_REGEX, prop_name):
+                    raise ValueError("Property name '%s' must begin with an alpha character." % prop_name)
+                elif prop_name.endswith('_ref') and ('ReferenceProperty' not in get_class_hierarchy_names(prop)):
                     raise ValueError(
                         "'%s' is named like a reference property but "
                         "is not a ReferenceProperty." % prop_name,
diff --git a/stix2/utils.py b/stix2/utils.py
index 7b3b6cf..da04a3e 100644
--- a/stix2/utils.py
+++ b/stix2/utils.py
@@ -7,6 +7,7 @@ except ImportError:
 import copy
 import datetime as dt
 import json
+import re
 
 from dateutil import parser
 import pytz
@@ -25,8 +26,10 @@ NOW = object()
 # STIX object properties that cannot be modified
 STIX_UNMOD_PROPERTIES = ['created', 'created_by_ref', 'id', 'type']
 
-TYPE_REGEX = r'^\-?[a-z0-9]+(-[a-z0-9]+)*\-?$'
-SCO21_EXT_REGEX = r'^\-?[a-z0-9]+(-[a-z0-9]+)*\-ext$'
+TYPE_REGEX = re.compile(r'^\-?[a-z0-9]+(-[a-z0-9]+)*\-?$')
+TYPE_21_REGEX = re.compile(r'^([a-z][a-z0-9]*)+(-[a-z0-9]+)*\-?$')
+EXT_21_REGEX = re.compile(r'^([a-z][a-z0-9]*)+(-[a-z0-9]+)*\-ext$')
+PREFIX_21_REGEX = re.compile(r'^[a-z].*')
 
 
 class STIXdatetime(dt.datetime):


Test patch:
diff --git a/stix2/test/v21/test_custom.py b/stix2/test/v21/test_custom.py
index 1e6f629..ef57a9b 100644
--- a/stix2/test/v21/test_custom.py
+++ b/stix2/test/v21/test_custom.py
@@ -9,6 +9,8 @@ import stix2.v21
 from ...exceptions import InvalidValueError
 from .constants import FAKE_TIME, IDENTITY_ID, MARKING_DEFINITION_ID
 
+# Custom Properties in SDOs
+
 IDENTITY_CUSTOM_PROP = stix2.v21.Identity(
     name="John Smith",
     identity_class="individual",
@@ -18,6 +20,18 @@ IDENTITY_CUSTOM_PROP = stix2.v21.Identity(
 
 
 def test_identity_custom_property():
+    identity = stix2.v21.Identity(
+        id=IDENTITY_ID,
+        created="2015-12-21T19:59:11Z",
+        modified="2015-12-21T19:59:11Z",
+        name="John Smith",
+        identity_class="individual",
+        custom_properties={
+            "foo": "bar",
+        },
+    )
+    assert identity.foo == "bar"
+
     with pytest.raises(ValueError) as excinfo:
         stix2.v21.Identity(
             id=IDENTITY_ID,
@@ -43,17 +57,47 @@ def test_identity_custom_property():
         )
     assert "Unexpected properties for Identity" in str(excinfo.value)
 
-    identity = stix2.v21.Identity(
-        id=IDENTITY_ID,
-        created="2015-12-21T19:59:11Z",
-        modified="2015-12-21T19:59:11Z",
-        name="John Smith",
-        identity_class="individual",
-        custom_properties={
-            "foo": "bar",
-        },
-    )
-    assert identity.foo == "bar"
+    # leading numeric character is illegal in 2.1
+
+    with pytest.raises(stix2.exceptions.InvalidValueError) as excinfo:
+        stix2.v21.Identity(
+            id=IDENTITY_ID,
+            created="2015-12-21T19:59:11Z",
+            modified="2015-12-21T19:59:11Z",
+            name="John Smith",
+            identity_class="individual",
+            custom_properties={
+                "7foo": "bar",
+            },
+        )
+    assert "must begin with an alpha character." in str(excinfo.value)
+
+    # leading "_" is illegal in 2.1
+
+    with pytest.raises(stix2.exceptions.InvalidValueError) as excinfo:
+        stix2.v21.Identity(
+            id=IDENTITY_ID,
+            created="2015-12-21T19:59:11Z",
+            modified="2015-12-21T19:59:11Z",
+            name="John Smith",
+            identity_class="individual",
+            custom_properties={
+                "_foo": "bar",
+            },
+        )
+    assert "must begin with an alpha character." in str(excinfo.value)
+
+    with pytest.raises(stix2.exceptions.InvalidValueError) as excinfo:
+        identity = stix2.v21.Identity(
+            id=IDENTITY_ID,
+            created="2015-12-21T19:59:11Z",
+            modified="2015-12-21T19:59:11Z",
+            name="John Smith",
+            identity_class="individual",
+            _x_foo="bar",
+            allow_custom=True,
+        )
+    assert "must begin with an alpha character." in str(excinfo.value)
 
 
 def test_identity_custom_property_invalid():
@@ -165,6 +209,8 @@ def test_custom_properties_dict_in_bundled_object():
     assert bundle.objects[0].x_foo == "bar"
     assert '"x_foo": "bar"' in str(bundle)
 
+# Custom properties in SCOs
+
 
 def test_custom_property_in_observed_data():
     artifact = stix2.v21.File(
@@ -184,6 +230,18 @@ def test_custom_property_in_observed_data():
     assert '"x_foo": "bar"' in str(observed_data)
 
 
+def test_invalid_custom_property_in_observed_data():
+    with pytest.raises(stix2.exceptions.InvalidValueError) as excinfo:
+        stix2.v21.File(
+            custom_properties={"8foo": 1},
+            allow_custom=True,
+            name='test',
+            x_foo='bar',
+        )
+
+    assert "must begin with an alpha character." in str(excinfo.value)
+
+
 def test_custom_property_object_in_observable_extension():
     ntfs = stix2.v21.NTFSExt(
         allow_custom=True,
@@ -245,6 +303,8 @@ def test_identity_custom_property_revoke():
     identity = IDENTITY_CUSTOM_PROP.revoke()
     assert identity.x_foo == "bar"
 
+# Custom markings
+
 
 def test_identity_custom_property_edit_markings():
     marking_obj = stix2.v21.MarkingDefinition(
@@ -267,6 +327,19 @@ def test_identity_custom_property_edit_markings():
     identity2.clear_markings('x_foo')
 
 
+def test_invalid_custom_property_in_marking():
+    with pytest.raises(ValueError) as excinfo:
+        @stix2.v21.CustomMarking(
+            'x-new-obj', [
+                ('9property1', stix2.properties.StringProperty(required=True)),
+            ],
+        )
+        class NewObj():
+            pass
+
+    assert "must begin with an alpha character." in str(excinfo.value)
+
+
 def test_custom_marking_no_init_1():
     @stix2.v21.CustomMarking(
         'x-new-obj', [
@@ -293,6 +366,40 @@ def test_custom_marking_no_init_2():
     assert no2.property1 == 'something'
 
 
+def test_custom_marking_invalid_type_name():
+    with pytest.raises(ValueError) as excinfo:
+        @stix2.v21.CustomMarking(
+            'x', [
+                ('property1', stix2.properties.StringProperty(required=True)),
+            ],
+        )
+        class NewObj(object):
+            pass  # pragma: no cover
+    assert "Invalid marking type name 'x': " in str(excinfo.value)
+
+    with pytest.raises(ValueError) as excinfo:
+        @stix2.v21.CustomMarking(
+            'x_new_marking', [
+                ('property1', stix2.properties.StringProperty(required=True)),
+            ],
+        )
+        class NewObj2(object):
+            pass  # pragma: no cover
+    assert "Invalid marking type name 'x_new_marking':" in str(excinfo.value)
+
+    with pytest.raises(ValueError) as excinfo:
+        @stix2.v21.CustomMarking(
+            '7x-new-marking', [
+                ('property1', stix2.properties.StringProperty(required=True)),
+            ],
+        )
+        class NewObj3(object):
+            pass  # pragma: no cover
+    assert "Invalid marking type name '7x-new-marking':" in str(excinfo.value)
+
+# Custom Objects
+
+
 @stix2.v21.CustomObject(
     'x-new-type', [
         ('property1', stix2.properties.StringProperty(required=True)),
@@ -374,6 +481,16 @@ def test_custom_object_invalid_type_name():
             pass  # pragma: no cover
     assert "Invalid type name 'x_new_object':" in str(excinfo.value)
 
+    with pytest.raises(ValueError) as excinfo:
+        @stix2.v21.CustomObject(
+            '7x-new-object', [
+                ('property1', stix2.properties.StringProperty(required=True)),
+            ],
+        )
+        class NewObj3(object):
+            pass  # pragma: no cover
+    assert "Invalid type name '7x-new-object':" in str(excinfo.value)
+
 
 def test_parse_custom_object_type():
     nt_string = """{
@@ -412,6 +529,8 @@ def test_parse_unregistered_custom_object_type_w_allow_custom():
     custom_obj = stix2.parse(nt_string, version="2.1", allow_custom=True)
     assert custom_obj["type"] == "x-foobar-observable"
 
+# Custom SCOs
+
 
 @stix2.v21.CustomObservable(
     'x-new-observable', [
@@ -479,6 +598,18 @@ def test_custom_observable_object_no_init_2():
     assert no2.property1 == 'something'
 
 
+def test_invalid_custom_property_in_custom_observable_object():
+    with pytest.raises(ValueError) as excinfo:
+        @stix2.v21.CustomObservable(
+            'x-new-sco', [
+                ('5property1', stix2.properties.StringProperty()),
+            ],
+        )
+        class NewObs(object):
+            pass  # pragma: no cover
+    assert "must begin with an alpha character." in str(excinfo.value)
+
+
 def test_custom_observable_object_invalid_type_name():
     with pytest.raises(ValueError) as excinfo:
         @stix2.v21.CustomObservable(
@@ -500,6 +631,16 @@ def test_custom_observable_object_invalid_type_name():
             pass  # pragma: no cover
     assert "Invalid observable type name 'x_new_obs':" in str(excinfo.value)
 
+    with pytest.raises(ValueError) as excinfo:
+        @stix2.v21.CustomObservable(
+            '7x-new-obs', [
+                ('property1', stix2.properties.StringProperty()),
+            ],
+        )
+        class NewObs3(object):
+            pass  # pragma: no cover
+    assert "Invalid observable type name '7x-new-obs':" in str(excinfo.value)
+
 
 def test_custom_observable_object_invalid_ref_property():
     with pytest.raises(ValueError) as excinfo:
@@ -736,6 +877,8 @@ def test_custom_observable_object_no_id_contrib_props():
     assert uuid_obj.variant == uuid.RFC_4122
     assert uuid_obj.version == 4
 
+# Custom Extensions
+
 
 @stix2.v21.CustomExtension(
     stix2.v21.DomainName, 'x-new-ext', [
@@ -874,6 +1017,16 @@ def test_custom_extension_invalid_type_name():
             pass  # pragma: no cover
     assert "Invalid extension type name 'x_new_ext':" in str(excinfo.value)
 
+    with pytest.raises(ValueError) as excinfo:
+        @stix2.v21.CustomExtension(
+            stix2.v21.File, '7x-new-ext', {
+                'property1': stix2.properties.StringProperty(required=True),
+            },
+        )
+        class Bla2Extension():
+            pass  # pragma: no cover
+    assert "Invalid extension type name '7x-new-ext':" in str(excinfo.value)
+
 
 def test_custom_extension_no_properties():
     with pytest.raises(ValueError):
@@ -922,6 +1075,19 @@ def test_custom_extension_no_init_2():
     assert ne2.property1 == "foobar"
 
 
+def test_invalid_custom_property_in_extension():
+    with pytest.raises(ValueError) as excinfo:
+        @stix2.v21.CustomExtension(
+            stix2.v21.DomainName, 'x-new3-ext', [
+                ('6property1', stix2.properties.StringProperty(required=True)),
+            ],
+        )
+        class NewExt():
+            pass
+
+    assert "must begin with an alpha character." in str(excinfo.value)
+
+
 def test_parse_observable_with_custom_extension():
     input_str = """{
         "type": "domain-name",