diff --git a/stix2/base.py b/stix2/base.py index acac94c..c819340 100644 --- a/stix2/base.py +++ b/stix2/base.py @@ -9,10 +9,11 @@ from .exceptions import (AtLeastOnePropertyError, DependentPropertiesError, ExtraPropertiesError, ImmutableError, InvalidObjRefError, InvalidValueError, MissingPropertiesError, - MutuallyExclusivePropertiesError, RevokeError, - UnmodifiablePropertyError) + MutuallyExclusivePropertiesError) from .markings.utils import validate -from .utils import NOW, format_datetime, get_timestamp, parse_into_datetime +from .utils import NOW, format_datetime, get_timestamp +from .utils import new_version as _new_version +from .utils import revoke as _revoke __all__ = ['STIXJSONEncoder', '_STIXBase'] @@ -162,30 +163,10 @@ class _STIXBase(collections.Mapping): # Versioning API def new_version(self, **kwargs): - unchangable_properties = [] - if self.get("revoked"): - raise RevokeError("new_version") - new_obj_inner = copy.deepcopy(self._inner) - properties_to_change = kwargs.keys() - for prop in ["created", "created_by_ref", "id", "type"]: - if prop in properties_to_change: - unchangable_properties.append(prop) - if unchangable_properties: - raise UnmodifiablePropertyError(unchangable_properties) - cls = type(self) - if 'modified' not in kwargs: - kwargs['modified'] = get_timestamp() - else: - new_modified_property = parse_into_datetime(kwargs['modified'], precision='millisecond') - if new_modified_property < self.modified: - raise InvalidValueError(cls, 'modified', "The new modified datetime cannot be before the current modified datatime.") - new_obj_inner.update(kwargs) - return cls(**new_obj_inner) + return _new_version(self, **kwargs) def revoke(self): - if self.get("revoked"): - raise RevokeError("revoke") - return self.new_version(revoked=True) + return _revoke(self) class _Observable(_STIXBase): diff --git a/stix2/test/constants.py b/stix2/test/constants.py index d62d932..c1789af 100644 --- a/stix2/test/constants.py +++ b/stix2/test/constants.py @@ -29,6 +29,16 @@ MARKING_IDS = [ "marking-definition--2802dfb1-1019-40a8-8848-68d0ec0e417f", ] +# All required args for a Campaign instance, plus some optional args +CAMPAIGN_MORE_KWARGS = dict( + id="campaign--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f", + created_by_ref="identity--f431f809-377b-45e0-aa1c-6a4751cae5ff", + created="2016-04-06T20:03:00.000Z", + modified="2016-04-06T20:03:00.000Z", + name="Green Group Attacks Against Finance", + description="Campaign by Green Group against a series of targets in the financial services sector.", +) + # Minimum required args for an Identity instance IDENTITY_KWARGS = dict( name="John Smith", diff --git a/stix2/test/test_versioning.py b/stix2/test/test_versioning.py index 281ae71..c7bd2fc 100644 --- a/stix2/test/test_versioning.py +++ b/stix2/test/test_versioning.py @@ -2,16 +2,11 @@ import pytest import stix2 +from .constants import CAMPAIGN_MORE_KWARGS + def test_making_new_version(): - campaign_v1 = stix2.Campaign( - id="campaign--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f", - created_by_ref="identity--f431f809-377b-45e0-aa1c-6a4751cae5ff", - created="2016-04-06T20:03:00.000Z", - modified="2016-04-06T20:03:00.000Z", - name="Green Group Attacks Against Finance", - description="Campaign by Green Group against a series of targets in the financial services sector." - ) + campaign_v1 = stix2.Campaign(**CAMPAIGN_MORE_KWARGS) campaign_v2 = campaign_v1.new_version(name="fred") @@ -25,14 +20,7 @@ def test_making_new_version(): def test_making_new_version_with_unset(): - campaign_v1 = stix2.Campaign( - id="campaign--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f", - created_by_ref="identity--f431f809-377b-45e0-aa1c-6a4751cae5ff", - created="2016-04-06T20:03:00.000Z", - modified="2016-04-06T20:03:00.000Z", - name="Green Group Attacks Against Finance", - description="Campaign by Green Group against a series of targets in the financial services sector." - ) + campaign_v1 = stix2.Campaign(**CAMPAIGN_MORE_KWARGS) campaign_v2 = campaign_v1.new_version(description=None) @@ -47,16 +35,11 @@ def test_making_new_version_with_unset(): def test_making_new_version_with_embedded_object(): campaign_v1 = stix2.Campaign( - id="campaign--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f", - created_by_ref="identity--f431f809-377b-45e0-aa1c-6a4751cae5ff", - created="2016-04-06T20:03:00.000Z", - modified="2016-04-06T20:03:00.000Z", - name="Green Group Attacks Against Finance", external_references=[{ "source_name": "capec", "external_id": "CAPEC-163" }], - description="Campaign by Green Group against a series of targets in the financial services sector." + **CAMPAIGN_MORE_KWARGS ) campaign_v2 = campaign_v1.new_version(external_references=[{ @@ -74,14 +57,7 @@ def test_making_new_version_with_embedded_object(): def test_revoke(): - campaign_v1 = stix2.Campaign( - id="campaign--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f", - created_by_ref="identity--f431f809-377b-45e0-aa1c-6a4751cae5ff", - created="2016-04-06T20:03:00.000Z", - modified="2016-04-06T20:03:00.000Z", - name="Green Group Attacks Against Finance", - description="Campaign by Green Group against a series of targets in the financial services sector." - ) + campaign_v1 = stix2.Campaign(**CAMPAIGN_MORE_KWARGS) campaign_v2 = campaign_v1.revoke() @@ -96,14 +72,7 @@ def test_revoke(): def test_versioning_error_invalid_property(): - campaign_v1 = stix2.Campaign( - id="campaign--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f", - created_by_ref="identity--f431f809-377b-45e0-aa1c-6a4751cae5ff", - created="2016-04-06T20:03:00.000Z", - modified="2016-04-06T20:03:00.000Z", - name="Green Group Attacks Against Finance", - description="Campaign by Green Group against a series of targets in the financial services sector." - ) + campaign_v1 = stix2.Campaign(**CAMPAIGN_MORE_KWARGS) with pytest.raises(stix2.exceptions.UnmodifiablePropertyError) as excinfo: campaign_v1.new_version(type="threat-actor") @@ -112,14 +81,7 @@ def test_versioning_error_invalid_property(): def test_versioning_error_bad_modified_value(): - campaign_v1 = stix2.Campaign( - id="campaign--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f", - created_by_ref="identity--f431f809-377b-45e0-aa1c-6a4751cae5ff", - created="2016-04-06T20:03:00.000Z", - modified="2016-04-06T20:03:00.000Z", - name="Green Group Attacks Against Finance", - description="Campaign by Green Group against a series of targets in the financial services sector." - ) + campaign_v1 = stix2.Campaign(**CAMPAIGN_MORE_KWARGS) with pytest.raises(stix2.exceptions.InvalidValueError) as excinfo: campaign_v1.new_version(modified="2015-04-06T20:03:00.000Z") @@ -130,14 +92,7 @@ def test_versioning_error_bad_modified_value(): def test_versioning_error_usetting_required_property(): - campaign_v1 = stix2.Campaign( - id="campaign--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f", - created_by_ref="identity--f431f809-377b-45e0-aa1c-6a4751cae5ff", - created="2016-04-06T20:03:00.000Z", - modified="2016-04-06T20:03:00.000Z", - name="Green Group Attacks Against Finance", - description="Campaign by Green Group against a series of targets in the financial services sector." - ) + campaign_v1 = stix2.Campaign(**CAMPAIGN_MORE_KWARGS) with pytest.raises(stix2.exceptions.MissingPropertiesError) as excinfo: campaign_v1.new_version(name=None) @@ -147,15 +102,7 @@ def test_versioning_error_usetting_required_property(): def test_versioning_error_new_version_of_revoked(): - campaign_v1 = stix2.Campaign( - id="campaign--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f", - created_by_ref="identity--f431f809-377b-45e0-aa1c-6a4751cae5ff", - created="2016-04-06T20:03:00.000Z", - modified="2016-04-06T20:03:00.000Z", - name="Green Group Attacks Against Finance", - description="Campaign by Green Group against a series of targets in the financial services sector." - ) - + campaign_v1 = stix2.Campaign(**CAMPAIGN_MORE_KWARGS) campaign_v2 = campaign_v1.revoke() with pytest.raises(stix2.exceptions.RevokeError) as excinfo: @@ -165,18 +112,84 @@ def test_versioning_error_new_version_of_revoked(): def test_versioning_error_revoke_of_revoked(): - campaign_v1 = stix2.Campaign( - id="campaign--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f", - created_by_ref="identity--f431f809-377b-45e0-aa1c-6a4751cae5ff", - created="2016-04-06T20:03:00.000Z", - modified="2016-04-06T20:03:00.000Z", - name="Green Group Attacks Against Finance", - description="Campaign by Green Group against a series of targets in the financial services sector." - ) - + campaign_v1 = stix2.Campaign(**CAMPAIGN_MORE_KWARGS) campaign_v2 = campaign_v1.revoke() with pytest.raises(stix2.exceptions.RevokeError) as excinfo: campaign_v2.revoke() assert excinfo.value.called_by == "revoke" + + +def test_making_new_version_dict(): + campaign_v1 = CAMPAIGN_MORE_KWARGS + campaign_v2 = stix2.utils.new_version(CAMPAIGN_MORE_KWARGS, name="fred") + + assert campaign_v1['id'] == campaign_v2['id'] + assert campaign_v1['created_by_ref'] == campaign_v2['created_by_ref'] + assert campaign_v1['created'] == campaign_v2['created'] + assert campaign_v1['name'] != campaign_v2['name'] + assert campaign_v2['name'] == "fred" + assert campaign_v1['description'] == campaign_v2['description'] + assert stix2.utils.parse_into_datetime(campaign_v1['modified'], precision='millisecond') < campaign_v2['modified'] + + +def test_versioning_error_dict_bad_modified_value(): + with pytest.raises(stix2.exceptions.InvalidValueError) as excinfo: + stix2.utils.new_version(CAMPAIGN_MORE_KWARGS, modified="2015-04-06T20:03:00.000Z") + + assert excinfo.value.cls == dict + assert excinfo.value.prop_name == "modified" + assert excinfo.value.reason == "The new modified datetime cannot be before the current modified datatime." + + +def test_versioning_error_dict_no_modified_value(): + campaign_v1 = { + 'type': 'campaign', + 'id': "campaign--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f", + 'created': "2016-04-06T20:03:00.000Z", + 'name': "Green Group Attacks Against Finance", + } + campaign_v2 = stix2.utils.new_version(campaign_v1, modified="2017-04-06T20:03:00.000Z") + + assert str(campaign_v2['modified']) == "2017-04-06T20:03:00.000Z" + + +def test_making_new_version_invalid_cls(): + campaign_v1 = "This is a campaign." + with pytest.raises(ValueError) as excinfo: + stix2.utils.new_version(campaign_v1, name="fred") + + assert 'cannot create new version of object of this type' in str(excinfo.value) + + +def test_revoke_dict(): + campaign_v1 = CAMPAIGN_MORE_KWARGS + campaign_v2 = stix2.utils.revoke(campaign_v1) + + assert campaign_v1['id'] == campaign_v2['id'] + assert campaign_v1['created_by_ref'] == campaign_v2['created_by_ref'] + assert campaign_v1['created'] == campaign_v2['created'] + assert campaign_v1['name'] == campaign_v2['name'] + assert campaign_v1['description'] == campaign_v2['description'] + assert stix2.utils.parse_into_datetime(campaign_v1['modified'], precision='millisecond') < campaign_v2['modified'] + + assert campaign_v2['revoked'] + + +def test_versioning_error_revoke_of_revoked_dict(): + campaign_v1 = CAMPAIGN_MORE_KWARGS + campaign_v2 = stix2.utils.revoke(campaign_v1) + + with pytest.raises(stix2.exceptions.RevokeError) as excinfo: + stix2.utils.revoke(campaign_v2) + + assert excinfo.value.called_by == "revoke" + + +def test_revoke_invalid_cls(): + campaign_v1 = "This is a campaign." + with pytest.raises(ValueError) as excinfo: + stix2.utils.revoke(campaign_v1) + + assert 'cannot revoke object of this type' in str(excinfo.value) diff --git a/stix2/utils.py b/stix2/utils.py index 12b889c..c83741c 100644 --- a/stix2/utils.py +++ b/stix2/utils.py @@ -1,11 +1,16 @@ """Utility functions and classes for the stix2 library.""" +from collections import Mapping +import copy import datetime as dt import json from dateutil import parser import pytz +from .exceptions import (InvalidValueError, RevokeError, + UnmodifiablePropertyError) + # Sentinel value for properties that should be set to the current time. # We can't use the standard 'default' approach, since if there are multiple # timestamps in a single object, the timestamps will vary by a few microseconds. @@ -112,3 +117,50 @@ def get_dict(data): return dict(data) except (ValueError, TypeError): raise ValueError("Cannot convert '%s' to dictionary." % str(data)) + + +def new_version(data, **kwargs): + """Create a new version of a STIX object, by modifying properties and + updating the `modified` property. + """ + + if not isinstance(data, Mapping): + raise ValueError('cannot create new version of object of this type! ' + 'Try a dictionary or instance of an SDO or SRO class.') + + unchangable_properties = [] + if data.get("revoked"): + raise RevokeError("new_version") + try: + new_obj_inner = copy.deepcopy(data._inner) + except AttributeError: + new_obj_inner = copy.deepcopy(data) + properties_to_change = kwargs.keys() + + # Make sure certain properties aren't trying to change + for prop in ["created", "created_by_ref", "id", "type"]: + if prop in properties_to_change: + unchangable_properties.append(prop) + if unchangable_properties: + raise UnmodifiablePropertyError(unchangable_properties) + + cls = type(data) + if 'modified' not in kwargs: + kwargs['modified'] = get_timestamp() + elif 'modified' in data: + old_modified_property = parse_into_datetime(data.get('modified'), precision='millisecond') + new_modified_property = parse_into_datetime(kwargs['modified'], precision='millisecond') + if new_modified_property < old_modified_property: + raise InvalidValueError(cls, 'modified', "The new modified datetime cannot be before the current modified datatime.") + new_obj_inner.update(kwargs) + return cls(**new_obj_inner) + + +def revoke(data): + if not isinstance(data, Mapping): + raise ValueError('cannot revoke object of this type! Try a dictionary ' + 'or instance of an SDO or SRO class.') + + if data.get("revoked"): + raise RevokeError("revoke") + return new_version(data, revoked=True)