diff --git a/stix2/base.py b/stix2/base.py index 09b9c39..1c90dab 100644 --- a/stix2/base.py +++ b/stix2/base.py @@ -3,12 +3,13 @@ import collections import copy import datetime as dt + import json from .exceptions import ExtraFieldsError, ImmutableError, InvalidValueError, \ MissingFieldsError, RevokeError, UnmodifiablePropertyError -from .utils import format_datetime, get_timestamp, NOW +from .utils import format_datetime, get_timestamp, NOW, parse_into_datetime __all__ = ['STIXJSONEncoder', '_STIXBase'] @@ -58,16 +59,22 @@ class _STIXBase(collections.Mapping): if extra_kwargs: raise ExtraFieldsError(cls, extra_kwargs) + # Remove any keyword arguments whose value is None + setting_kwargs = {} + for prop_name, prop_value in kwargs.items(): + if prop_value: + setting_kwargs[prop_name] = prop_value + # Detect any missing required fields required_fields = get_required_properties(cls._properties) - missing_kwargs = set(required_fields) - set(kwargs) + missing_kwargs = set(required_fields) - set(setting_kwargs) if missing_kwargs: raise MissingFieldsError(cls, missing_kwargs) for prop_name, prop_metadata in cls._properties.items(): - self._check_property(prop_name, prop_metadata, kwargs) + self._check_property(prop_name, prop_metadata, setting_kwargs) - self._inner = kwargs + self._inner = setting_kwargs if self.granular_markings: for m in self.granular_markings: @@ -121,10 +128,14 @@ class _STIXBase(collections.Mapping): 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']) + 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) - cls = type(self) return cls(**new_obj_inner) def revoke(self): diff --git a/stix2/exceptions.py b/stix2/exceptions.py index c04ada4..20e44a7 100644 --- a/stix2/exceptions.py +++ b/stix2/exceptions.py @@ -25,7 +25,7 @@ class MissingFieldsError(STIXError, ValueError): self.fields = sorted(list(fields)) def __str__(self): - msg = "Missing required field(s) for {0}: ({1})." + msg = "No values for required field(s) for {0}: ({1})." return msg.format(self.cls.__name__, ", ".join(x for x in self.fields)) diff --git a/stix2/test/constants.py b/stix2/test/constants.py index 1c8ae2b..5ef599e 100644 --- a/stix2/test/constants.py +++ b/stix2/test/constants.py @@ -39,6 +39,9 @@ RELATIONSHIP_KWARGS = dict( target_ref=MALWARE_ID, ) +# Minimum required args for a Sighting instance SIGHTING_KWARGS = dict( sighting_of_ref=INDICATOR_ID, ) + + diff --git a/stix2/test/test_external_reference.py b/stix2/test/test_external_reference.py index f8d9b66..1e25fd2 100644 --- a/stix2/test/test_external_reference.py +++ b/stix2/test/test_external_reference.py @@ -113,4 +113,3 @@ def test_external_reference_source_required(): assert excinfo.value.cls == stix2.ExternalReference assert excinfo.value.fields == ["source_name"] - assert str(excinfo.value) == "Missing required field(s) for ExternalReference: (source_name)." diff --git a/stix2/test/test_indicator.py b/stix2/test/test_indicator.py index 1c2b610..baf4b38 100644 --- a/stix2/test/test_indicator.py +++ b/stix2/test/test_indicator.py @@ -93,7 +93,7 @@ def test_indicator_required_fields(): assert excinfo.value.cls == stix2.Indicator assert excinfo.value.fields == ["labels", "pattern"] - assert str(excinfo.value) == "Missing required field(s) for Indicator: (labels, pattern)." + assert str(excinfo.value) == "No values for required field(s) for Indicator: (labels, pattern)." def test_indicator_required_field_pattern(): @@ -102,7 +102,6 @@ def test_indicator_required_field_pattern(): assert excinfo.value.cls == stix2.Indicator assert excinfo.value.fields == ["pattern"] - assert str(excinfo.value) == "Missing required field(s) for Indicator: (pattern)." def test_indicator_created_ref_invalid_format(): diff --git a/stix2/test/test_kill_chain_phases.py b/stix2/test/test_kill_chain_phases.py index d2ecc1f..037e930 100644 --- a/stix2/test/test_kill_chain_phases.py +++ b/stix2/test/test_kill_chain_phases.py @@ -41,7 +41,6 @@ def test_kill_chain_required_fields(): assert excinfo.value.cls == stix2.KillChainPhase assert excinfo.value.fields == ["kill_chain_name", "phase_name"] - assert str(excinfo.value) == "Missing required field(s) for KillChainPhase: (kill_chain_name, phase_name)." def test_kill_chain_required_field_chain_name(): @@ -51,7 +50,6 @@ def test_kill_chain_required_field_chain_name(): assert excinfo.value.cls == stix2.KillChainPhase assert excinfo.value.fields == ["kill_chain_name"] - assert str(excinfo.value) == "Missing required field(s) for KillChainPhase: (kill_chain_name)." def test_kill_chain_required_field_phase_name(): @@ -61,4 +59,3 @@ def test_kill_chain_required_field_phase_name(): assert excinfo.value.cls == stix2.KillChainPhase assert excinfo.value.fields == ["phase_name"] - assert str(excinfo.value) == "Missing required field(s) for KillChainPhase: (phase_name)." diff --git a/stix2/test/test_malware.py b/stix2/test/test_malware.py index edc35b6..f3bfccd 100644 --- a/stix2/test/test_malware.py +++ b/stix2/test/test_malware.py @@ -76,7 +76,6 @@ def test_malware_required_fields(): assert excinfo.value.cls == stix2.Malware assert excinfo.value.fields == ["labels", "name"] - assert str(excinfo.value) == "Missing required field(s) for Malware: (labels, name)." def test_malware_required_field_name(): @@ -85,7 +84,6 @@ def test_malware_required_field_name(): assert excinfo.value.cls == stix2.Malware assert excinfo.value.fields == ["name"] - assert str(excinfo.value) == "Missing required field(s) for Malware: (name)." def test_cannot_assign_to_malware_attributes(malware): diff --git a/stix2/test/test_relationship.py b/stix2/test/test_relationship.py index 72e659c..869107d 100644 --- a/stix2/test/test_relationship.py +++ b/stix2/test/test_relationship.py @@ -76,13 +76,16 @@ def test_relationship_id_must_start_with_relationship(): def test_relationship_required_field_relationship_type(): with pytest.raises(stix2.exceptions.MissingFieldsError) as excinfo: stix2.Relationship() - assert str(excinfo.value) == "Missing required field(s) for Relationship: (relationship_type, source_ref, target_ref)." + assert excinfo.value.cls == stix2.Relationship + assert excinfo.value.fields == ["relationship_type", "source_ref", "target_ref"] def test_relationship_missing_some_required_fields(): with pytest.raises(stix2.exceptions.MissingFieldsError) as excinfo: stix2.Relationship(relationship_type='indicates') - assert str(excinfo.value) == "Missing required field(s) for Relationship: (source_ref, target_ref)." + + assert excinfo.value.cls == stix2.Relationship + assert excinfo.value.fields == ["source_ref", "target_ref"] def test_relationship_required_field_target_ref(): @@ -94,7 +97,6 @@ def test_relationship_required_field_target_ref(): assert excinfo.value.cls == stix2.Relationship assert excinfo.value.fields == ["target_ref"] - assert str(excinfo.value) == "Missing required field(s) for Relationship: (target_ref)." def test_cannot_assign_to_relationship_attributes(relationship): diff --git a/stix2/test/test_versioning.py b/stix2/test/test_versioning.py index cecaa89..0e51abc 100644 --- a/stix2/test/test_versioning.py +++ b/stix2/test/test_versioning.py @@ -23,6 +23,26 @@ def test_making_new_version(): assert campaign_v1.modified < campaign_v2.modified +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_v2 = campaign_v1.new_version(description=None) + + 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.description == None + assert campaign_v1.modified < campaign_v2.modified + + def test_making_new_version_with_embedded_object(): campaign_v1 = stix2.Campaign( id="campaign--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f", @@ -86,7 +106,42 @@ def test_versioning_error_invalid_property(): with pytest.raises(stix2.exceptions.UnmodifiablePropertyError) as excinfo: campaign_v1.new_version(type="threat-actor") - str(excinfo.value) == "These properties cannot be changed when making a new version: type" + assert str(excinfo.value) == "These properties cannot be changed when making a new version: type." + + +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." + ) + + with pytest.raises(stix2.exceptions.InvalidValueError) as excinfo: + campaign_v1.new_version(modified="2015-04-06T20:03:00.000Z") + + assert excinfo.value.cls == stix2.Campaign + 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_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." + ) + + with pytest.raises(stix2.exceptions.MissingFieldsError) as excinfo: + campaign_v1.new_version(name=None) + + assert excinfo.value.cls == stix2.Campaign + assert excinfo.value.fields == [ "name" ] def test_versioning_error_new_version_of_revoked(): @@ -104,7 +159,7 @@ def test_versioning_error_new_version_of_revoked(): with pytest.raises(stix2.exceptions.RevokeError) as excinfo: campaign_v2.new_version(name="barney") - str(excinfo.value) == "Cannot create a new version of a revoked object" + assert excinfo.value.called_by == "new_version" def test_versioning_error_revoke_of_revoked(): @@ -122,4 +177,4 @@ def test_versioning_error_revoke_of_revoked(): with pytest.raises(stix2.exceptions.RevokeError) as excinfo: campaign_v2.revoke() - str(excinfo.value) == "Cannot revoke an already revoked object" + assert excinfo.value.called_by == "revoke" diff --git a/stix2/utils.py b/stix2/utils.py index cdc7f54..5feffae 100644 --- a/stix2/utils.py +++ b/stix2/utils.py @@ -1,6 +1,7 @@ """Utility functions and classes for the stix2 library.""" import datetime as dt +from dateutil import parser import json import pytz @@ -34,6 +35,28 @@ def format_datetime(dttm): return ts + "Z" +def parse_into_datetime(value): + if isinstance(value, dt.date): + if hasattr(value, 'hour'): + return value + else: + # Add a time component + return dt.datetime.combine(value, dt.time(), tzinfo=pytz.utc) + + # value isn't a date or datetime object so assume it's a string + try: + parsed = parser.parse(value) + except TypeError: + # Unknown format + raise ValueError("must be a datetime object, date object, or " + "timestamp string in a recognizable format.") + if parsed.tzinfo: + return parsed.astimezone(pytz.utc) + else: + # Doesn't have timezone info in the string; assume UTC + return pytz.utc.localize(parsed) + + def get_dict(data): """Return data as a dictionary. Input can be a dictionary, string, or file-like object.