cti-python-stix2/stix2/base.py

337 lines
13 KiB
Python
Raw Normal View History

"""Base classes for type definitions in the stix2 library."""
2017-02-10 22:35:02 +01:00
import collections
2017-05-02 20:06:42 +02:00
import copy
2017-02-10 22:35:02 +01:00
import datetime as dt
2017-08-15 19:40:47 +02:00
import simplejson as json
2017-02-10 22:35:02 +01:00
from .exceptions import (
AtLeastOnePropertyError, CustomContentError, DependentPropertiesError,
ExtraPropertiesError, ImmutableError, InvalidObjRefError,
InvalidValueError, MissingPropertiesError,
MutuallyExclusivePropertiesError,
)
from .markings.utils import validate
2017-08-31 22:36:59 +02:00
from .utils import NOW, find_property_index, format_datetime, get_timestamp
from .utils import new_version as _new_version
from .utils import revoke as _revoke
2017-02-10 22:35:02 +01:00
__all__ = ['STIXJSONEncoder', '_STIXBase']
DEFAULT_ERROR = "{type} must have {property}='{expected}'."
2017-02-10 22:35:02 +01:00
class STIXJSONEncoder(json.JSONEncoder):
"""Custom JSONEncoder subclass for serializing Python ``stix2`` objects.
If an optional property with a default value specified in the STIX 2 spec
is set to that default value, it will be left out of the serialized output.
An example of this type of property include the ``revoked`` common property.
"""
def default(self, obj):
if isinstance(obj, (dt.date, dt.datetime)):
return format_datetime(obj)
elif isinstance(obj, _STIXBase):
tmp_obj = dict(copy.deepcopy(obj))
for prop_name in obj._defaulted_optional_properties:
del tmp_obj[prop_name]
return tmp_obj
else:
return super(STIXJSONEncoder, self).default(obj)
class STIXJSONIncludeOptionalDefaultsEncoder(json.JSONEncoder):
"""Custom JSONEncoder subclass for serializing Python ``stix2`` objects.
Differs from ``STIXJSONEncoder`` in that if an optional property with a default
value specified in the STIX 2 spec is set to that default value, it will be
included in the serialized output.
"""
2017-02-10 22:35:02 +01:00
def default(self, obj):
if isinstance(obj, (dt.date, dt.datetime)):
return format_datetime(obj)
elif isinstance(obj, _STIXBase):
return dict(obj)
else:
2018-06-26 18:23:53 +02:00
return super(STIXJSONIncludeOptionalDefaultsEncoder, self).default(obj)
2017-02-10 22:35:02 +01:00
2017-02-24 16:28:53 +01:00
def get_required_properties(properties):
2017-03-22 14:26:13 +01:00
return (k for k, v in properties.items() if v.required)
2017-02-24 16:28:53 +01:00
2017-02-10 22:35:02 +01:00
class _STIXBase(collections.Mapping):
"""Base class for STIX object types"""
2017-08-29 21:08:26 +02:00
def object_properties(self):
props = set(self._properties.keys())
custom_props = list(set(self._inner.keys()) - props)
custom_props.sort()
all_properties = list(self._properties.keys())
all_properties.extend(custom_props) # Any custom properties to the bottom
return all_properties
def _check_property(self, prop_name, prop, kwargs):
2017-02-24 17:20:24 +01:00
if prop_name not in kwargs:
if hasattr(prop, 'default'):
value = prop.default()
if value == NOW:
value = self.__now
kwargs[prop_name] = value
if prop_name in kwargs:
try:
2017-04-17 21:13:11 +02:00
kwargs[prop_name] = prop.clean(kwargs[prop_name])
except ValueError as exc:
if self.__allow_custom and isinstance(exc, CustomContentError):
return
2017-04-18 21:42:59 +02:00
raise InvalidValueError(self.__class__, prop_name, reason=str(exc))
2017-02-24 17:20:24 +01:00
# interproperty constraint methods
def _check_mutually_exclusive_properties(self, list_of_properties, at_least_one=True):
current_properties = self.properties_populated()
count = len(set(list_of_properties).intersection(current_properties))
# at_least_one allows for xor to be checked
if count > 1 or (at_least_one and count == 0):
raise MutuallyExclusivePropertiesError(self.__class__, list_of_properties)
def _check_at_least_one_property(self, list_of_properties=None):
if not list_of_properties:
list_of_properties = sorted(list(self.__class__._properties.keys()))
if 'type' in list_of_properties:
list_of_properties.remove('type')
current_properties = self.properties_populated()
list_of_properties_populated = set(list_of_properties).intersection(current_properties)
if list_of_properties and (not list_of_properties_populated or list_of_properties_populated == set(['extensions'])):
raise AtLeastOnePropertyError(self.__class__, list_of_properties)
def _check_properties_dependency(self, list_of_properties, list_of_dependent_properties):
failed_dependency_pairs = []
for p in list_of_properties:
for dp in list_of_dependent_properties:
2017-06-08 14:42:32 +02:00
if not self.get(p) and self.get(dp):
failed_dependency_pairs.append((p, dp))
if failed_dependency_pairs:
raise DependentPropertiesError(self.__class__, failed_dependency_pairs)
def _check_object_constraints(self):
for m in self.get('granular_markings', []):
validate(self, m.get('selectors'))
def __init__(self, allow_custom=False, **kwargs):
2017-02-10 22:35:02 +01:00
cls = self.__class__
self.__allow_custom = allow_custom
2017-02-10 22:35:02 +01:00
# Use the same timestamp for any auto-generated datetimes
2017-02-24 16:28:53 +01:00
self.__now = get_timestamp()
2017-02-10 22:35:02 +01:00
# Detect any keyword arguments not allowed for a specific type
2017-06-09 18:20:40 +02:00
custom_props = kwargs.pop('custom_properties', {})
if custom_props and not isinstance(custom_props, dict):
raise ValueError("'custom_properties' must be a dictionary")
if not self.__allow_custom:
extra_kwargs = list(set(kwargs) - set(self._properties))
if extra_kwargs:
raise ExtraPropertiesError(cls, extra_kwargs)
if custom_props:
self.__allow_custom = True
2017-02-10 22:35:02 +01:00
# Remove any keyword arguments whose value is None
setting_kwargs = {}
2017-06-09 18:20:40 +02:00
props = kwargs.copy()
props.update(custom_props)
for prop_name, prop_value in props.items():
if prop_value is not None:
setting_kwargs[prop_name] = prop_value
# Detect any missing required properties
required_properties = set(get_required_properties(self._properties))
missing_kwargs = required_properties - set(setting_kwargs)
2017-02-10 22:35:02 +01:00
if missing_kwargs:
raise MissingPropertiesError(cls, missing_kwargs)
2017-02-10 22:35:02 +01:00
for prop_name, prop_metadata in self._properties.items():
self._check_property(prop_name, prop_metadata, setting_kwargs)
2017-02-10 22:35:02 +01:00
# Cache defaulted optional properties for serialization
defaulted = []
for name, prop in self._properties.items():
try:
if (not prop.required and not hasattr(prop, '_fixed_value') and
prop.default() == setting_kwargs[name]):
defaulted.append(name)
except (AttributeError, KeyError):
continue
self._defaulted_optional_properties = defaulted
self._inner = setting_kwargs
2017-02-10 22:35:02 +01:00
self._check_object_constraints()
2017-02-10 22:35:02 +01:00
def __getitem__(self, key):
return self._inner[key]
def __iter__(self):
return iter(self._inner)
def __len__(self):
return len(self._inner)
# Handle attribute access just like key access
def __getattr__(self, name):
2018-06-06 21:30:45 +02:00
# Pickle-proofing: pickle invokes this on uninitialized instances (i.e.
# __init__ has not run). So no "self" attributes are set yet. The
# usual behavior of this method reads an __init__-assigned attribute,
# which would cause infinite recursion. So this check disables all
# attribute reads until the instance has been properly initialized.
unpickling = '_inner' not in self.__dict__
2018-06-06 21:30:45 +02:00
if not unpickling and name in self:
return self.__getitem__(name)
2017-06-08 14:42:32 +02:00
raise AttributeError("'%s' object has no attribute '%s'" %
(self.__class__.__name__, name))
2017-02-10 22:35:02 +01:00
def __setattr__(self, name, value):
if not name.startswith("_"):
raise ImmutableError(self.__class__, name)
2017-02-10 22:35:02 +01:00
super(_STIXBase, self).__setattr__(name, value)
def __str__(self):
return self.serialize(pretty=True)
2017-02-10 22:58:17 +01:00
def __repr__(self):
2017-08-29 21:08:26 +02:00
props = [(k, self[k]) for k in self.object_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]),
)
def __deepcopy__(self, memo):
# Assume: we can ignore the memo argument, because no object will ever contain the same sub-object multiple times.
new_inner = copy.deepcopy(self._inner, memo)
cls = type(self)
if isinstance(self, _Observable):
# Assume: valid references in the original object are still valid in the new version
new_inner['_valid_refs'] = {'*': '*'}
new_inner['allow_custom'] = self.__allow_custom
return cls(**new_inner)
def properties_populated(self):
return list(self._inner.keys())
2017-05-02 20:06:42 +02:00
# Versioning API
def new_version(self, **kwargs):
return _new_version(self, **kwargs)
2017-05-02 20:06:42 +02:00
def revoke(self):
return _revoke(self)
def serialize(self, pretty=False, include_optional_defaults=False, **kwargs):
"""
Serialize a STIX object.
Args:
pretty (bool): If True, output properties following the STIX specs
formatting. This includes indentation. Refer to notes for more
2018-03-13 21:01:42 +01:00
details. (Default: ``False``)
include_optional_defaults (bool): Determines whether to include
optional properties set to the default value defined in the spec.
**kwargs: The arguments for a json.dumps() call.
Examples:
>>> import stix2
>>> identity = stix2.Identity(name='Example Corp.', identity_class='organization')
>>> print(identity.serialize(sort_keys=True))
{"created": "2018-06-08T19:03:54.066Z", ... "name": "Example Corp.", "type": "identity"}
>>> print(identity.serialize(sort_keys=True, indent=4))
{
"created": "2018-06-08T19:03:54.066Z",
"id": "identity--d7f3e25a-ba1c-447a-ab71-6434b092b05e",
"identity_class": "organization",
"modified": "2018-06-08T19:03:54.066Z",
"name": "Example Corp.",
"type": "identity"
}
Returns:
2018-03-16 19:26:41 +01:00
str: The serialized JSON object.
2018-07-06 20:11:59 +02:00
Notes:
The argument ``pretty=True`` will output the STIX object following
spec order. Using this argument greatly impacts object serialization
performance. If your use case is centered across machine-to-machine
operation it is recommended to set ``pretty=False``.
When ``pretty=True`` the following key-value pairs will be added or
overridden: indent=4, separators=(",", ": "), item_sort_key=sort_by.
"""
if pretty:
def sort_by(element):
return find_property_index(self, *element)
kwargs.update({'indent': 4, 'separators': (',', ': '), 'item_sort_key': sort_by})
if include_optional_defaults:
return json.dumps(self, cls=STIXJSONIncludeOptionalDefaultsEncoder, **kwargs)
else:
return json.dumps(self, cls=STIXJSONEncoder, **kwargs)
class _Observable(_STIXBase):
def __init__(self, **kwargs):
# the constructor might be called independently of an observed data object
self._STIXBase__valid_refs = kwargs.pop('_valid_refs', [])
self.__allow_custom = kwargs.get('allow_custom', False)
self._properties['extensions'].allow_custom = kwargs.get('allow_custom', False)
super(_Observable, self).__init__(**kwargs)
def _check_ref(self, ref, prop, prop_name):
if '*' in self._STIXBase__valid_refs:
return # don't check if refs are valid
if ref not in self._STIXBase__valid_refs:
raise InvalidObjRefError(self.__class__, prop_name, "'%s' is not a valid object in local scope" % ref)
try:
allowed_types = prop.contained.valid_types
except AttributeError:
allowed_types = prop.valid_types
try:
ref_type = self._STIXBase__valid_refs[ref]
except TypeError:
raise ValueError("'%s' must be created with _valid_refs as a dict, not a list." % self.__class__.__name__)
if allowed_types:
if ref_type not in allowed_types:
raise InvalidObjRefError(self.__class__, prop_name, "object reference '%s' is of an invalid type '%s'" % (ref, ref_type))
def _check_property(self, prop_name, prop, kwargs):
super(_Observable, self)._check_property(prop_name, prop, kwargs)
if prop_name not in kwargs:
return
if prop_name.endswith('_ref'):
ref = kwargs[prop_name]
self._check_ref(ref, prop, prop_name)
elif prop_name.endswith('_refs'):
for ref in kwargs[prop_name]:
self._check_ref(ref, prop, prop_name)
class _Extension(_STIXBase):
def _check_object_constraints(self):
super(_Extension, self)._check_object_constraints()
self._check_at_least_one_property()