diff --git a/stix2/base.py b/stix2/base.py index 05bf545..5b58f06 100644 --- a/stix2/base.py +++ b/stix2/base.py @@ -99,3 +99,7 @@ class _STIXBase(collections.Mapping): props = [(k, self[k]) for k in sorted(self._properties) if self.get(k)] return "{0}({1})".format(self.__class__.__name__, ", ".join(["{0!s}={1!r}".format(k, v) for k, v in props])) + + +class Observable(_STIXBase): + pass diff --git a/stix2/exceptions.py b/stix2/exceptions.py index 2606796..61cec79 100644 --- a/stix2/exceptions.py +++ b/stix2/exceptions.py @@ -49,3 +49,16 @@ class ImmutableError(STIXError, ValueError): def __init__(self): super(ImmutableError, self).__init__("Cannot modify properties after creation.") + + +class DictionaryKeyError(STIXError, ValueError): + """Dictionary key does not conform to the correct format.""" + + def __init__(self, key, reason): + super(DictionaryKeyError, self).__init__() + self.key = key + self.reason = reason + + def __str__(self): + msg = "Invliad dictionary key {0.key}: ({0.reason})." + return msg.format(self) diff --git a/stix2/properties.py b/stix2/properties.py index 76fe31b..b8ce17b 100644 --- a/stix2/properties.py +++ b/stix2/properties.py @@ -1,3 +1,5 @@ +import base64 +import binascii import collections import datetime as dt import inspect @@ -8,7 +10,8 @@ from dateutil import parser import pytz from six import text_type -from .base import _STIXBase +from .base import _STIXBase, Observable +from .exceptions import DictionaryKeyError class Property(object): @@ -213,6 +216,86 @@ class TimestampProperty(Property): return pytz.utc.localize(parsed) +class ObservableProperty(Property): + + def clean(self, value): + dictified = dict(value) + for obj in dictified: + if not issubclass(type(obj), Observable): + raise ValueError("Objects in an observable property must be " + "Cyber Observable Objects") + return dictified + + +class DictionaryProperty(Property): + + def clean(self, value): + dictified = dict(value) + for k in dictified.keys(): + if len(k) < 3: + raise DictionaryKeyError(k, "shorter than 3 characters") + elif len(k) > 256: + raise DictionaryKeyError(k, "longer than 256 characters") + if not re.match('^[a-zA-Z0-9_-]+$', k): + raise DictionaryKeyError(k, "contains characters other than" + "lowercase a-z, uppercase A-Z, " + "numerals 0-9, hyphen (-), or " + "underscore (_)") + return dictified + + +HASHES_REGEX = { + "MD5": ("^[a-fA-F0-9]{32}$", "MD5"), + "MD6": ("^[a-fA-F0-9]{32}|[a-fA-F0-9]{40}|[a-fA-F0-9]{56}|[a-fA-F0-9]{64}|[a-fA-F0-9]{96}|[a-fA-F0-9]{128}$", "MD6"), + "RIPEMD160": ("^[a-fA-F0-9]{40}$", "RIPEMD-160"), + "SHA1": ("^[a-fA-F0-9]{40}$", "SHA-1"), + "SHA224": ("^[a-fA-F0-9]{56}$", "SHA-224"), + "SHA256": ("^[a-fA-F0-9]{64}$", "SHA-256"), + "SHA384": ("^[a-fA-F0-9]{96}$", "SHA-384"), + "SHA512": ("^[a-fA-F0-9]{128}$", "SHA-512"), + "SHA3224": ("^[a-fA-F0-9]{56}$", "SHA3-224"), + "SHA3256": ("^[a-fA-F0-9]{64}$", "SHA3-256"), + "SHA3384": ("^[a-fA-F0-9]{96}$", "SHA3-384"), + "SHA3512": ("^[a-fA-F0-9]{128}$", "SHA3-512"), + "SSDEEP": ("^[a-zA-Z0-9/+:.]{1,128}$", "ssdeep"), + "WHIRLPOOL": ("^[a-fA-F0-9]{128}$", "WHIRLPOOL"), +} + + +class HashesProperty(DictionaryProperty): + + def clean(self, value): + clean_dict = super(HashesProperty, self).clean(value) + for k, v in clean_dict.items(): + key = k.upper().replace('-', '') + if key in HASHES_REGEX: + vocab_key = HASHES_REGEX[key][1] + if not re.match(HASHES_REGEX[key][0], v): + raise ValueError("'%s' is not a valid %s hash" % (v, vocab_key)) + if k != vocab_key: + clean_dict[vocab_key] = clean_dict[k] + del clean_dict[k] + return clean_dict + + +class BinaryProperty(Property): + + def clean(self, value): + try: + base64.b64decode(value) + except (binascii.Error, TypeError): + raise ValueError("must contain a base64 encoded string") + return value + + +class HexProperty(Property): + + def clean(self, value): + if not re.match('^([a-fA-F0-9]{2})+$', value): + raise ValueError("must contain an even number of hexadecimal characters") + return value + + REF_REGEX = re.compile("^[a-z][a-z-]+[a-z]--[0-9a-fA-F]{8}-[0-9a-fA-F]{4}" "-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") diff --git a/stix2/test/test_properties.py b/stix2/test/test_properties.py index e83b2fc..246f349 100644 --- a/stix2/test/test_properties.py +++ b/stix2/test/test_properties.py @@ -1,8 +1,12 @@ import pytest -from stix2.properties import (BooleanProperty, IDProperty, IntegerProperty, - ListProperty, Property, ReferenceProperty, - StringProperty, TimestampProperty, TypeProperty) +from stix2.exceptions import DictionaryKeyError +from stix2.properties import (BinaryProperty, BooleanProperty, + DictionaryProperty, HashesProperty, HexProperty, + IDProperty, IntegerProperty, ListProperty, + Property, ReferenceProperty, StringProperty, + TimestampProperty, TypeProperty) + from .constants import FAKE_TIME @@ -171,3 +175,60 @@ def test_timestamp_property_invalid(): ts_prop.clean(1) with pytest.raises(ValueError): ts_prop.clean("someday sometime") + + +def test_binary_property(): + bin_prop = BinaryProperty() + + assert bin_prop.clean("TG9yZW0gSXBzdW0=") + with pytest.raises(ValueError): + bin_prop.clean("foobar") + + +def test_hex_property(): + hex_prop = HexProperty() + + assert hex_prop.clean("4c6f72656d20497073756d") + with pytest.raises(ValueError): + hex_prop.clean("foobar") + + +@pytest.mark.parametrize("d", [ + {'description': 'something'}, + [('abc', 1), ('bcd', 2), ('cde', 3)], +]) +def test_dictionary_property_valid(d): + dict_prop = DictionaryProperty() + assert dict_prop.clean(d) + + +@pytest.mark.parametrize("d", [ + {'a': 'something'}, + {'a'*300: 'something'}, + {'Hey!': 'something'}, +]) +def test_dictionary_property_invalid(d): + dict_prop = DictionaryProperty() + + with pytest.raises(DictionaryKeyError): + dict_prop.clean(d) + + +@pytest.mark.parametrize("value", [ + {"sha256": "6db12788c37247f2316052e142f42f4b259d6561751e5f401a1ae2a6df9c674b"}, + [('MD5', '2dfb1bcc980200c6706feee399d41b3f'), ('RIPEMD-160', 'b3a8cd8a27c90af79b3c81754f267780f443dfef')], +]) +def test_hashes_property_valid(value): + hash_prop = HashesProperty() + assert hash_prop.clean(value) + + +@pytest.mark.parametrize("value", [ + {"MD5": "a"}, + {"SHA-256": "2dfb1bcc980200c6706feee399d41b3f"}, +]) +def test_hashes_property_invalid(value): + hash_prop = HashesProperty() + + with pytest.raises(ValueError): + hash_prop.clean(value)