From 450806bac6d967bdd7b85ba65d366db3ae111485 Mon Sep 17 00:00:00 2001 From: Greg Back Date: Mon, 19 Dec 2016 13:53:17 -0500 Subject: [PATCH 01/46] Add README --- README.md | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..7637bcb --- /dev/null +++ b/README.md @@ -0,0 +1,150 @@ +# stix2 + +Create, parse, and interact with STIX 2 JSON content. + +## Installation + +Install with [`pip`](https://pip.pypa.io/en/stable/): + +``` +pip install stix2 +``` + +## Usage + + +### Creating STIX Domain Objects + +To create a STIX object, provide keyword arguments to the type's constructor: + +```python +from stix2 import Indicator + +indicator = Indicator(name="File hash for malware variant", + labels=['malicious-activity'], + pattern='file:hashes.md5 = "d41d8cd98f00b204e9800998ecf8427e"') + +``` + +Certain required attributes of all objectswill be set automatically if not +provided as keyword arguments: + +- If not provided, `type` will be set automatically to the correct type. + You can also provide the type explicitly, but this is not necessary: + + ```python + indicator = Indicator(type='indicator', ...) + ``` + + Passing a value for `type` that does not match the class being constructed + will cause an error: + + ```python + >>> indicator = Indicator(type='indicator', ...) + ValueError: Indicators must have type='indicator' + ``` + +- If not provided, `id` will be generated randomly. If you provide an `id` + argument, it must begin with the correct prefix: + + ```python + >>> indicator = Indicator(id="campaign--63ce9068-b5ab-47fa-a2cf-a602ea01f21a") + ValueError: Indicator id values must begin with 'indicator--' + ``` + +- If not provided, `created` and `modified` will be set to the (same) current + time. + +For indicators, `labels` and `pattern` are required and cannot be set +automatically. Trying to create an indicator that is missing one of these fields +will result in an error: + +```python +>>> indicator = Indicator() +ValueError: Missing required field for Indicator: 'labels' +``` + +However, the required `valid_from` attribute on Indicators will be set to the +current time if not provided as a keyword argument. + +Once created, the object acts like a frozen dictionary. Properties can be +accessed using the standard Python dictionary syntax: + +```python +>>> indicator['name'] +'File hash for malware variant' +``` + +TBD: Should we allow property access using the standard Python attribute syntax? + +```python +>>> indicator.name +'File hash for malware variant' +``` + +Attempting to modify any attributes will raise an error: + +```python +>>>indicator['name'] = "This is a revised name" +ValueError: Cannot modify properties after creation. +``` + +To update the properties of an object, see [Versioning](#versioning) below. + +Creating a Malware object follows the same pattern: + +```python +from stix2 import Malware + +malware = Malware(name="Poison Ivy", + labels=['remote-access-trojan']) +``` + +As with indicators, the `type`, `id`, `created`, and `modified` properties will +be set automatically if not provided. For Malware objects, the `labels` and +`name` properties must be provided. + +### Creating Relationships + +STIX 2 Relationships are separate objects, not properties of the object on +either side of the relationship. They are constructed similarly to other STIX +objects. The `type`, `id`, `created`, and `modified` properties are added +automatically if not provided. Callers must provide the `relationship_type`, +`source_ref`, and `target_ref` properties. + +```python +from stix2 import Relationship + +relationship = Relationship(relationship_type='indicates', + source_ref=indicator.id, + target_ref=malware.id) +``` + +The `source_ref` and `target_ref` properties can be either the ID's of other +STIX objects, or the STIX objects themselves. For readability, Relationship +objects can also be constructed with the `source_ref`, `relationship_type`, and +`target_ref` as positional (non-keyword) arguments: + +```python +relationship = Relationship(indicator, 'indicates', malware) +``` + +### Creating Bundles + +STIX Bundles can be created by passing objects as arguments to the Bundle +constructor. All required properties (`type`, `id`, and `spec_version`) will be +set automatically if not provided, or can be provided as keyword arguments: + +``` +from stix2 import bundle + +bundle = Bundle(indicator, malware, relationship) +``` + +### Serializing STIX objects + +TBD + +### Versioning + +TBD \ No newline at end of file From 86585d229e2c0071472b36529246a1f4c72c56ed Mon Sep 17 00:00:00 2001 From: Greg Back Date: Tue, 17 Jan 2017 08:54:39 -0800 Subject: [PATCH 02/46] Initial package files. --- .gitignore | 59 ++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 4 +++ setup.py | 10 +++++++ stix2/__init__.py | 0 stix2/test/__init__.py | 0 5 files changed, 73 insertions(+) create mode 100644 .gitignore create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 stix2/__init__.py create mode 100644 stix2/test/__init__.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e50675e --- /dev/null +++ b/.gitignore @@ -0,0 +1,59 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7077d7c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +pytest +tox + +-e . diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..f72753b --- /dev/null +++ b/setup.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +from setuptools import setup, find_packages + +setup( + name='stix2', + description="Produce and consume STIX 2 JSON content", + version='0.0.1', + packages=find_packages(), + keywords="stix stix2 json cti cyber threat intelligence", +) diff --git a/stix2/__init__.py b/stix2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stix2/test/__init__.py b/stix2/test/__init__.py new file mode 100644 index 0000000..e69de29 From 2f8c2780c2da5959a414cfc1dbd3efdbcb828a89 Mon Sep 17 00:00:00 2001 From: Greg Back Date: Tue, 17 Jan 2017 12:37:47 -0800 Subject: [PATCH 03/46] Initial tests for STIX 2 --- stix2/__init__.py | 7 +++++++ stix2/test/test_stix2.py | 8 ++++++++ 2 files changed, 15 insertions(+) create mode 100644 stix2/test/test_stix2.py diff --git a/stix2/__init__.py b/stix2/__init__.py index e69de29..74aeca4 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -0,0 +1,7 @@ +import uuid + + +class Indicator: + + def __init__(self): + self.id = "indicator--" + str(uuid.uuid4()) diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py new file mode 100644 index 0000000..0ca98f9 --- /dev/null +++ b/stix2/test/test_stix2.py @@ -0,0 +1,8 @@ +"""Tests for the stix2 library""" + +import stix2 + + +def test_basic_indicator(): + indicator = stix2.Indicator() + assert indicator.id.startswith("indicator") From 6761d1fdfce1e1792e22d26b69b869dca9b531b0 Mon Sep 17 00:00:00 2001 From: Greg Back Date: Tue, 17 Jan 2017 13:58:19 -0800 Subject: [PATCH 04/46] Add required fields to Indicator. --- setup.py | 5 ++++ stix2/__init__.py | 51 ++++++++++++++++++++++++++++++++++++++-- stix2/test/test_stix2.py | 48 +++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index f72753b..22ee0ac 100644 --- a/setup.py +++ b/setup.py @@ -1,10 +1,15 @@ #!/usr/bin/env python from setuptools import setup, find_packages +install_requires = [ + 'pytz', +] + setup( name='stix2', description="Produce and consume STIX 2 JSON content", version='0.0.1', packages=find_packages(), + install_requires=install_requires, keywords="stix stix2 json cti cyber threat intelligence", ) diff --git a/stix2/__init__.py b/stix2/__init__.py index 74aeca4..678374a 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -1,7 +1,54 @@ +from datetime import datetime +import json import uuid +import pytz + + +def format_datetime(dt): + # 1. Convert to UTC + # 2. Format in isoformat + # 3. Strip off "+00:00" + # 4. Add "Z" + return dt.astimezone(pytz.utc).isoformat()[:-6] + "Z" + +# REQUIRED (all): +# - type +# - id +# - created +# - modified + class Indicator: + # REQUIRED (Indicator): + # - type + # - labels + # - pattern + # - valid_from + required = [''] - def __init__(self): - self.id = "indicator--" + str(uuid.uuid4()) + def __init__(self, type='indicator', id=None, created=None, modified=None, + labels=None, pattern=None, valid_from=None): + now = datetime.now(tz=pytz.UTC) + + self.type = type + if not id: + id = "indicator--" + str(uuid.uuid4()) + self.id = id + self.created = created or now + self.modified = modified or now + self.labels = labels + self.pattern = pattern + self.valid_from = valid_from or now + + def __str__(self): + # TODO: put keys in specific order. Probably need custom JSON encoder. + return json.dumps({ + 'type': self.type, + 'id': self.id, + 'created': format_datetime(self.created), + 'modified': format_datetime(self.modified), + 'labels': self.labels, + 'pattern': self.pattern, + 'valid_from': format_datetime(self.valid_from), + }, indent=4, sort_keys=True, separators=(",", ": ")) # Don't include spaces after commas. diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index 0ca98f9..82b7e42 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -1,8 +1,56 @@ """Tests for the stix2 library""" +import datetime + +import pytest +import pytz + import stix2 +amsterdam = pytz.timezone('Europe/Amsterdam') +eastern = pytz.timezone('US/Eastern') + + +@pytest.mark.parametrize('dt,timestamp', [ + (datetime.datetime(2017, 1, 1, tzinfo=pytz.utc), '2017-01-01T00:00:00Z'), + (amsterdam.localize(datetime.datetime(2017, 1, 1)), '2016-12-31T23:00:00Z'), + (eastern.localize(datetime.datetime(2017, 1, 1, 12, 34, 56)), '2017-01-01T17:34:56Z'), + (eastern.localize(datetime.datetime(2017, 7, 1)), '2017-07-01T04:00:00Z'), +]) +def test_timestamp_formatting(dt, timestamp): + assert stix2.format_datetime(dt) == timestamp + def test_basic_indicator(): indicator = stix2.Indicator() assert indicator.id.startswith("indicator") + + +EXPECTED = """{ + "created": "2017-01-01T00:00:00Z", + "id": "indicator--01234567-89ab-cdef-0123-456789abcdef", + "labels": [ + "malicious-activity" + ], + "modified": "2017-01-01T00:00:00Z", + "pattern": "[file:hashes.MD5 = 'd41d8cd98f00b204e9800998ecf8427e']", + "type": "indicator", + "valid_from": "1970-01-01T00:00:00Z" +}""" + + +def test_indicator_with_all_required_fields(): + now = datetime.datetime(2017, 1, 1, 0, 0, 0, tzinfo=pytz.utc) + epoch = datetime.datetime(1970, 1, 1, 0, 0, 0, tzinfo=pytz.utc) + + indicator = stix2.Indicator( + type="indicator", + id="indicator--01234567-89ab-cdef-0123-456789abcdef", + created=now, + modified=now, + labels=['malicious-activity'], + pattern="[file:hashes.MD5 = 'd41d8cd98f00b204e9800998ecf8427e']", + valid_from=epoch, + ) + + assert str(indicator) == EXPECTED From d054b9debaad2e34f00f1d60e8dd01ce4f31ad4c Mon Sep 17 00:00:00 2001 From: Greg Back Date: Tue, 17 Jan 2017 14:09:20 -0800 Subject: [PATCH 05/46] Add tests for all different fields --- stix2/test/test_stix2.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index 82b7e42..c2d6271 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -21,11 +21,6 @@ def test_timestamp_formatting(dt, timestamp): assert stix2.format_datetime(dt) == timestamp -def test_basic_indicator(): - indicator = stix2.Indicator() - assert indicator.id.startswith("indicator") - - EXPECTED = """{ "created": "2017-01-01T00:00:00Z", "id": "indicator--01234567-89ab-cdef-0123-456789abcdef", @@ -46,11 +41,26 @@ def test_indicator_with_all_required_fields(): indicator = stix2.Indicator( type="indicator", id="indicator--01234567-89ab-cdef-0123-456789abcdef", - created=now, - modified=now, labels=['malicious-activity'], pattern="[file:hashes.MD5 = 'd41d8cd98f00b204e9800998ecf8427e']", + created=now, + modified=now, valid_from=epoch, ) assert str(indicator) == EXPECTED + + +def test_indicator_autogenerated_fields(): + indicator = stix2.Indicator( + labels=['malicious-activity'], + pattern="[file:hashes.MD5 = 'd41d8cd98f00b204e9800998ecf8427e']", + ) + + assert indicator.type == 'indicator' + assert indicator.id.startswith('indicator--') + assert indicator.created is not None + assert indicator.modified is not None + assert indicator.labels == ['malicious-activity'] + assert indicator.pattern == "[file:hashes.MD5 = 'd41d8cd98f00b204e9800998ecf8427e']" + assert indicator.valid_from is not None From 9974ade5b67f38f70550ca53b20cb7ba1d497e57 Mon Sep 17 00:00:00 2001 From: Greg Back Date: Tue, 17 Jan 2017 14:26:12 -0800 Subject: [PATCH 06/46] Update README --- README.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 7637bcb..fdcd56e 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,10 @@ indicator = Indicator(name="File hash for malware variant", ``` -Certain required attributes of all objectswill be set automatically if not +Certain required attributes of all objects will be set automatically if not provided as keyword arguments: -- If not provided, `type` will be set automatically to the correct type. +- If not provided, `type` will be set automatically to the correct type. You can also provide the type explicitly, but this is not necessary: ```python @@ -40,7 +40,7 @@ provided as keyword arguments: will cause an error: ```python - >>> indicator = Indicator(type='indicator', ...) + >>> indicator = Indicator(type='xxx', ...) ValueError: Indicators must have type='indicator' ``` @@ -48,7 +48,7 @@ provided as keyword arguments: argument, it must begin with the correct prefix: ```python - >>> indicator = Indicator(id="campaign--63ce9068-b5ab-47fa-a2cf-a602ea01f21a") + >>> indicator = Indicator(id="campaign--63ce9068-b5ab-47fa-a2cf-a602ea01f21a") ValueError: Indicator id values must begin with 'indicator--' ``` @@ -60,7 +60,7 @@ automatically. Trying to create an indicator that is missing one of these fields will result in an error: ```python ->>> indicator = Indicator() +>>> indicator = Indicator() ValueError: Missing required field for Indicator: 'labels' ``` @@ -102,14 +102,14 @@ malware = Malware(name="Poison Ivy", As with indicators, the `type`, `id`, `created`, and `modified` properties will be set automatically if not provided. For Malware objects, the `labels` and -`name` properties must be provided. +`name` properties must be provided. ### Creating Relationships STIX 2 Relationships are separate objects, not properties of the object on either side of the relationship. They are constructed similarly to other STIX objects. The `type`, `id`, `created`, and `modified` properties are added -automatically if not provided. Callers must provide the `relationship_type`, +automatically if not provided. Callers must provide the `relationship_type`, `source_ref`, and `target_ref` properties. ```python @@ -120,10 +120,10 @@ relationship = Relationship(relationship_type='indicates', target_ref=malware.id) ``` -The `source_ref` and `target_ref` properties can be either the ID's of other +The `source_ref` and `target_ref` properties can be either the ID's of other STIX objects, or the STIX objects themselves. For readability, Relationship objects can also be constructed with the `source_ref`, `relationship_type`, and -`target_ref` as positional (non-keyword) arguments: +`target_ref` as positional (non-keyword) arguments: ```python relationship = Relationship(indicator, 'indicates', malware) @@ -147,4 +147,4 @@ TBD ### Versioning -TBD \ No newline at end of file +TBD From ebf6513445c54553199c8dcb7c8beefdf73bb707 Mon Sep 17 00:00:00 2001 From: Greg Back Date: Tue, 17 Jan 2017 14:46:00 -0800 Subject: [PATCH 07/46] Check for valid IDs and types on indicators. --- stix2/__init__.py | 8 +++++++- stix2/test/test_stix2.py | 27 ++++++++++++++++++++++----- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/stix2/__init__.py b/stix2/__init__.py index 678374a..4a3420e 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -31,10 +31,16 @@ class Indicator: labels=None, pattern=None, valid_from=None): now = datetime.now(tz=pytz.UTC) + if type != 'indicator': + raise ValueError("Indicators must have type='indicator'.") self.type = type + if not id: - id = "indicator--" + str(uuid.uuid4()) + id = 'indicator--' + str(uuid.uuid4()) + if not id.startswith('indicator--'): + raise ValueError("Indicator id values must begin with 'indicator--'.") self.id = id + self.created = created or now self.modified = modified or now self.labels = labels diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index c2d6271..59dd820 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -51,12 +51,15 @@ def test_indicator_with_all_required_fields(): assert str(indicator) == EXPECTED -def test_indicator_autogenerated_fields(): - indicator = stix2.Indicator( - labels=['malicious-activity'], - pattern="[file:hashes.MD5 = 'd41d8cd98f00b204e9800998ecf8427e']", - ) +# Minimum required args for an indicator +KWARGS = dict( + labels=['malicious-activity'], + pattern="[file:hashes.MD5 = 'd41d8cd98f00b204e9800998ecf8427e']", +) + +def test_indicator_autogenerated_fields(): + indicator = stix2.Indicator(**KWARGS) assert indicator.type == 'indicator' assert indicator.id.startswith('indicator--') assert indicator.created is not None @@ -64,3 +67,17 @@ def test_indicator_autogenerated_fields(): assert indicator.labels == ['malicious-activity'] assert indicator.pattern == "[file:hashes.MD5 = 'd41d8cd98f00b204e9800998ecf8427e']" assert indicator.valid_from is not None + + +def test_indicator_type_must_be_indicator(): + with pytest.raises(ValueError) as excinfo: + indicator = stix2.Indicator(type='xxx') + + assert "Indicators must have type='indicator'." in str(excinfo) + + +def test_indicator_id_must_start_with_indicator(): + with pytest.raises(ValueError) as excinfo: + indicator = stix2.Indicator(id='my-prefix--') + + assert "Indicator id values must begin with 'indicator--'." in str(excinfo) From 31cebdd34a713d7176e12aa52e8d09cb37230bd9 Mon Sep 17 00:00:00 2001 From: Greg Back Date: Tue, 17 Jan 2017 14:52:56 -0800 Subject: [PATCH 08/46] Add tests for required fields. --- stix2/__init__.py | 6 ++++++ stix2/test/test_stix2.py | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/stix2/__init__.py b/stix2/__init__.py index 4a3420e..80eaa41 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -43,7 +43,13 @@ class Indicator: self.created = created or now self.modified = modified or now + + if not labels: + raise ValueError("Missing required field for Indicator: 'labels'.") self.labels = labels + + if not pattern: + raise ValueError("Missing required field for Indicator: 'pattern'.") self.pattern = pattern self.valid_from = valid_from or now diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index 59dd820..902b7de 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -81,3 +81,16 @@ def test_indicator_id_must_start_with_indicator(): indicator = stix2.Indicator(id='my-prefix--') assert "Indicator id values must begin with 'indicator--'." in str(excinfo) + + +def test_indicator_required_field_labels(): + with pytest.raises(ValueError) as excinfo: + indicator = stix2.Indicator() + assert "Missing required field for Indicator: 'labels'." in str(excinfo) + + +def test_indicator_required_field_pattern(): + with pytest.raises(ValueError) as excinfo: + # Label is checked first, so make sure that is provided + indicator = stix2.Indicator(labels=['malicious-activity']) + assert "Missing required field for Indicator: 'pattern'." in str(excinfo) From eeec5a4ce31f0fb40463e600eabea5e5d862ed7a Mon Sep 17 00:00:00 2001 From: Greg Back Date: Tue, 17 Jan 2017 15:03:56 -0800 Subject: [PATCH 09/46] Allow key-based access along with attribute access --- stix2/__init__.py | 5 ++++- stix2/test/test_stix2.py | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/stix2/__init__.py b/stix2/__init__.py index 80eaa41..e2e95f9 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -19,7 +19,7 @@ def format_datetime(dt): # - modified -class Indicator: +class Indicator(object): # REQUIRED (Indicator): # - type # - labels @@ -27,6 +27,9 @@ class Indicator: # - valid_from required = [''] + def __getitem__(self, key): + return getattr(self, key) + def __init__(self, type='indicator', id=None, created=None, modified=None, labels=None, pattern=None, valid_from=None): now = datetime.now(tz=pytz.UTC) diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index 902b7de..489e3bd 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -68,6 +68,14 @@ def test_indicator_autogenerated_fields(): assert indicator.pattern == "[file:hashes.MD5 = 'd41d8cd98f00b204e9800998ecf8427e']" assert indicator.valid_from is not None + assert indicator['type'] == 'indicator' + assert indicator['id'].startswith('indicator--') + assert indicator['created'] is not None + assert indicator['modified'] is not None + assert indicator['labels'] == ['malicious-activity'] + assert indicator['pattern'] == "[file:hashes.MD5 = 'd41d8cd98f00b204e9800998ecf8427e']" + assert indicator['valid_from'] is not None + def test_indicator_type_must_be_indicator(): with pytest.raises(ValueError) as excinfo: From ef0b80ad44811a76d499a7e445d4560a47486fe9 Mon Sep 17 00:00:00 2001 From: Greg Back Date: Tue, 17 Jan 2017 15:52:03 -0800 Subject: [PATCH 10/46] Allow attribute and key-based access. Make immutable. --- stix2/__init__.py | 79 +++++++++++++++++++++++++--------------- stix2/test/test_stix2.py | 9 +++++ 2 files changed, 58 insertions(+), 30 deletions(-) diff --git a/stix2/__init__.py b/stix2/__init__.py index e2e95f9..c1f3feb 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -1,3 +1,4 @@ +import collections from datetime import datetime import json import uuid @@ -12,58 +13,76 @@ def format_datetime(dt): # 4. Add "Z" return dt.astimezone(pytz.utc).isoformat()[:-6] + "Z" -# REQUIRED (all): -# - type -# - id -# - created -# - modified - -class Indicator(object): - # REQUIRED (Indicator): - # - type - # - labels - # - pattern - # - valid_from - required = [''] +class Indicator(collections.Mapping): def __getitem__(self, key): - return getattr(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): + return self.get(name) + + def __setattr__(self, name, value): + if name != '_inner': + raise ValueError("Cannot modify properties after creation.") + super(Indicator, self).__setattr__(name, value) def __init__(self, type='indicator', id=None, created=None, modified=None, labels=None, pattern=None, valid_from=None): - now = datetime.now(tz=pytz.UTC) + # TODO: + # - created_by_ref + # - revoked + # - external_references + # - object_marking_refs + # - granular_markings + + # - name + # - description + # - valid_until + # - kill_chain_phases + + if not created or not modified or not valid_from: + now = datetime.now(tz=pytz.UTC) if type != 'indicator': raise ValueError("Indicators must have type='indicator'.") - self.type = type if not id: id = 'indicator--' + str(uuid.uuid4()) if not id.startswith('indicator--'): raise ValueError("Indicator id values must begin with 'indicator--'.") - self.id = id - - self.created = created or now - self.modified = modified or now if not labels: raise ValueError("Missing required field for Indicator: 'labels'.") - self.labels = labels if not pattern: raise ValueError("Missing required field for Indicator: 'pattern'.") - self.pattern = pattern - self.valid_from = valid_from or now + + self._inner = { + 'type': type, + 'id': id or now, + 'created': created or now, + 'modified': modified or now, + 'labels': labels, + 'pattern': pattern, + 'valid_from': valid_from or now, + } def __str__(self): # TODO: put keys in specific order. Probably need custom JSON encoder. return json.dumps({ - 'type': self.type, - 'id': self.id, - 'created': format_datetime(self.created), - 'modified': format_datetime(self.modified), - 'labels': self.labels, - 'pattern': self.pattern, - 'valid_from': format_datetime(self.valid_from), + 'type': self['type'], + 'id': self['id'], + 'created': format_datetime(self['created']), + 'modified': format_datetime(self['modified']), + 'labels': self['labels'], + 'pattern': self['pattern'], + 'valid_from': format_datetime(self['valid_from']), }, indent=4, sort_keys=True, separators=(",", ": ")) # Don't include spaces after commas. diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index 489e3bd..ab45602 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -102,3 +102,12 @@ def test_indicator_required_field_pattern(): # Label is checked first, so make sure that is provided indicator = stix2.Indicator(labels=['malicious-activity']) assert "Missing required field for Indicator: 'pattern'." in str(excinfo) + + +def test_cannot_assign_to_attributes(): + indicator = stix2.Indicator(**KWARGS) + + with pytest.raises(ValueError) as excinfo: + indicator.valid_from = datetime.datetime.now() + + assert "Cannot modify properties after creation." in str(excinfo) From a1dfa09fb9060a81425f40c8a68860dc4e97e492 Mon Sep 17 00:00:00 2001 From: Greg Back Date: Tue, 17 Jan 2017 16:09:53 -0800 Subject: [PATCH 11/46] Add coverage and pytest-cov to requirements. --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 7077d7c..d10154e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ pytest tox +pytest-cov -e . From 3e7adef792d586ed027011ba2d6d0906ee03ef8e Mon Sep 17 00:00:00 2001 From: Greg Back Date: Tue, 17 Jan 2017 16:53:27 -0800 Subject: [PATCH 12/46] Add Malware object with required fields. --- stix2/__init__.py | 66 +++++++++++++++++++++++++-- stix2/test/test_stix2.py | 99 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 156 insertions(+), 9 deletions(-) diff --git a/stix2/__init__.py b/stix2/__init__.py index c1f3feb..dd24dc9 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -7,14 +7,19 @@ import pytz def format_datetime(dt): + # TODO: how to handle naive datetime + # 1. Convert to UTC # 2. Format in isoformat # 3. Strip off "+00:00" # 4. Add "Z" + + # TODO: how to handle timestamps with subsecond 0's return dt.astimezone(pytz.utc).isoformat()[:-6] + "Z" -class Indicator(collections.Mapping): +class _STIXBase(collections.Mapping): + """Base class for STIX object types""" def __getitem__(self, key): return self._inner[key] @@ -32,7 +37,10 @@ class Indicator(collections.Mapping): def __setattr__(self, name, value): if name != '_inner': raise ValueError("Cannot modify properties after creation.") - super(Indicator, self).__setattr__(name, value) + super(_STIXBase, self).__setattr__(name, value) + + +class Indicator(_STIXBase): def __init__(self, type='indicator', id=None, created=None, modified=None, labels=None, pattern=None, valid_from=None): @@ -67,7 +75,7 @@ class Indicator(collections.Mapping): self._inner = { 'type': type, - 'id': id or now, + 'id': id, 'created': created or now, 'modified': modified or now, 'labels': labels, @@ -86,3 +94,55 @@ class Indicator(collections.Mapping): 'pattern': self['pattern'], 'valid_from': format_datetime(self['valid_from']), }, indent=4, sort_keys=True, separators=(",", ": ")) # Don't include spaces after commas. + + +class Malware(_STIXBase): + + def __init__(self, type='malware', id=None, created=None, modified=None, + labels=None, name=None): + # TODO: + # - created_by_ref + # - revoked + # - external_references + # - object_marking_refs + # - granular_markings + + # - description + # - kill_chain_phases + + if not created or not modified: + now = datetime.now(tz=pytz.UTC) + + if type != 'malware': + raise ValueError("Malware must have type='malware'.") + + if not id: + id = 'malware--' + str(uuid.uuid4()) + if not id.startswith('malware--'): + raise ValueError("Malware id values must begin with 'malware--'.") + + if not labels: + raise ValueError("Missing required field for Malware: 'labels'.") + + if not name: + raise ValueError("Missing required field for Malware: 'name'.") + + self._inner = { + 'type': type, + 'id': id, + 'created': created or now, + 'modified': modified or now, + 'labels': labels, + 'name': name, + } + + def __str__(self): + # TODO: put keys in specific order. Probably need custom JSON encoder. + return json.dumps({ + 'type': self['type'], + 'id': self['id'], + 'created': format_datetime(self['created']), + 'modified': format_datetime(self['modified']), + 'labels': self['labels'], + 'name': self['name'], + }, indent=4, sort_keys=True, separators=(",", ": ")) # Don't include spaces after commas. diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index ab45602..0bb841f 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -21,7 +21,7 @@ def test_timestamp_formatting(dt, timestamp): assert stix2.format_datetime(dt) == timestamp -EXPECTED = """{ +EXPECTED_INDICATOR = """{ "created": "2017-01-01T00:00:00Z", "id": "indicator--01234567-89ab-cdef-0123-456789abcdef", "labels": [ @@ -48,18 +48,18 @@ def test_indicator_with_all_required_fields(): valid_from=epoch, ) - assert str(indicator) == EXPECTED + assert str(indicator) == EXPECTED_INDICATOR -# Minimum required args for an indicator -KWARGS = dict( +# Minimum required args for an Indicator instance +INDICATOR_KWARGS = dict( labels=['malicious-activity'], pattern="[file:hashes.MD5 = 'd41d8cd98f00b204e9800998ecf8427e']", ) def test_indicator_autogenerated_fields(): - indicator = stix2.Indicator(**KWARGS) + indicator = stix2.Indicator(**INDICATOR_KWARGS) assert indicator.type == 'indicator' assert indicator.id.startswith('indicator--') assert indicator.created is not None @@ -105,9 +105,96 @@ def test_indicator_required_field_pattern(): def test_cannot_assign_to_attributes(): - indicator = stix2.Indicator(**KWARGS) + indicator = stix2.Indicator(**INDICATOR_KWARGS) with pytest.raises(ValueError) as excinfo: indicator.valid_from = datetime.datetime.now() assert "Cannot modify properties after creation." in str(excinfo) + + +EXPECTED_MALWARE = """{ + "created": "2016-05-12T08:17:27Z", + "id": "malware--0c7b5b88-8ff7-4a4d-aa9d-feb398cd0061", + "labels": [ + "ransomware" + ], + "modified": "2016-05-12T08:17:27Z", + "name": "Cryptolocker", + "type": "malware" +}""" + + +def test_malware_with_all_required_fields(): + now = datetime.datetime(2016, 5, 12, 8, 17, 27, tzinfo=pytz.utc) + + malware = stix2.Malware( + type="malware", + id="malware--0c7b5b88-8ff7-4a4d-aa9d-feb398cd0061", + created=now, + modified=now, + labels=["ransomware"], + name="Cryptolocker", + ) + + assert str(malware) == EXPECTED_MALWARE + + +# Minimum required args for a Malware instance +MALWARE_KWARGS = dict( + labels=['ransomware'], + name="Cryptolocker", +) + + +def test_malware_autogenerated_fields(): + malware = stix2.Malware(**MALWARE_KWARGS) + assert malware.type == 'malware' + assert malware.id.startswith('malware--') + assert malware.created is not None + assert malware.modified is not None + assert malware.labels == ['ransomware'] + assert malware.name == "Cryptolocker" + + assert malware['type'] == 'malware' + assert malware['id'].startswith('malware--') + assert malware['created'] is not None + assert malware['modified'] is not None + assert malware['labels'] == ['ransomware'] + assert malware['name'] == "Cryptolocker" + + +def test_malware_type_must_be_malware(): + with pytest.raises(ValueError) as excinfo: + malware = stix2.Malware(type='xxx') + + assert "Malware must have type='malware'." in str(excinfo) + + +def test_malware_id_must_start_with_malware(): + with pytest.raises(ValueError) as excinfo: + malware = stix2.Malware(id='my-prefix--') + + assert "Malware id values must begin with 'malware--'." in str(excinfo) + + +def test_malware_required_field_labels(): + with pytest.raises(ValueError) as excinfo: + malware = stix2.Malware() + assert "Missing required field for Malware: 'labels'." in str(excinfo) + + +def test_malware_required_field_name(): + with pytest.raises(ValueError) as excinfo: + # Label is checked first, so make sure that is provided + malware = stix2.Malware(labels=['ransomware']) + assert "Missing required field for Malware: 'name'." in str(excinfo) + + +def test_cannot_assign_to_attributes(): + malware = stix2.Malware(**MALWARE_KWARGS) + + with pytest.raises(ValueError) as excinfo: + malware.name = "Cryptolocker II" + + assert "Cannot modify properties after creation." in str(excinfo) From 4eaa87660bc4562118fdda6fdc4f298edc0daedb Mon Sep 17 00:00:00 2001 From: Greg Back Date: Tue, 17 Jan 2017 16:58:17 -0800 Subject: [PATCH 13/46] Pull out __str__ function --- stix2/__init__.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/stix2/__init__.py b/stix2/__init__.py index dd24dc9..fba7839 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -39,6 +39,11 @@ class _STIXBase(collections.Mapping): raise ValueError("Cannot modify properties after creation.") super(_STIXBase, self).__setattr__(name, value) + def __str__(self): + # TODO: put keys in specific order. Probably need custom JSON encoder. + return json.dumps(self._dict(), indent=4, sort_keys=True, + separators=(",", ": ")) # Don't include spaces after commas. + class Indicator(_STIXBase): @@ -83,9 +88,8 @@ class Indicator(_STIXBase): 'valid_from': valid_from or now, } - def __str__(self): - # TODO: put keys in specific order. Probably need custom JSON encoder. - return json.dumps({ + def _dict(self): + return { 'type': self['type'], 'id': self['id'], 'created': format_datetime(self['created']), @@ -93,7 +97,7 @@ class Indicator(_STIXBase): 'labels': self['labels'], 'pattern': self['pattern'], 'valid_from': format_datetime(self['valid_from']), - }, indent=4, sort_keys=True, separators=(",", ": ")) # Don't include spaces after commas. + } class Malware(_STIXBase): @@ -136,13 +140,12 @@ class Malware(_STIXBase): 'name': name, } - def __str__(self): - # TODO: put keys in specific order. Probably need custom JSON encoder. - return json.dumps({ + def _dict(self): + return { 'type': self['type'], 'id': self['id'], 'created': format_datetime(self['created']), 'modified': format_datetime(self['modified']), 'labels': self['labels'], 'name': self['name'], - }, indent=4, sort_keys=True, separators=(",", ": ")) # Don't include spaces after commas. + } From 022f7c9166a4698b6a534f29c3870f35dc5b898f Mon Sep 17 00:00:00 2001 From: Greg Back Date: Tue, 17 Jan 2017 17:25:40 -0800 Subject: [PATCH 14/46] Convert constructors to kwargs. --- stix2/__init__.py | 99 ++++++++++++++++++++++++++-------------- stix2/test/test_stix2.py | 8 +++- 2 files changed, 72 insertions(+), 35 deletions(-) diff --git a/stix2/__init__.py b/stix2/__init__.py index fba7839..5a515e8 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -47,8 +47,17 @@ class _STIXBase(collections.Mapping): class Indicator(_STIXBase): - def __init__(self, type='indicator', id=None, created=None, modified=None, - labels=None, pattern=None, valid_from=None): + _properties = [ + 'type', + 'id', + 'created', + 'modified', + 'labels', + 'pattern', + 'valid_from', + ] + + def __init__(self, **kwargs): # TODO: # - created_by_ref # - revoked @@ -61,31 +70,38 @@ class Indicator(_STIXBase): # - valid_until # - kill_chain_phases - if not created or not modified or not valid_from: - now = datetime.now(tz=pytz.UTC) + # TODO: do we care about the performance penalty of creating this + # if we won't need it? + now = datetime.now(tz=pytz.UTC) - if type != 'indicator': - raise ValueError("Indicators must have type='indicator'.") + if not kwargs.get('type'): + kwargs['type'] = 'indicator' + if kwargs['type'] != 'indicator': + raise ValueError("Indicator must have type='indicator'.") - if not id: - id = 'indicator--' + str(uuid.uuid4()) - if not id.startswith('indicator--'): + if not kwargs.get('id'): + kwargs['id'] = 'indicator--' + str(uuid.uuid4()) + if not kwargs['id'].startswith('indicator--'): raise ValueError("Indicator id values must begin with 'indicator--'.") - if not labels: + if not kwargs.get('labels'): raise ValueError("Missing required field for Indicator: 'labels'.") - if not pattern: + if not kwargs.get('pattern'): raise ValueError("Missing required field for Indicator: 'pattern'.") + extra_kwargs = list(set(kwargs.keys()) - set(self._properties)) + if extra_kwargs: + raise TypeError("unexpected keyword arguments: " + str(extra_kwargs)) + self._inner = { - 'type': type, - 'id': id, - 'created': created or now, - 'modified': modified or now, - 'labels': labels, - 'pattern': pattern, - 'valid_from': valid_from or now, + 'type': kwargs['type'], + 'id': kwargs['id'], + 'created': kwargs.get('created', now), + 'modified': kwargs.get('modified', now), + 'labels': kwargs['labels'], + 'pattern': kwargs['pattern'], + 'valid_from': kwargs.get('valid_from', now), } def _dict(self): @@ -102,8 +118,16 @@ class Indicator(_STIXBase): class Malware(_STIXBase): - def __init__(self, type='malware', id=None, created=None, modified=None, - labels=None, name=None): + _properties = [ + 'type', + 'id', + 'created', + 'modified', + 'labels', + 'name', + ] + + def __init__(self, **kwargs): # TODO: # - created_by_ref # - revoked @@ -114,30 +138,37 @@ class Malware(_STIXBase): # - description # - kill_chain_phases - if not created or not modified: - now = datetime.now(tz=pytz.UTC) + # TODO: do we care about the performance penalty of creating this + # if we won't need it? + now = datetime.now(tz=pytz.UTC) - if type != 'malware': + if not kwargs.get('type'): + kwargs['type'] = 'malware' + if kwargs['type'] != 'malware': raise ValueError("Malware must have type='malware'.") - if not id: - id = 'malware--' + str(uuid.uuid4()) - if not id.startswith('malware--'): + if not kwargs.get('id'): + kwargs['id'] = 'malware--' + str(uuid.uuid4()) + if not kwargs['id'].startswith('malware--'): raise ValueError("Malware id values must begin with 'malware--'.") - if not labels: + if not kwargs.get('labels'): raise ValueError("Missing required field for Malware: 'labels'.") - if not name: + if not kwargs.get('name'): raise ValueError("Missing required field for Malware: 'name'.") + extra_kwargs = list(set(kwargs.keys()) - set(self._properties)) + if extra_kwargs: + raise TypeError("unexpected keyword arguments: " + str(extra_kwargs)) + self._inner = { - 'type': type, - 'id': id, - 'created': created or now, - 'modified': modified or now, - 'labels': labels, - 'name': name, + 'type': kwargs['type'], + 'id': kwargs['id'], + 'created': kwargs.get('created', now), + 'modified': kwargs.get('modified', now), + 'labels': kwargs['labels'], + 'name': kwargs['name'], } def _dict(self): diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index 0bb841f..d2259f3 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -81,7 +81,7 @@ def test_indicator_type_must_be_indicator(): with pytest.raises(ValueError) as excinfo: indicator = stix2.Indicator(type='xxx') - assert "Indicators must have type='indicator'." in str(excinfo) + assert "Indicator must have type='indicator'." in str(excinfo) def test_indicator_id_must_start_with_indicator(): @@ -113,6 +113,12 @@ def test_cannot_assign_to_attributes(): assert "Cannot modify properties after creation." in str(excinfo) +def test_invalid_kwarg_to_indicator(): + with pytest.raises(TypeError) as excinfo: + indicator = stix2.Indicator(my_custom_property="foo", **INDICATOR_KWARGS) + assert "unexpected keyword arguments: ['my_custom_property']" in str(excinfo) + + EXPECTED_MALWARE = """{ "created": "2016-05-12T08:17:27Z", "id": "malware--0c7b5b88-8ff7-4a4d-aa9d-feb398cd0061", From e23d265d20ff270a217a4fc6bc9aa5f3367df75e Mon Sep 17 00:00:00 2001 From: Greg Back Date: Wed, 18 Jan 2017 09:31:33 -0800 Subject: [PATCH 15/46] Use pytest fixtures --- stix2/test/test_stix2.py | 51 +++++++++++++++++++++------------------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index d2259f3..b9423c7 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -21,6 +21,29 @@ def test_timestamp_formatting(dt, timestamp): assert stix2.format_datetime(dt) == timestamp +# Minimum required args for an Indicator instance +INDICATOR_KWARGS = dict( + labels=['malicious-activity'], + pattern="[file:hashes.MD5 = 'd41d8cd98f00b204e9800998ecf8427e']", +) + +# Minimum required args for a Malware instance +MALWARE_KWARGS = dict( + labels=['ransomware'], + name="Cryptolocker", +) + + +@pytest.fixture +def indicator(): + return stix2.Indicator(**INDICATOR_KWARGS) + + +@pytest.fixture +def malware(): + return stix2.Malware(**MALWARE_KWARGS) + + EXPECTED_INDICATOR = """{ "created": "2017-01-01T00:00:00Z", "id": "indicator--01234567-89ab-cdef-0123-456789abcdef", @@ -51,15 +74,7 @@ def test_indicator_with_all_required_fields(): assert str(indicator) == EXPECTED_INDICATOR -# Minimum required args for an Indicator instance -INDICATOR_KWARGS = dict( - labels=['malicious-activity'], - pattern="[file:hashes.MD5 = 'd41d8cd98f00b204e9800998ecf8427e']", -) - - -def test_indicator_autogenerated_fields(): - indicator = stix2.Indicator(**INDICATOR_KWARGS) +def test_indicator_autogenerated_fields(indicator): assert indicator.type == 'indicator' assert indicator.id.startswith('indicator--') assert indicator.created is not None @@ -104,9 +119,7 @@ def test_indicator_required_field_pattern(): assert "Missing required field for Indicator: 'pattern'." in str(excinfo) -def test_cannot_assign_to_attributes(): - indicator = stix2.Indicator(**INDICATOR_KWARGS) - +def test_cannot_assign_to_attributes(indicator): with pytest.raises(ValueError) as excinfo: indicator.valid_from = datetime.datetime.now() @@ -146,15 +159,7 @@ def test_malware_with_all_required_fields(): assert str(malware) == EXPECTED_MALWARE -# Minimum required args for a Malware instance -MALWARE_KWARGS = dict( - labels=['ransomware'], - name="Cryptolocker", -) - - -def test_malware_autogenerated_fields(): - malware = stix2.Malware(**MALWARE_KWARGS) +def test_malware_autogenerated_fields(malware): assert malware.type == 'malware' assert malware.id.startswith('malware--') assert malware.created is not None @@ -197,9 +202,7 @@ def test_malware_required_field_name(): assert "Missing required field for Malware: 'name'." in str(excinfo) -def test_cannot_assign_to_attributes(): - malware = stix2.Malware(**MALWARE_KWARGS) - +def test_cannot_assign_to_attributes(malware): with pytest.raises(ValueError) as excinfo: malware.name = "Cryptolocker II" From da75833400463445e25f486b69915fbb2813ba79 Mon Sep 17 00:00:00 2001 From: Greg Back Date: Wed, 18 Jan 2017 10:59:28 -0800 Subject: [PATCH 16/46] Add Relationship class with required fields. --- stix2/__init__.py | 71 ++++++++++++++++++++++ stix2/test/test_stix2.py | 127 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 194 insertions(+), 4 deletions(-) diff --git a/stix2/__init__.py b/stix2/__init__.py index 5a515e8..f881f96 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -180,3 +180,74 @@ class Malware(_STIXBase): 'labels': self['labels'], 'name': self['name'], } + + +class Relationship(_STIXBase): + + _properties = [ + 'type', + 'id', + 'created', + 'modified', + 'relationship_type', + 'source_ref', + 'target_ref', + ] + + def __init__(self, **kwargs): + # TODO: + # - created_by_ref + # - revoked + # - external_references + # - object_marking_refs + # - granular_markings + + # - description + + # TODO: do we care about the performance penalty of creating this + # if we won't need it? + now = datetime.now(tz=pytz.UTC) + + if not kwargs.get('type'): + kwargs['type'] = 'relationship' + if kwargs['type'] != 'relationship': + raise ValueError("Relationship must have type='relationship'.") + + if not kwargs.get('id'): + kwargs['id'] = 'relationship--' + str(uuid.uuid4()) + if not kwargs['id'].startswith('relationship--'): + raise ValueError("Relationship id values must begin with 'relationship--'.") + + if not kwargs.get('relationship_type'): + raise ValueError("Missing required field for Relationship: 'relationship_type'.") + + if not kwargs.get('source_ref'): + raise ValueError("Missing required field for Relationship: 'source_ref'.") + + if not kwargs.get('target_ref'): + raise ValueError("Missing required field for Relationship: 'target_ref'.") + + extra_kwargs = list(set(kwargs.keys()) - set(self._properties)) + if extra_kwargs: + raise TypeError("unexpected keyword arguments: " + str(extra_kwargs)) + + self._inner = { + 'type': kwargs['type'], + 'id': kwargs['id'], + 'created': kwargs.get('created', now), + 'modified': kwargs.get('modified', now), + 'relationship_type': kwargs['relationship_type'], + 'source_ref': kwargs['source_ref'], + 'target_ref': kwargs['target_ref'], + } + + def _dict(self): + return { + 'type': self['type'], + 'id': self['id'], + 'created': format_datetime(self['created']), + 'modified': format_datetime(self['modified']), + 'relationship_type': self['relationship_type'], + 'source_ref': self['source_ref'], + 'target_ref': self['target_ref'], + } diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index b9423c7..02e721d 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -34,14 +34,33 @@ MALWARE_KWARGS = dict( ) +# Minimum required args for a Relationship instance +RELATIONSHIP_KWARGS = dict( + relationship_type="indicates", + source_ref="indicator--01234567-89ab-cdef-0123-456789abcdef", + target_ref="malware--fedcba98-7654-3210-fedc-ba9876543210", +) + + @pytest.fixture def indicator(): - return stix2.Indicator(**INDICATOR_KWARGS) + return stix2.Indicator( + id="indicator--01234567-89ab-cdef-0123-456789abcdef", + **INDICATOR_KWARGS + ) @pytest.fixture def malware(): - return stix2.Malware(**MALWARE_KWARGS) + return stix2.Malware( + id="malware--fedcba98-7654-3210-fedc-ba9876543210", + **MALWARE_KWARGS + ) + + +@pytest.fixture +def relationship(): + return stix2.Relationship(**RELATIONSHIP_KWARGS) EXPECTED_INDICATOR = """{ @@ -119,7 +138,7 @@ def test_indicator_required_field_pattern(): assert "Missing required field for Indicator: 'pattern'." in str(excinfo) -def test_cannot_assign_to_attributes(indicator): +def test_cannot_assign_to_indicator_attributes(indicator): with pytest.raises(ValueError) as excinfo: indicator.valid_from = datetime.datetime.now() @@ -202,8 +221,108 @@ def test_malware_required_field_name(): assert "Missing required field for Malware: 'name'." in str(excinfo) -def test_cannot_assign_to_attributes(malware): +def test_cannot_assign_to_malware_attributes(malware): with pytest.raises(ValueError) as excinfo: malware.name = "Cryptolocker II" assert "Cannot modify properties after creation." in str(excinfo) + + +def test_invalid_kwarg_to_malware(): + with pytest.raises(TypeError) as excinfo: + malware = stix2.Malware(my_custom_property="foo", **MALWARE_KWARGS) + assert "unexpected keyword arguments: ['my_custom_property']" in str(excinfo) + + +EXPECTED_RELATIONSHIP = """{ + "created": "2016-04-06T20:06:37Z", + "id": "relationship--44298a74-ba52-4f0c-87a3-1824e67d7fad", + "modified": "2016-04-06T20:06:37Z", + "relationship_type": "indicates", + "source_ref": "indicator--01234567-89ab-cdef-0123-456789abcdef", + "target_ref": "malware--fedcba98-7654-3210-fedc-ba9876543210", + "type": "relationship" +}""" + + +def test_relationship_all_required_fields(indicator, malware): + now = datetime.datetime(2016, 4, 6, 20, 6, 37, tzinfo=pytz.utc) + + relationship = stix2.Relationship( + type='relationship', + id='relationship--44298a74-ba52-4f0c-87a3-1824e67d7fad', + created=now, + modified=now, + relationship_type='indicates', + source_ref=indicator.id, + target_ref=malware.id, + ) + assert str(relationship) == EXPECTED_RELATIONSHIP + + +def test_relationship_autogenerated_fields(relationship): + assert relationship.type == 'relationship' + assert relationship.id.startswith('relationship--') + assert relationship.created is not None + assert relationship.modified is not None + assert relationship.relationship_type == 'indicates' + assert relationship.source_ref == "indicator--01234567-89ab-cdef-0123-456789abcdef" + assert relationship.target_ref == "malware--fedcba98-7654-3210-fedc-ba9876543210" + + assert relationship['type'] == 'relationship' + assert relationship['id'].startswith('relationship--') + assert relationship['created'] is not None + assert relationship['modified'] is not None + assert relationship['relationship_type'] == 'indicates' + assert relationship['source_ref'] == "indicator--01234567-89ab-cdef-0123-456789abcdef" + assert relationship['target_ref'] == "malware--fedcba98-7654-3210-fedc-ba9876543210" + + +def test_relationship_type_must_be_relationship(): + with pytest.raises(ValueError) as excinfo: + relationship = stix2.Relationship(type='xxx') + + assert "Relationship must have type='relationship'." in str(excinfo) + + +def test_relationship_id_must_start_with_relationship(): + with pytest.raises(ValueError) as excinfo: + relationship = stix2.Relationship(id='my-prefix--') + + assert "Relationship id values must begin with 'relationship--'." in str(excinfo) + + +def test_relationship_required_field_relationship_type(): + with pytest.raises(ValueError) as excinfo: + relationship = stix2.Relationship() + assert "Missing required field for Relationship: 'relationship_type'." in str(excinfo) + + +def test_relationship_required_field_source_ref(): + with pytest.raises(ValueError) as excinfo: + # relationship_type is checked first, so make sure that is provided + relationship = stix2.Relationship(relationship_type='indicates') + assert "Missing required field for Relationship: 'source_ref'." in str(excinfo) + + +def test_relationship_required_field_target_ref(): + with pytest.raises(ValueError) as excinfo: + # relationship_type and source_ref are checked first, so make sure those are provided + relationship = stix2.Relationship( + relationship_type='indicates', + source_ref='indicator--01234567-89ab-cdef-0123-456789abcdef' + ) + assert "Missing required field for Relationship: 'target_ref'." in str(excinfo) + + +def test_cannot_assign_to_relationship_attributes(relationship): + with pytest.raises(ValueError) as excinfo: + relationship.relationship_type = "derived-from" + + assert "Cannot modify properties after creation." in str(excinfo) + + +def test_invalid_kwarg_to_relationship(): + with pytest.raises(TypeError) as excinfo: + relationship = stix2.Relationship(my_custom_property="foo", **RELATIONSHIP_KWARGS) + assert "unexpected keyword arguments: ['my_custom_property']" in str(excinfo) From e683acbf48151ed493837672a987d13470f0f3e8 Mon Sep 17 00:00:00 2001 From: Greg Back Date: Wed, 18 Jan 2017 11:21:46 -0800 Subject: [PATCH 17/46] Normalize IDs in tests. --- stix2/test/test_stix2.py | 44 +++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index 02e721d..23d9dd4 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -21,6 +21,10 @@ def test_timestamp_formatting(dt, timestamp): assert stix2.format_datetime(dt) == timestamp +INDICATOR_ID = "indicator--01234567-89ab-cdef-0123-456789abcdef" +MALWARE_ID = "malware--fedcba98-7654-3210-fedc-ba9876543210" +RELATIONSHIP_ID = "relationship--00000000-1111-2222-3333-444444444444" + # Minimum required args for an Indicator instance INDICATOR_KWARGS = dict( labels=['malicious-activity'], @@ -37,25 +41,19 @@ MALWARE_KWARGS = dict( # Minimum required args for a Relationship instance RELATIONSHIP_KWARGS = dict( relationship_type="indicates", - source_ref="indicator--01234567-89ab-cdef-0123-456789abcdef", - target_ref="malware--fedcba98-7654-3210-fedc-ba9876543210", + source_ref=INDICATOR_ID, + target_ref=MALWARE_ID, ) @pytest.fixture def indicator(): - return stix2.Indicator( - id="indicator--01234567-89ab-cdef-0123-456789abcdef", - **INDICATOR_KWARGS - ) + return stix2.Indicator(id=INDICATOR_ID, **INDICATOR_KWARGS) @pytest.fixture def malware(): - return stix2.Malware( - id="malware--fedcba98-7654-3210-fedc-ba9876543210", - **MALWARE_KWARGS - ) + return stix2.Malware(id=MALWARE_ID, **MALWARE_KWARGS) @pytest.fixture @@ -82,7 +80,7 @@ def test_indicator_with_all_required_fields(): indicator = stix2.Indicator( type="indicator", - id="indicator--01234567-89ab-cdef-0123-456789abcdef", + id=INDICATOR_ID, labels=['malicious-activity'], pattern="[file:hashes.MD5 = 'd41d8cd98f00b204e9800998ecf8427e']", created=now, @@ -153,7 +151,7 @@ def test_invalid_kwarg_to_indicator(): EXPECTED_MALWARE = """{ "created": "2016-05-12T08:17:27Z", - "id": "malware--0c7b5b88-8ff7-4a4d-aa9d-feb398cd0061", + "id": "malware--fedcba98-7654-3210-fedc-ba9876543210", "labels": [ "ransomware" ], @@ -168,7 +166,7 @@ def test_malware_with_all_required_fields(): malware = stix2.Malware( type="malware", - id="malware--0c7b5b88-8ff7-4a4d-aa9d-feb398cd0061", + id=MALWARE_ID, created=now, modified=now, labels=["ransomware"], @@ -236,7 +234,7 @@ def test_invalid_kwarg_to_malware(): EXPECTED_RELATIONSHIP = """{ "created": "2016-04-06T20:06:37Z", - "id": "relationship--44298a74-ba52-4f0c-87a3-1824e67d7fad", + "id": "relationship--00000000-1111-2222-3333-444444444444", "modified": "2016-04-06T20:06:37Z", "relationship_type": "indicates", "source_ref": "indicator--01234567-89ab-cdef-0123-456789abcdef", @@ -245,17 +243,17 @@ EXPECTED_RELATIONSHIP = """{ }""" -def test_relationship_all_required_fields(indicator, malware): +def test_relationship_all_required_fields(): now = datetime.datetime(2016, 4, 6, 20, 6, 37, tzinfo=pytz.utc) relationship = stix2.Relationship( type='relationship', - id='relationship--44298a74-ba52-4f0c-87a3-1824e67d7fad', + id=RELATIONSHIP_ID, created=now, modified=now, relationship_type='indicates', - source_ref=indicator.id, - target_ref=malware.id, + source_ref=INDICATOR_ID, + target_ref=MALWARE_ID, ) assert str(relationship) == EXPECTED_RELATIONSHIP @@ -266,16 +264,16 @@ def test_relationship_autogenerated_fields(relationship): assert relationship.created is not None assert relationship.modified is not None assert relationship.relationship_type == 'indicates' - assert relationship.source_ref == "indicator--01234567-89ab-cdef-0123-456789abcdef" - assert relationship.target_ref == "malware--fedcba98-7654-3210-fedc-ba9876543210" + assert relationship.source_ref == INDICATOR_ID + assert relationship.target_ref == MALWARE_ID assert relationship['type'] == 'relationship' assert relationship['id'].startswith('relationship--') assert relationship['created'] is not None assert relationship['modified'] is not None assert relationship['relationship_type'] == 'indicates' - assert relationship['source_ref'] == "indicator--01234567-89ab-cdef-0123-456789abcdef" - assert relationship['target_ref'] == "malware--fedcba98-7654-3210-fedc-ba9876543210" + assert relationship['source_ref'] == INDICATOR_ID + assert relationship['target_ref'] == MALWARE_ID def test_relationship_type_must_be_relationship(): @@ -310,7 +308,7 @@ def test_relationship_required_field_target_ref(): # relationship_type and source_ref are checked first, so make sure those are provided relationship = stix2.Relationship( relationship_type='indicates', - source_ref='indicator--01234567-89ab-cdef-0123-456789abcdef' + source_ref=INDICATOR_ID ) assert "Missing required field for Relationship: 'target_ref'." in str(excinfo) From fd548a5f41da0f46712a9fef268be7bc6f0ba9de Mon Sep 17 00:00:00 2001 From: Greg Back Date: Wed, 18 Jan 2017 15:14:22 -0800 Subject: [PATCH 18/46] Allow creating relationships from objects, not just IDs. --- stix2/__init__.py | 4 ++++ stix2/test/test_stix2.py | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/stix2/__init__.py b/stix2/__init__.py index f881f96..573e9f8 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -223,9 +223,13 @@ class Relationship(_STIXBase): if not kwargs.get('source_ref'): raise ValueError("Missing required field for Relationship: 'source_ref'.") + elif isinstance(kwargs['source_ref'], _STIXBase): + kwargs['source_ref'] = kwargs['source_ref'].id if not kwargs.get('target_ref'): raise ValueError("Missing required field for Relationship: 'target_ref'.") + elif isinstance(kwargs['target_ref'], _STIXBase): + kwargs['target_ref'] = kwargs['target_ref'].id extra_kwargs = list(set(kwargs.keys()) - set(self._properties)) if extra_kwargs: diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index 23d9dd4..e8c1e21 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -324,3 +324,15 @@ def test_invalid_kwarg_to_relationship(): with pytest.raises(TypeError) as excinfo: relationship = stix2.Relationship(my_custom_property="foo", **RELATIONSHIP_KWARGS) assert "unexpected keyword arguments: ['my_custom_property']" in str(excinfo) + + +def test_create_relationship_from_objects_rather_than_ids(indicator, malware): + relationship = stix2.Relationship( + relationship_type="indicates", + source_ref=indicator, + target_ref=malware, + ) + + assert relationship.relationship_type == 'indicates' + assert relationship.source_ref == INDICATOR_ID + assert relationship.target_ref == MALWARE_ID From 742d9645d6becdbfcbdede7a0eb062cba538b6c2 Mon Sep 17 00:00:00 2001 From: Greg Back Date: Wed, 18 Jan 2017 15:14:56 -0800 Subject: [PATCH 19/46] Allow shorter syntax for creating relationships. --- stix2/__init__.py | 10 +++++++++- stix2/test/test_stix2.py | 8 ++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/stix2/__init__.py b/stix2/__init__.py index 573e9f8..bf48a48 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -194,7 +194,8 @@ class Relationship(_STIXBase): 'target_ref', ] - def __init__(self, **kwargs): + def __init__(self, source_ref=None, relationship_type=None, target_ref=None, + **kwargs): # TODO: # - created_by_ref # - revoked @@ -204,6 +205,13 @@ class Relationship(_STIXBase): # - description + if source_ref and not kwargs.get('source_ref'): + kwargs['source_ref'] = source_ref + if relationship_type and not kwargs.get('relationship_type'): + kwargs['relationship_type'] = relationship_type + if target_ref and not kwargs.get('target_ref'): + kwargs['target_ref'] = target_ref + # TODO: do we care about the performance penalty of creating this # if we won't need it? now = datetime.now(tz=pytz.UTC) diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index e8c1e21..591be64 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -336,3 +336,11 @@ def test_create_relationship_from_objects_rather_than_ids(indicator, malware): assert relationship.relationship_type == 'indicates' assert relationship.source_ref == INDICATOR_ID assert relationship.target_ref == MALWARE_ID + + +def test_create_relationship_with_positional_args(indicator, malware): + relationship = stix2.Relationship(indicator, 'indicates', malware) + + assert relationship.relationship_type == 'indicates' + assert relationship.source_ref == INDICATOR_ID + assert relationship.target_ref == MALWARE_ID From d803d15addf910fa7abeefb2f537350039b83cd7 Mon Sep 17 00:00:00 2001 From: Greg Back Date: Wed, 18 Jan 2017 16:10:03 -0800 Subject: [PATCH 20/46] Add info on exporting STIX objects to README --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fdcd56e..bc6436c 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ STIX Bundles can be created by passing objects as arguments to the Bundle constructor. All required properties (`type`, `id`, and `spec_version`) will be set automatically if not provided, or can be provided as keyword arguments: -``` +```python from stix2 import bundle bundle = Bundle(indicator, malware, relationship) @@ -143,7 +143,13 @@ bundle = Bundle(indicator, malware, relationship) ### Serializing STIX objects -TBD +The string representation of all STIX classes is a valid STIX JSON object. + +```python +indicator = Indicator(...) + +print(str(indicator)) +``` ### Versioning From 4d9dcafbc62c97dd4a41d3320e27101243a46790 Mon Sep 17 00:00:00 2001 From: Greg Back Date: Wed, 18 Jan 2017 16:10:18 -0800 Subject: [PATCH 21/46] Small fixups --- stix2/__init__.py | 1 + stix2/test/test_stix2.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/stix2/__init__.py b/stix2/__init__.py index bf48a48..215030f 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -194,6 +194,7 @@ class Relationship(_STIXBase): 'target_ref', ] + # Explicitly define the first three kwargs to make readable Relationship declarations. def __init__(self, source_ref=None, relationship_type=None, target_ref=None, **kwargs): # TODO: diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index 591be64..82a7607 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -37,7 +37,6 @@ MALWARE_KWARGS = dict( name="Cryptolocker", ) - # Minimum required args for a Relationship instance RELATIONSHIP_KWARGS = dict( relationship_type="indicates", From 439211082a96ddb7387e0e0c2dbf5e26ad7b2654 Mon Sep 17 00:00:00 2001 From: Greg Back Date: Wed, 18 Jan 2017 16:14:57 -0800 Subject: [PATCH 22/46] Add custom clock fixture --- stix2/test/test_stix2.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index 82a7607..b96dfb4 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -9,6 +9,19 @@ import stix2 amsterdam = pytz.timezone('Europe/Amsterdam') eastern = pytz.timezone('US/Eastern') +FAKE_TIME = datetime.datetime(2017, 01, 01, 12, 34, 56) + + +# Inspired by: http://stackoverflow.com/a/24006251 +@pytest.fixture +def clock(monkeypatch): + + class mydatetime(datetime.datetime): + @classmethod + def now(cls): + return FAKE_TIME + + monkeypatch.setattr(datetime, 'datetime', mydatetime) @pytest.mark.parametrize('dt,timestamp', [ From 022f344b949fef977d24a9b22784c112ff545a13 Mon Sep 17 00:00:00 2001 From: Greg Back Date: Wed, 18 Jan 2017 16:32:52 -0800 Subject: [PATCH 23/46] Add UUID fixture --- stix2/test/test_stix2.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index b96dfb4..9319ed6 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -1,6 +1,7 @@ """Tests for the stix2 library""" import datetime +import uuid import pytest import pytz @@ -24,6 +25,29 @@ def clock(monkeypatch): monkeypatch.setattr(datetime, 'datetime', mydatetime) +@pytest.fixture +def uuid4(monkeypatch): + def wrapper(): + data = [0] + + def wrapped(): + data[0] += 1 + return "00000000-0000-0000-0000-00000000%04x" % data[0] + + return wrapped + monkeypatch.setattr(uuid, "uuid4", wrapper()) + + +def test_my_uuid4_fixture(uuid4): + assert uuid.uuid4() == "00000000-0000-0000-0000-000000000001" + assert uuid.uuid4() == "00000000-0000-0000-0000-000000000002" + assert uuid.uuid4() == "00000000-0000-0000-0000-000000000003" + for _ in range(256): + uuid.uuid4() + assert uuid.uuid4() == "00000000-0000-0000-0000-000000000104" + + + @pytest.mark.parametrize('dt,timestamp', [ (datetime.datetime(2017, 1, 1, tzinfo=pytz.utc), '2017-01-01T00:00:00Z'), (amsterdam.localize(datetime.datetime(2017, 1, 1)), '2016-12-31T23:00:00Z'), From e4e75e459bcda5d2a933dec414a7ea68d7ab7433 Mon Sep 17 00:00:00 2001 From: Greg Back Date: Wed, 18 Jan 2017 16:57:26 -0800 Subject: [PATCH 24/46] Update how fixtures work during testing. --- stix2/__init__.py | 8 ++--- stix2/test/test_stix2.py | 69 +++++++++++++++++++++------------------- 2 files changed, 41 insertions(+), 36 deletions(-) diff --git a/stix2/__init__.py b/stix2/__init__.py index 215030f..f284ee5 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -1,5 +1,5 @@ import collections -from datetime import datetime +import datetime import json import uuid @@ -72,7 +72,7 @@ class Indicator(_STIXBase): # TODO: do we care about the performance penalty of creating this # if we won't need it? - now = datetime.now(tz=pytz.UTC) + now = datetime.datetime.now(tz=pytz.UTC) if not kwargs.get('type'): kwargs['type'] = 'indicator' @@ -140,7 +140,7 @@ class Malware(_STIXBase): # TODO: do we care about the performance penalty of creating this # if we won't need it? - now = datetime.now(tz=pytz.UTC) + now = datetime.datetime.now(tz=pytz.UTC) if not kwargs.get('type'): kwargs['type'] = 'malware' @@ -215,7 +215,7 @@ class Relationship(_STIXBase): # TODO: do we care about the performance penalty of creating this # if we won't need it? - now = datetime.now(tz=pytz.UTC) + now = datetime.datetime.now(tz=pytz.UTC) if not kwargs.get('type'): kwargs['type'] = 'relationship' diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index 9319ed6..1230ed7 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -10,7 +10,7 @@ import stix2 amsterdam = pytz.timezone('Europe/Amsterdam') eastern = pytz.timezone('US/Eastern') -FAKE_TIME = datetime.datetime(2017, 01, 01, 12, 34, 56) +FAKE_TIME = datetime.datetime(2017, 01, 01, 12, 34, 56, tzinfo=pytz.utc) # Inspired by: http://stackoverflow.com/a/24006251 @@ -19,12 +19,16 @@ def clock(monkeypatch): class mydatetime(datetime.datetime): @classmethod - def now(cls): + def now(cls, tz=None): return FAKE_TIME monkeypatch.setattr(datetime, 'datetime', mydatetime) +def test_clock(clock): + assert datetime.datetime.now() == FAKE_TIME + + @pytest.fixture def uuid4(monkeypatch): def wrapper(): @@ -47,7 +51,6 @@ def test_my_uuid4_fixture(uuid4): assert uuid.uuid4() == "00000000-0000-0000-0000-000000000104" - @pytest.mark.parametrize('dt,timestamp', [ (datetime.datetime(2017, 1, 1, tzinfo=pytz.utc), '2017-01-01T00:00:00Z'), (amsterdam.localize(datetime.datetime(2017, 1, 1)), '2016-12-31T23:00:00Z'), @@ -83,17 +86,17 @@ RELATIONSHIP_KWARGS = dict( @pytest.fixture -def indicator(): - return stix2.Indicator(id=INDICATOR_ID, **INDICATOR_KWARGS) +def indicator(uuid4, clock): + return stix2.Indicator(**INDICATOR_KWARGS) @pytest.fixture -def malware(): - return stix2.Malware(id=MALWARE_ID, **MALWARE_KWARGS) +def malware(uuid4, clock): + return stix2.Malware(**MALWARE_KWARGS) @pytest.fixture -def relationship(): +def relationship(uuid4, clock): return stix2.Relationship(**RELATIONSHIP_KWARGS) @@ -129,20 +132,20 @@ def test_indicator_with_all_required_fields(): def test_indicator_autogenerated_fields(indicator): assert indicator.type == 'indicator' - assert indicator.id.startswith('indicator--') - assert indicator.created is not None - assert indicator.modified is not None + assert indicator.id == 'indicator--00000000-0000-0000-0000-000000000001' + assert indicator.created == FAKE_TIME + assert indicator.modified == FAKE_TIME assert indicator.labels == ['malicious-activity'] assert indicator.pattern == "[file:hashes.MD5 = 'd41d8cd98f00b204e9800998ecf8427e']" - assert indicator.valid_from is not None + assert indicator.valid_from == FAKE_TIME assert indicator['type'] == 'indicator' - assert indicator['id'].startswith('indicator--') - assert indicator['created'] is not None - assert indicator['modified'] is not None + assert indicator['id'] == 'indicator--00000000-0000-0000-0000-000000000001' + assert indicator['created'] == FAKE_TIME + assert indicator['modified'] == FAKE_TIME assert indicator['labels'] == ['malicious-activity'] assert indicator['pattern'] == "[file:hashes.MD5 = 'd41d8cd98f00b204e9800998ecf8427e']" - assert indicator['valid_from'] is not None + assert indicator['valid_from'] == FAKE_TIME def test_indicator_type_must_be_indicator(): @@ -214,16 +217,16 @@ def test_malware_with_all_required_fields(): def test_malware_autogenerated_fields(malware): assert malware.type == 'malware' - assert malware.id.startswith('malware--') - assert malware.created is not None - assert malware.modified is not None + assert malware.id == 'malware--00000000-0000-0000-0000-000000000001' + assert malware.created == FAKE_TIME + assert malware.modified == FAKE_TIME assert malware.labels == ['ransomware'] assert malware.name == "Cryptolocker" assert malware['type'] == 'malware' - assert malware['id'].startswith('malware--') - assert malware['created'] is not None - assert malware['modified'] is not None + assert malware['id'] == 'malware--00000000-0000-0000-0000-000000000001' + assert malware['created'] == FAKE_TIME + assert malware['modified'] == FAKE_TIME assert malware['labels'] == ['ransomware'] assert malware['name'] == "Cryptolocker" @@ -296,17 +299,17 @@ def test_relationship_all_required_fields(): def test_relationship_autogenerated_fields(relationship): assert relationship.type == 'relationship' - assert relationship.id.startswith('relationship--') - assert relationship.created is not None - assert relationship.modified is not None + assert relationship.id == 'relationship--00000000-0000-0000-0000-000000000001' + assert relationship.created == FAKE_TIME + assert relationship.modified == FAKE_TIME assert relationship.relationship_type == 'indicates' assert relationship.source_ref == INDICATOR_ID assert relationship.target_ref == MALWARE_ID assert relationship['type'] == 'relationship' - assert relationship['id'].startswith('relationship--') - assert relationship['created'] is not None - assert relationship['modified'] is not None + assert relationship['id'] == 'relationship--00000000-0000-0000-0000-000000000001' + assert relationship['created'] == FAKE_TIME + assert relationship['modified'] == FAKE_TIME assert relationship['relationship_type'] == 'indicates' assert relationship['source_ref'] == INDICATOR_ID assert relationship['target_ref'] == MALWARE_ID @@ -370,13 +373,15 @@ def test_create_relationship_from_objects_rather_than_ids(indicator, malware): ) assert relationship.relationship_type == 'indicates' - assert relationship.source_ref == INDICATOR_ID - assert relationship.target_ref == MALWARE_ID + assert relationship.source_ref == 'indicator--00000000-0000-0000-0000-000000000001' + assert relationship.target_ref == 'malware--00000000-0000-0000-0000-000000000002' + assert relationship.id == 'relationship--00000000-0000-0000-0000-000000000003' def test_create_relationship_with_positional_args(indicator, malware): relationship = stix2.Relationship(indicator, 'indicates', malware) assert relationship.relationship_type == 'indicates' - assert relationship.source_ref == INDICATOR_ID - assert relationship.target_ref == MALWARE_ID + assert relationship.source_ref == 'indicator--00000000-0000-0000-0000-000000000001' + assert relationship.target_ref == 'malware--00000000-0000-0000-0000-000000000002' + assert relationship.id == 'relationship--00000000-0000-0000-0000-000000000003' From 855ca929fae8fe5a075aba40c176cec6055edb6b Mon Sep 17 00:00:00 2001 From: Greg Back Date: Wed, 18 Jan 2017 16:58:25 -0800 Subject: [PATCH 25/46] Add initial Bundle implementation. --- stix2/__init__.py | 36 +++++++++++++++++++++ stix2/test/test_stix2.py | 69 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/stix2/__init__.py b/stix2/__init__.py index f284ee5..da775a9 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -45,6 +45,42 @@ class _STIXBase(collections.Mapping): separators=(",", ": ")) # Don't include spaces after commas. +class Bundle(_STIXBase): + + def __init__(self, type="bundle", id=None, spec_version="2.0", objects=None): + + if type != 'bundle': + raise ValueError("Bundle must have type='bundle'.") + + id = id or 'bundle--' + str(uuid.uuid4()) + if not id.startswith('bundle--'): + raise ValueError("Bundle id values must begin with 'bundle--'.") + + if spec_version != '2.0': + raise ValueError("Bundle must have spec_version='2.0'.") + + objects = objects or [] + + self._inner = { + 'type': type, + 'id': id, + 'spec_version': spec_version, + 'objects': objects, + } + + def _dict(self): + bundle = { + 'type': self['type'], + 'id': self['id'], + 'spec_version': self['spec_version'], + } + + if self.get('objects'): + bundle['objects'] = [x._dict() for x in self['objects']] + + return bundle + + class Indicator(_STIXBase): _properties = [ diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index 1230ed7..c216bc6 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -385,3 +385,72 @@ def test_create_relationship_with_positional_args(indicator, malware): assert relationship.source_ref == 'indicator--00000000-0000-0000-0000-000000000001' assert relationship.target_ref == 'malware--00000000-0000-0000-0000-000000000002' assert relationship.id == 'relationship--00000000-0000-0000-0000-000000000003' + + +EXPECTED_BUNDLE = """ +{ + "id": "bundle--624182ea-4c8f-5cd9-979d-3ae5f8a3ee56", + "objects": [ + { + "created": "2017-01-01T00:00:00Z", + "id": "indicator--01234567-89ab-cdef-0123-456789abcdef", + "labels": [ + "malicious-activity" + ], + "modified": "2017-01-01T00:00:00Z", + "pattern": "[file:hashes.MD5 = 'd41d8cd98f00b204e9800998ecf8427e']", + "type": "indicator", + "valid_from": "1970-01-01T00:00:00Z" + }, + { + "created": "2016-05-12T08:17:27Z", + "id": "malware--fedcba98-7654-3210-fedc-ba9876543210", + "labels": [ + "ransomware" + ], + "modified": "2016-05-12T08:17:27Z", + "name": "Cryptolocker", + "type": "malware" + }, + { + "created": "2016-04-06T20:06:37Z", + "id": "relationship--00000000-1111-2222-3333-444444444444", + "modified": "2016-04-06T20:06:37Z", + "relationship_type": "indicates", + "source_ref": "indicator--01234567-89ab-cdef-0123-456789abcdef", + "target_ref": "malware--fedcba98-7654-3210-fedc-ba9876543210", + "type": "relationship" + } + ], + "spec_version": "2.0", + "type": "bundle" +}""" + + +def test_empty_bundle(): + bundle = stix2.Bundle() + + assert bundle.type == "bundle" + assert bundle.id.startswith("bundle--") + assert bundle.spec_version == "2.0" + + +def test_bundle_with_wrong_type(): + with pytest.raises(ValueError) as excinfo: + bundle = stix2.Bundle(type="not-a-bundle") + + assert "Bundle must have type='bundle'." in str(excinfo) + + +def test_bundle_id_must_start_with_bundle(): + with pytest.raises(ValueError) as excinfo: + bundle = stix2.Bundle(id='my-prefix--') + + assert "Bundle id values must begin with 'bundle--'." in str(excinfo) + + +def test_bundle_with_wrong_spec_version(): + with pytest.raises(ValueError) as excinfo: + bundle = stix2.Bundle(spec_version="1.2") + + assert "Bundle must have spec_version='2.0'." in str(excinfo) From 15e9ff8da6efa1093933cade0261bc48645369e2 Mon Sep 17 00:00:00 2001 From: Greg Back Date: Wed, 18 Jan 2017 17:03:20 -0800 Subject: [PATCH 26/46] Make Bundle test repeatable --- stix2/test/test_stix2.py | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index c216bc6..95d0dcd 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -387,35 +387,34 @@ def test_create_relationship_with_positional_args(indicator, malware): assert relationship.id == 'relationship--00000000-0000-0000-0000-000000000003' -EXPECTED_BUNDLE = """ -{ - "id": "bundle--624182ea-4c8f-5cd9-979d-3ae5f8a3ee56", +EXPECTED_BUNDLE = """{ + "id": "bundle--00000000-0000-0000-0000-000000000004", "objects": [ { - "created": "2017-01-01T00:00:00Z", - "id": "indicator--01234567-89ab-cdef-0123-456789abcdef", + "created": "2017-01-01T12:34:56Z", + "id": "indicator--00000000-0000-0000-0000-000000000001", "labels": [ "malicious-activity" ], - "modified": "2017-01-01T00:00:00Z", + "modified": "2017-01-01T12:34:56Z", "pattern": "[file:hashes.MD5 = 'd41d8cd98f00b204e9800998ecf8427e']", "type": "indicator", - "valid_from": "1970-01-01T00:00:00Z" + "valid_from": "2017-01-01T12:34:56Z" }, { - "created": "2016-05-12T08:17:27Z", - "id": "malware--fedcba98-7654-3210-fedc-ba9876543210", + "created": "2017-01-01T12:34:56Z", + "id": "malware--00000000-0000-0000-0000-000000000002", "labels": [ "ransomware" ], - "modified": "2016-05-12T08:17:27Z", + "modified": "2017-01-01T12:34:56Z", "name": "Cryptolocker", "type": "malware" }, { - "created": "2016-04-06T20:06:37Z", - "id": "relationship--00000000-1111-2222-3333-444444444444", - "modified": "2016-04-06T20:06:37Z", + "created": "2017-01-01T12:34:56Z", + "id": "relationship--00000000-0000-0000-0000-000000000003", + "modified": "2017-01-01T12:34:56Z", "relationship_type": "indicates", "source_ref": "indicator--01234567-89ab-cdef-0123-456789abcdef", "target_ref": "malware--fedcba98-7654-3210-fedc-ba9876543210", @@ -454,3 +453,9 @@ def test_bundle_with_wrong_spec_version(): bundle = stix2.Bundle(spec_version="1.2") assert "Bundle must have spec_version='2.0'." in str(excinfo) + + +def test_create_bundle(indicator, malware, relationship): + bundle = stix2.Bundle(objects=[indicator, malware, relationship]) + + assert str(bundle) == EXPECTED_BUNDLE From 26ed0389ea114b92ec8310bc450c8331769033d2 Mon Sep 17 00:00:00 2001 From: Greg Back Date: Wed, 1 Feb 2017 12:50:43 -0600 Subject: [PATCH 27/46] Fix invalid numeric literals --- stix2/test/test_stix2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index 95d0dcd..511ee08 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -10,7 +10,7 @@ import stix2 amsterdam = pytz.timezone('Europe/Amsterdam') eastern = pytz.timezone('US/Eastern') -FAKE_TIME = datetime.datetime(2017, 01, 01, 12, 34, 56, tzinfo=pytz.utc) +FAKE_TIME = datetime.datetime(2017, 1, 1, 12, 34, 56, tzinfo=pytz.utc) # Inspired by: http://stackoverflow.com/a/24006251 From 8843e9b19039bf525f5b5d4e3c9b6d5205b7286c Mon Sep 17 00:00:00 2001 From: Greg Back Date: Wed, 1 Feb 2017 13:27:24 -0600 Subject: [PATCH 28/46] WIP: refactor common fields. --- stix2/__init__.py | 84 +++++++++++++++++++++++++++++++---------------- 1 file changed, 56 insertions(+), 28 deletions(-) diff --git a/stix2/__init__.py b/stix2/__init__.py index da775a9..26e3c1f 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -21,6 +21,31 @@ def format_datetime(dt): class _STIXBase(collections.Mapping): """Base class for STIX object types""" + def _check_kwargs(self, **kwargs): + class_name = self.__class__.__name__ + + # Ensure that, if provided, the 'type' kwarg is correct. + required_type = self.__class__._type + if not kwargs.get('type'): + kwargs['type'] = required_type + if kwargs['type'] != required_type: + msg = "{0} must have type='{1}'." + raise ValueError(msg.format(class_name, required_type)) + + return kwargs + + def __init__(self, **kwargs): + # Detect any keyword arguments not allowed for a specific type + extra_kwargs = list(set(kwargs) - set(self.__class__._properties)) + if extra_kwargs: + raise TypeError("unexpected keyword arguments: " + str(extra_kwargs)) + + # TODO: move all of this back into init, once we check the right things + # in the right order. + self._check_kwargs(**kwargs) + + self._inner = kwargs + def __getitem__(self, key): return self._inner[key] @@ -47,11 +72,15 @@ class _STIXBase(collections.Mapping): class Bundle(_STIXBase): + _type = 'bundle' + _properties = [ + 'type', + 'id', + 'spec_version', + 'objects', + ] + def __init__(self, type="bundle", id=None, spec_version="2.0", objects=None): - - if type != 'bundle': - raise ValueError("Bundle must have type='bundle'.") - id = id or 'bundle--' + str(uuid.uuid4()) if not id.startswith('bundle--'): raise ValueError("Bundle id values must begin with 'bundle--'.") @@ -61,12 +90,13 @@ class Bundle(_STIXBase): objects = objects or [] - self._inner = { + kwargs = { 'type': type, 'id': id, 'spec_version': spec_version, 'objects': objects, } + super(Bundle, self).__init__(**kwargs) def _dict(self): bundle = { @@ -83,6 +113,7 @@ class Bundle(_STIXBase): class Indicator(_STIXBase): + _type = 'indicator' _properties = [ 'type', 'id', @@ -110,10 +141,8 @@ class Indicator(_STIXBase): # if we won't need it? now = datetime.datetime.now(tz=pytz.UTC) - if not kwargs.get('type'): - kwargs['type'] = 'indicator' - if kwargs['type'] != 'indicator': - raise ValueError("Indicator must have type='indicator'.") + # TODO: remove once we check all the fields in the right order + kwargs = self._check_kwargs(**kwargs) if not kwargs.get('id'): kwargs['id'] = 'indicator--' + str(uuid.uuid4()) @@ -126,19 +155,16 @@ class Indicator(_STIXBase): if not kwargs.get('pattern'): raise ValueError("Missing required field for Indicator: 'pattern'.") - extra_kwargs = list(set(kwargs.keys()) - set(self._properties)) - if extra_kwargs: - raise TypeError("unexpected keyword arguments: " + str(extra_kwargs)) - - self._inner = { - 'type': kwargs['type'], + kwargs.update({ + # 'type': kwargs['type'], 'id': kwargs['id'], 'created': kwargs.get('created', now), 'modified': kwargs.get('modified', now), 'labels': kwargs['labels'], 'pattern': kwargs['pattern'], 'valid_from': kwargs.get('valid_from', now), - } + }) + super(Indicator, self).__init__(**kwargs) def _dict(self): return { @@ -154,6 +180,7 @@ class Indicator(_STIXBase): class Malware(_STIXBase): + _type = 'malware' _properties = [ 'type', 'id', @@ -178,6 +205,9 @@ class Malware(_STIXBase): # if we won't need it? now = datetime.datetime.now(tz=pytz.UTC) + # TODO: remove once we check all the fields in the right order + kwargs = self._check_kwargs(**kwargs) + if not kwargs.get('type'): kwargs['type'] = 'malware' if kwargs['type'] != 'malware': @@ -194,18 +224,15 @@ class Malware(_STIXBase): if not kwargs.get('name'): raise ValueError("Missing required field for Malware: 'name'.") - extra_kwargs = list(set(kwargs.keys()) - set(self._properties)) - if extra_kwargs: - raise TypeError("unexpected keyword arguments: " + str(extra_kwargs)) - - self._inner = { + kwargs.update({ 'type': kwargs['type'], 'id': kwargs['id'], 'created': kwargs.get('created', now), 'modified': kwargs.get('modified', now), 'labels': kwargs['labels'], 'name': kwargs['name'], - } + }) + super(Malware, self).__init__(**kwargs) def _dict(self): return { @@ -220,6 +247,7 @@ class Malware(_STIXBase): class Relationship(_STIXBase): + _type = 'relationship' _properties = [ 'type', 'id', @@ -242,6 +270,9 @@ class Relationship(_STIXBase): # - description + # TODO: remove once we check all the fields in the right order + kwargs = self._check_kwargs(**kwargs) + if source_ref and not kwargs.get('source_ref'): kwargs['source_ref'] = source_ref if relationship_type and not kwargs.get('relationship_type'): @@ -276,11 +307,7 @@ class Relationship(_STIXBase): elif isinstance(kwargs['target_ref'], _STIXBase): kwargs['target_ref'] = kwargs['target_ref'].id - extra_kwargs = list(set(kwargs.keys()) - set(self._properties)) - if extra_kwargs: - raise TypeError("unexpected keyword arguments: " + str(extra_kwargs)) - - self._inner = { + kwargs.update({ 'type': kwargs['type'], 'id': kwargs['id'], 'created': kwargs.get('created', now), @@ -288,7 +315,8 @@ class Relationship(_STIXBase): 'relationship_type': kwargs['relationship_type'], 'source_ref': kwargs['source_ref'], 'target_ref': kwargs['target_ref'], - } + }) + super(Relationship, self).__init__(**kwargs) def _dict(self): return { From b5ab54b6a9d4c31a418ff26db849a19aa8757dbd Mon Sep 17 00:00:00 2001 From: Greg Back Date: Wed, 1 Feb 2017 13:34:44 -0600 Subject: [PATCH 29/46] WIP: convert bundle to using kwargs. --- stix2/__init__.py | 34 ++++++++++------------------------ 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/stix2/__init__.py b/stix2/__init__.py index 26e3c1f..d022302 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -80,22 +80,20 @@ class Bundle(_STIXBase): 'objects', ] - def __init__(self, type="bundle", id=None, spec_version="2.0", objects=None): - id = id or 'bundle--' + str(uuid.uuid4()) - if not id.startswith('bundle--'): + def __init__(self, **kwargs): + # TODO: remove once we check all the fields in the right order + kwargs = self._check_kwargs(**kwargs) + + if not kwargs.get('id'): + kwargs['id'] = 'bundle--' + str(uuid.uuid4()) + if not kwargs['id'].startswith('bundle--'): raise ValueError("Bundle id values must begin with 'bundle--'.") - if spec_version != '2.0': + if not kwargs.get('spec_version'): + kwargs['spec_version'] = '2.0' + if kwargs['spec_version'] != '2.0': raise ValueError("Bundle must have spec_version='2.0'.") - objects = objects or [] - - kwargs = { - 'type': type, - 'id': id, - 'spec_version': spec_version, - 'objects': objects, - } super(Bundle, self).__init__(**kwargs) def _dict(self): @@ -156,12 +154,8 @@ class Indicator(_STIXBase): raise ValueError("Missing required field for Indicator: 'pattern'.") kwargs.update({ - # 'type': kwargs['type'], - 'id': kwargs['id'], 'created': kwargs.get('created', now), 'modified': kwargs.get('modified', now), - 'labels': kwargs['labels'], - 'pattern': kwargs['pattern'], 'valid_from': kwargs.get('valid_from', now), }) super(Indicator, self).__init__(**kwargs) @@ -225,12 +219,8 @@ class Malware(_STIXBase): raise ValueError("Missing required field for Malware: 'name'.") kwargs.update({ - 'type': kwargs['type'], - 'id': kwargs['id'], 'created': kwargs.get('created', now), 'modified': kwargs.get('modified', now), - 'labels': kwargs['labels'], - 'name': kwargs['name'], }) super(Malware, self).__init__(**kwargs) @@ -308,13 +298,9 @@ class Relationship(_STIXBase): kwargs['target_ref'] = kwargs['target_ref'].id kwargs.update({ - 'type': kwargs['type'], - 'id': kwargs['id'], 'created': kwargs.get('created', now), 'modified': kwargs.get('modified', now), 'relationship_type': kwargs['relationship_type'], - 'source_ref': kwargs['source_ref'], - 'target_ref': kwargs['target_ref'], }) super(Relationship, self).__init__(**kwargs) From b4eb6c1fd1eb081849519a5c15330f8502cffcdf Mon Sep 17 00:00:00 2001 From: Greg Back Date: Wed, 1 Feb 2017 13:44:57 -0600 Subject: [PATCH 30/46] Refactor common ID check. --- stix2/__init__.py | 44 ++++++++++++++++---------------------------- 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/stix2/__init__.py b/stix2/__init__.py index d022302..9b7e1c3 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -21,17 +21,29 @@ def format_datetime(dt): class _STIXBase(collections.Mapping): """Base class for STIX object types""" - def _check_kwargs(self, **kwargs): - class_name = self.__class__.__name__ + @classmethod + def _make_id(cls): + return cls._type + "--" + str(uuid.uuid4()) + + @classmethod + def _check_kwargs(cls, **kwargs): + class_name = cls.__name__ # Ensure that, if provided, the 'type' kwarg is correct. - required_type = self.__class__._type + required_type = cls._type if not kwargs.get('type'): kwargs['type'] = required_type if kwargs['type'] != required_type: msg = "{0} must have type='{1}'." raise ValueError(msg.format(class_name, required_type)) + id_prefix = cls._type + "--" + if not kwargs.get('id'): + kwargs['id'] = cls._make_id() + if not kwargs['id'].startswith(id_prefix): + msg = "{0} id values must begin with '{1}'." + raise ValueError(msg.format(class_name, id_prefix)) + return kwargs def __init__(self, **kwargs): @@ -84,11 +96,6 @@ class Bundle(_STIXBase): # TODO: remove once we check all the fields in the right order kwargs = self._check_kwargs(**kwargs) - if not kwargs.get('id'): - kwargs['id'] = 'bundle--' + str(uuid.uuid4()) - if not kwargs['id'].startswith('bundle--'): - raise ValueError("Bundle id values must begin with 'bundle--'.") - if not kwargs.get('spec_version'): kwargs['spec_version'] = '2.0' if kwargs['spec_version'] != '2.0': @@ -142,11 +149,6 @@ class Indicator(_STIXBase): # TODO: remove once we check all the fields in the right order kwargs = self._check_kwargs(**kwargs) - if not kwargs.get('id'): - kwargs['id'] = 'indicator--' + str(uuid.uuid4()) - if not kwargs['id'].startswith('indicator--'): - raise ValueError("Indicator id values must begin with 'indicator--'.") - if not kwargs.get('labels'): raise ValueError("Missing required field for Indicator: 'labels'.") @@ -202,16 +204,6 @@ class Malware(_STIXBase): # TODO: remove once we check all the fields in the right order kwargs = self._check_kwargs(**kwargs) - if not kwargs.get('type'): - kwargs['type'] = 'malware' - if kwargs['type'] != 'malware': - raise ValueError("Malware must have type='malware'.") - - if not kwargs.get('id'): - kwargs['id'] = 'malware--' + str(uuid.uuid4()) - if not kwargs['id'].startswith('malware--'): - raise ValueError("Malware id values must begin with 'malware--'.") - if not kwargs.get('labels'): raise ValueError("Missing required field for Malware: 'labels'.") @@ -274,11 +266,6 @@ class Relationship(_STIXBase): # if we won't need it? now = datetime.datetime.now(tz=pytz.UTC) - if not kwargs.get('type'): - kwargs['type'] = 'relationship' - if kwargs['type'] != 'relationship': - raise ValueError("Relationship must have type='relationship'.") - if not kwargs.get('id'): kwargs['id'] = 'relationship--' + str(uuid.uuid4()) if not kwargs['id'].startswith('relationship--'): @@ -301,6 +288,7 @@ class Relationship(_STIXBase): 'created': kwargs.get('created', now), 'modified': kwargs.get('modified', now), 'relationship_type': kwargs['relationship_type'], + 'target_ref': kwargs['target_ref'], }) super(Relationship, self).__init__(**kwargs) From ce31356839cc38affa174d7a25528881407e8cc6 Mon Sep 17 00:00:00 2001 From: Greg Back Date: Wed, 1 Feb 2017 14:35:41 -0600 Subject: [PATCH 31/46] start of automated property checking. --- stix2/__init__.py | 90 +++++++++++++++++++++++++++-------------------- 1 file changed, 51 insertions(+), 39 deletions(-) diff --git a/stix2/__init__.py b/stix2/__init__.py index 9b7e1c3..ce974b6 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -6,6 +6,18 @@ import uuid import pytz +COMMON_PROPERTIES = { + 'type': { + 'default': (lambda x: x._type), + 'validate': (lambda x, val: val == x._type), + 'error_msg': "{type} must have {field}='{expected}'.", + }, + 'id': {}, + 'created': {}, + 'modified': {}, +} + + def format_datetime(dt): # TODO: how to handle naive datetime @@ -29,13 +41,18 @@ class _STIXBase(collections.Mapping): def _check_kwargs(cls, **kwargs): class_name = cls.__name__ - # Ensure that, if provided, the 'type' kwarg is correct. - required_type = cls._type - if not kwargs.get('type'): - kwargs['type'] = required_type - if kwargs['type'] != required_type: - msg = "{0} must have type='{1}'." - raise ValueError(msg.format(class_name, required_type)) + for prop_name, prop_metadata in cls._properties.items(): + if prop_metadata.get('default') and prop_name not in kwargs: + kwargs[prop_name] = prop_metadata['default'](cls) + + if prop_metadata.get('validate'): + if not prop_metadata['validate'](cls, kwargs[prop_name]): + msg = prop_metadata['error_msg'].format( + type=class_name, + field=prop_name, + expected=prop_metadata.get('default')(cls), + ) + raise ValueError(msg) id_prefix = cls._type + "--" if not kwargs.get('id'): @@ -85,12 +102,16 @@ class _STIXBase(collections.Mapping): class Bundle(_STIXBase): _type = 'bundle' - _properties = [ - 'type', - 'id', - 'spec_version', - 'objects', - ] + _properties = { + 'type': { + 'default': (lambda x: x._type), + 'validate': (lambda x, val: val == x._type), + 'error_msg': "{type} must have {field}='{expected}'.", + }, + 'id': {}, + 'spec_version': {}, + 'objects': {}, + } def __init__(self, **kwargs): # TODO: remove once we check all the fields in the right order @@ -119,15 +140,12 @@ class Bundle(_STIXBase): class Indicator(_STIXBase): _type = 'indicator' - _properties = [ - 'type', - 'id', - 'created', - 'modified', - 'labels', - 'pattern', - 'valid_from', - ] + _properties = COMMON_PROPERTIES.copy() + _properties.update({ + 'labels': {}, + 'pattern': {}, + 'valid_from': {}, + }) def __init__(self, **kwargs): # TODO: @@ -177,14 +195,11 @@ class Indicator(_STIXBase): class Malware(_STIXBase): _type = 'malware' - _properties = [ - 'type', - 'id', - 'created', - 'modified', - 'labels', - 'name', - ] + _properties = COMMON_PROPERTIES.copy() + _properties.update({ + 'labels': {}, + 'name': {}, + }) def __init__(self, **kwargs): # TODO: @@ -230,15 +245,12 @@ class Malware(_STIXBase): class Relationship(_STIXBase): _type = 'relationship' - _properties = [ - 'type', - 'id', - 'created', - 'modified', - 'relationship_type', - 'source_ref', - 'target_ref', - ] + _properties = COMMON_PROPERTIES.copy() + _properties.update({ + 'relationship_type': {}, + 'source_ref': {}, + 'target_ref': {}, + }) # Explicitly define the first three kwargs to make readable Relationship declarations. def __init__(self, source_ref=None, relationship_type=None, target_ref=None, From 58fccd7f7d0898f3c5b0c4a1c650c4c3289c023f Mon Sep 17 00:00:00 2001 From: Greg Back Date: Wed, 1 Feb 2017 14:51:59 -0600 Subject: [PATCH 32/46] Further refactoring bundle. --- stix2/__init__.py | 48 ++++++++++++++++++++++++++--------------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/stix2/__init__.py b/stix2/__init__.py index ce974b6..52c549d 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -5,12 +5,11 @@ import uuid import pytz - +DEFAULT_ERROR = "{type} must have {field}='{expected}'." COMMON_PROPERTIES = { 'type': { 'default': (lambda x: x._type), - 'validate': (lambda x, val: val == x._type), - 'error_msg': "{type} must have {field}='{expected}'.", + 'validate': (lambda x, val: val == x._type) }, 'id': {}, 'created': {}, @@ -42,15 +41,26 @@ class _STIXBase(collections.Mapping): class_name = cls.__name__ for prop_name, prop_metadata in cls._properties.items(): - if prop_metadata.get('default') and prop_name not in kwargs: - kwargs[prop_name] = prop_metadata['default'](cls) + if prop_name not in kwargs: + if prop_metadata.get('default'): + kwargs[prop_name] = prop_metadata['default'](cls) + elif prop_metadata.get('fixed'): + kwargs[prop_name] = prop_metadata['fixed'] if prop_metadata.get('validate'): if not prop_metadata['validate'](cls, kwargs[prop_name]): - msg = prop_metadata['error_msg'].format( + msg = prop_metadata.get('error_msg', DEFAULT_ERROR).format( type=class_name, field=prop_name, - expected=prop_metadata.get('default')(cls), + expected=prop_metadata['default'](cls), + ) + raise ValueError(msg) + elif prop_metadata.get('fixed'): + if kwargs[prop_name] != prop_metadata['fixed']: + msg = prop_metadata.get('error_msg', DEFAULT_ERROR).format( + type=class_name, + field=prop_name, + expected=prop_metadata['fixed'] ) raise ValueError(msg) @@ -64,15 +74,15 @@ class _STIXBase(collections.Mapping): return kwargs def __init__(self, **kwargs): + # TODO: move all of this back into init, once we check the right things + # in the right order, or move after the unexpected check. + kwargs = self._check_kwargs(**kwargs) + # Detect any keyword arguments not allowed for a specific type extra_kwargs = list(set(kwargs) - set(self.__class__._properties)) if extra_kwargs: raise TypeError("unexpected keyword arguments: " + str(extra_kwargs)) - # TODO: move all of this back into init, once we check the right things - # in the right order. - self._check_kwargs(**kwargs) - self._inner = kwargs def __getitem__(self, key): @@ -105,22 +115,18 @@ class Bundle(_STIXBase): _properties = { 'type': { 'default': (lambda x: x._type), - 'validate': (lambda x, val: val == x._type), - 'error_msg': "{type} must have {field}='{expected}'.", + 'validate': (lambda x, val: val == x._type) }, 'id': {}, - 'spec_version': {}, + 'spec_version': { + 'fixed': "2.0", + }, 'objects': {}, } def __init__(self, **kwargs): - # TODO: remove once we check all the fields in the right order - kwargs = self._check_kwargs(**kwargs) - - if not kwargs.get('spec_version'): - kwargs['spec_version'] = '2.0' - if kwargs['spec_version'] != '2.0': - raise ValueError("Bundle must have spec_version='2.0'.") + # TODO: Allow variable number of arguments to pass "objects" to the + # Bundle constructor super(Bundle, self).__init__(**kwargs) From 2a1709a7de707a9cfc21221d0799d35502c81355 Mon Sep 17 00:00:00 2001 From: Greg Back Date: Wed, 1 Feb 2017 14:57:07 -0600 Subject: [PATCH 33/46] Allow passing objects to Bundle as args --- stix2/__init__.py | 7 ++++--- stix2/test/test_stix2.py | 6 ++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/stix2/__init__.py b/stix2/__init__.py index 52c549d..82b8989 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -124,9 +124,10 @@ class Bundle(_STIXBase): 'objects': {}, } - def __init__(self, **kwargs): - # TODO: Allow variable number of arguments to pass "objects" to the - # Bundle constructor + def __init__(self, *args, **kwargs): + # Add any positional arguments to the 'objects' kwarg. + if args: + kwargs['objects'] = kwargs.get('objects', []) + list(args) super(Bundle, self).__init__(**kwargs) diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index 511ee08..4bca1bb 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -459,3 +459,9 @@ def test_create_bundle(indicator, malware, relationship): bundle = stix2.Bundle(objects=[indicator, malware, relationship]) assert str(bundle) == EXPECTED_BUNDLE + + +def test_create_bundle_with_positional_args(indicator, malware, relationship): + bundle = stix2.Bundle(indicator, malware, relationship) + + assert str(bundle) == EXPECTED_BUNDLE From 724774900d400f01770406ad71cff158cf4f72ed Mon Sep 17 00:00:00 2001 From: Greg Back Date: Wed, 1 Feb 2017 16:04:20 -0600 Subject: [PATCH 34/46] Generic form of JSON serialization --- stix2/__init__.py | 58 +++++++++++------------------------------------ 1 file changed, 13 insertions(+), 45 deletions(-) diff --git a/stix2/__init__.py b/stix2/__init__.py index 82b8989..9f8c53b 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -29,6 +29,17 @@ def format_datetime(dt): return dt.astimezone(pytz.utc).isoformat()[:-6] + "Z" +class STIXJSONEncoder(json.JSONEncoder): + + def default(self, obj): + if isinstance(obj, (datetime.date, datetime.datetime)): + return format_datetime(obj) + elif isinstance(obj, _STIXBase): + return dict(obj) + else: + return super(STIXJSONEncoder, self).default(obj) + + class _STIXBase(collections.Mapping): """Base class for STIX object types""" @@ -105,7 +116,7 @@ class _STIXBase(collections.Mapping): def __str__(self): # TODO: put keys in specific order. Probably need custom JSON encoder. - return json.dumps(self._dict(), indent=4, sort_keys=True, + return json.dumps(self, indent=4, sort_keys=True, cls=STIXJSONEncoder, separators=(",", ": ")) # Don't include spaces after commas. @@ -131,18 +142,6 @@ class Bundle(_STIXBase): super(Bundle, self).__init__(**kwargs) - def _dict(self): - bundle = { - 'type': self['type'], - 'id': self['id'], - 'spec_version': self['spec_version'], - } - - if self.get('objects'): - bundle['objects'] = [x._dict() for x in self['objects']] - - return bundle - class Indicator(_STIXBase): @@ -187,17 +186,6 @@ class Indicator(_STIXBase): }) super(Indicator, self).__init__(**kwargs) - def _dict(self): - return { - 'type': self['type'], - 'id': self['id'], - 'created': format_datetime(self['created']), - 'modified': format_datetime(self['modified']), - 'labels': self['labels'], - 'pattern': self['pattern'], - 'valid_from': format_datetime(self['valid_from']), - } - class Malware(_STIXBase): @@ -238,16 +226,6 @@ class Malware(_STIXBase): }) super(Malware, self).__init__(**kwargs) - def _dict(self): - return { - 'type': self['type'], - 'id': self['id'], - 'created': format_datetime(self['created']), - 'modified': format_datetime(self['modified']), - 'labels': self['labels'], - 'name': self['name'], - } - class Relationship(_STIXBase): @@ -309,15 +287,5 @@ class Relationship(_STIXBase): 'relationship_type': kwargs['relationship_type'], 'target_ref': kwargs['target_ref'], }) - super(Relationship, self).__init__(**kwargs) - def _dict(self): - return { - 'type': self['type'], - 'id': self['id'], - 'created': format_datetime(self['created']), - 'modified': format_datetime(self['modified']), - 'relationship_type': self['relationship_type'], - 'source_ref': self['source_ref'], - 'target_ref': self['target_ref'], - } + super(Relationship, self).__init__(**kwargs) From e677167cb45fe4f86321472127dbd95a624d9a47 Mon Sep 17 00:00:00 2001 From: Greg Back Date: Thu, 2 Feb 2017 08:13:48 -0600 Subject: [PATCH 35/46] Refine tests. --- stix2/test/test_stix2.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index 4bca1bb..a748acd 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -188,6 +188,13 @@ def test_invalid_kwarg_to_indicator(): assert "unexpected keyword arguments: ['my_custom_property']" in str(excinfo) +def test_created_modified_time_are_identical_by_default(): + """By default, the created and modified times should be the same.""" + indicator = stix2.Indicator(**INDICATOR_KWARGS) + + assert indicator.created == indicator.modified + + EXPECTED_MALWARE = """{ "created": "2016-05-12T08:17:27Z", "id": "malware--fedcba98-7654-3210-fedc-ba9876543210", @@ -432,6 +439,7 @@ def test_empty_bundle(): assert bundle.type == "bundle" assert bundle.id.startswith("bundle--") assert bundle.spec_version == "2.0" + assert bundle.objects is None def test_bundle_with_wrong_type(): From 67c331167294a44ab4583f7a0762d46ab98a675c Mon Sep 17 00:00:00 2001 From: Greg Back Date: Thu, 2 Feb 2017 08:33:36 -0600 Subject: [PATCH 36/46] Handle ID fields in a generic way. --- stix2/__init__.py | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/stix2/__init__.py b/stix2/__init__.py index 9f8c53b..7458f1e 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -11,7 +11,12 @@ COMMON_PROPERTIES = { 'default': (lambda x: x._type), 'validate': (lambda x, val: val == x._type) }, - 'id': {}, + 'id': { + 'default': (lambda x: x._make_id()), + 'validate': (lambda x, val: val.startswith(x._type + "--")), + 'expected': (lambda x: x._type + "--"), + 'error_msg': "{type} {field} values must begin with '{expected}'." + }, 'created': {}, 'modified': {}, } @@ -63,7 +68,8 @@ class _STIXBase(collections.Mapping): msg = prop_metadata.get('error_msg', DEFAULT_ERROR).format( type=class_name, field=prop_name, - expected=prop_metadata['default'](cls), + expected=prop_metadata.get('expected', + prop_metadata['default'])(cls), ) raise ValueError(msg) elif prop_metadata.get('fixed'): @@ -75,13 +81,6 @@ class _STIXBase(collections.Mapping): ) raise ValueError(msg) - id_prefix = cls._type + "--" - if not kwargs.get('id'): - kwargs['id'] = cls._make_id() - if not kwargs['id'].startswith(id_prefix): - msg = "{0} id values must begin with '{1}'." - raise ValueError(msg.format(class_name, id_prefix)) - return kwargs def __init__(self, **kwargs): @@ -124,11 +123,9 @@ class Bundle(_STIXBase): _type = 'bundle' _properties = { - 'type': { - 'default': (lambda x: x._type), - 'validate': (lambda x, val: val == x._type) - }, - 'id': {}, + # Borrow the 'type' and 'id' definitions + 'type': COMMON_PROPERTIES['type'], + 'id': COMMON_PROPERTIES['id'], 'spec_version': { 'fixed': "2.0", }, @@ -263,11 +260,6 @@ class Relationship(_STIXBase): # if we won't need it? now = datetime.datetime.now(tz=pytz.UTC) - if not kwargs.get('id'): - kwargs['id'] = 'relationship--' + str(uuid.uuid4()) - if not kwargs['id'].startswith('relationship--'): - raise ValueError("Relationship id values must begin with 'relationship--'.") - if not kwargs.get('relationship_type'): raise ValueError("Missing required field for Relationship: 'relationship_type'.") From 675a29dbfba52328550973a05f9d012cb302c784 Mon Sep 17 00:00:00 2001 From: Greg Back Date: Thu, 2 Feb 2017 08:53:46 -0600 Subject: [PATCH 37/46] Add support for required fields with no default values. --- stix2/__init__.py | 66 +++++++++++++++++++++++------------------------ 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/stix2/__init__.py b/stix2/__init__.py index 7458f1e..ca7739f 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -58,6 +58,10 @@ class _STIXBase(collections.Mapping): for prop_name, prop_metadata in cls._properties.items(): if prop_name not in kwargs: + if prop_metadata.get('required'): + msg = "Missing required field for {type}: '{field}'." + raise ValueError(msg.format(type=class_name, + field=prop_name)) if prop_metadata.get('default'): kwargs[prop_name] = prop_metadata['default'](cls) elif prop_metadata.get('fixed'): @@ -145,8 +149,12 @@ class Indicator(_STIXBase): _type = 'indicator' _properties = COMMON_PROPERTIES.copy() _properties.update({ - 'labels': {}, - 'pattern': {}, + 'labels': { + 'required': True, + }, + 'pattern': { + 'required': True, + }, 'valid_from': {}, }) @@ -170,12 +178,6 @@ class Indicator(_STIXBase): # TODO: remove once we check all the fields in the right order kwargs = self._check_kwargs(**kwargs) - if not kwargs.get('labels'): - raise ValueError("Missing required field for Indicator: 'labels'.") - - if not kwargs.get('pattern'): - raise ValueError("Missing required field for Indicator: 'pattern'.") - kwargs.update({ 'created': kwargs.get('created', now), 'modified': kwargs.get('modified', now), @@ -189,8 +191,12 @@ class Malware(_STIXBase): _type = 'malware' _properties = COMMON_PROPERTIES.copy() _properties.update({ - 'labels': {}, - 'name': {}, + 'labels': { + 'required': True, + }, + 'name': { + 'required': True, + }, }) def __init__(self, **kwargs): @@ -211,12 +217,6 @@ class Malware(_STIXBase): # TODO: remove once we check all the fields in the right order kwargs = self._check_kwargs(**kwargs) - if not kwargs.get('labels'): - raise ValueError("Missing required field for Malware: 'labels'.") - - if not kwargs.get('name'): - raise ValueError("Missing required field for Malware: 'name'.") - kwargs.update({ 'created': kwargs.get('created', now), 'modified': kwargs.get('modified', now), @@ -229,9 +229,15 @@ class Relationship(_STIXBase): _type = 'relationship' _properties = COMMON_PROPERTIES.copy() _properties.update({ - 'relationship_type': {}, - 'source_ref': {}, - 'target_ref': {}, + 'relationship_type': { + 'required': True, + }, + 'source_ref': { + 'required': True, + }, + 'target_ref': { + 'required': True, + }, }) # Explicitly define the first three kwargs to make readable Relationship declarations. @@ -246,9 +252,7 @@ class Relationship(_STIXBase): # - description - # TODO: remove once we check all the fields in the right order - kwargs = self._check_kwargs(**kwargs) - + # Allow (source_ref, relationship_type, target_ref) as positional args. if source_ref and not kwargs.get('source_ref'): kwargs['source_ref'] = source_ref if relationship_type and not kwargs.get('relationship_type'): @@ -256,28 +260,24 @@ class Relationship(_STIXBase): if target_ref and not kwargs.get('target_ref'): kwargs['target_ref'] = target_ref + # TODO: remove once we check all the fields in the right order + kwargs = self._check_kwargs(**kwargs) + # TODO: do we care about the performance penalty of creating this # if we won't need it? now = datetime.datetime.now(tz=pytz.UTC) - if not kwargs.get('relationship_type'): - raise ValueError("Missing required field for Relationship: 'relationship_type'.") - - if not kwargs.get('source_ref'): - raise ValueError("Missing required field for Relationship: 'source_ref'.") - elif isinstance(kwargs['source_ref'], _STIXBase): + # If actual STIX objects (vs. just the IDs) are passed in, extract the + # ID values to use in the Relationship object. + if kwargs.get('source_ref') and isinstance(kwargs['source_ref'], _STIXBase): kwargs['source_ref'] = kwargs['source_ref'].id - if not kwargs.get('target_ref'): - raise ValueError("Missing required field for Relationship: 'target_ref'.") - elif isinstance(kwargs['target_ref'], _STIXBase): + if kwargs.get('target_ref') and isinstance(kwargs['target_ref'], _STIXBase): kwargs['target_ref'] = kwargs['target_ref'].id kwargs.update({ 'created': kwargs.get('created', now), 'modified': kwargs.get('modified', now), - 'relationship_type': kwargs['relationship_type'], - 'target_ref': kwargs['target_ref'], }) super(Relationship, self).__init__(**kwargs) From 1ba064734be640824e4e7699aef7d56b0631cb6b Mon Sep 17 00:00:00 2001 From: Greg Back Date: Thu, 2 Feb 2017 09:50:35 -0600 Subject: [PATCH 38/46] Special handling for timestamp fields. If a type has more than one timestamp field that should be automatically generated, we want them to all be same, not vary by milliseconds. --- stix2/__init__.py | 83 +++++++++++++++++------------------------------ 1 file changed, 30 insertions(+), 53 deletions(-) diff --git a/stix2/__init__.py b/stix2/__init__.py index ca7739f..c9628c2 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -5,6 +5,12 @@ import uuid import pytz + +# Sentinel value for fields 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. +NOW = object() + DEFAULT_ERROR = "{type} must have {field}='{expected}'." COMMON_PROPERTIES = { 'type': { @@ -17,8 +23,12 @@ COMMON_PROPERTIES = { 'expected': (lambda x: x._type + "--"), 'error_msg': "{type} {field} values must begin with '{expected}'." }, - 'created': {}, - 'modified': {}, + 'created': { + 'default': NOW, + }, + 'modified': { + 'default': NOW, + }, } @@ -52,10 +62,18 @@ class _STIXBase(collections.Mapping): def _make_id(cls): return cls._type + "--" + str(uuid.uuid4()) - @classmethod - def _check_kwargs(cls, **kwargs): + def __init__(self, **kwargs): + cls = self.__class__ class_name = cls.__name__ + # Use the same timestamp for any auto-generated datetimes + now = datetime.datetime.now(tz=pytz.UTC) + + # Detect any keyword arguments not allowed for a specific type + extra_kwargs = list(set(kwargs) - set(cls._properties)) + if extra_kwargs: + raise TypeError("unexpected keyword arguments: " + str(extra_kwargs)) + for prop_name, prop_metadata in cls._properties.items(): if prop_name not in kwargs: if prop_metadata.get('required'): @@ -63,7 +81,11 @@ class _STIXBase(collections.Mapping): raise ValueError(msg.format(type=class_name, field=prop_name)) if prop_metadata.get('default'): - kwargs[prop_name] = prop_metadata['default'](cls) + default = prop_metadata['default'] + if default == NOW: + kwargs[prop_name] = now + else: + kwargs[prop_name] = default(cls) elif prop_metadata.get('fixed'): kwargs[prop_name] = prop_metadata['fixed'] @@ -85,18 +107,6 @@ class _STIXBase(collections.Mapping): ) raise ValueError(msg) - return kwargs - - def __init__(self, **kwargs): - # TODO: move all of this back into init, once we check the right things - # in the right order, or move after the unexpected check. - kwargs = self._check_kwargs(**kwargs) - - # Detect any keyword arguments not allowed for a specific type - extra_kwargs = list(set(kwargs) - set(self.__class__._properties)) - if extra_kwargs: - raise TypeError("unexpected keyword arguments: " + str(extra_kwargs)) - self._inner = kwargs def __getitem__(self, key): @@ -155,7 +165,9 @@ class Indicator(_STIXBase): 'pattern': { 'required': True, }, - 'valid_from': {}, + 'valid_from': { + 'default': NOW, + }, }) def __init__(self, **kwargs): @@ -171,18 +183,6 @@ class Indicator(_STIXBase): # - valid_until # - kill_chain_phases - # TODO: do we care about the performance penalty of creating this - # if we won't need it? - now = datetime.datetime.now(tz=pytz.UTC) - - # TODO: remove once we check all the fields in the right order - kwargs = self._check_kwargs(**kwargs) - - kwargs.update({ - 'created': kwargs.get('created', now), - 'modified': kwargs.get('modified', now), - 'valid_from': kwargs.get('valid_from', now), - }) super(Indicator, self).__init__(**kwargs) @@ -210,17 +210,6 @@ class Malware(_STIXBase): # - description # - kill_chain_phases - # TODO: do we care about the performance penalty of creating this - # if we won't need it? - now = datetime.datetime.now(tz=pytz.UTC) - - # TODO: remove once we check all the fields in the right order - kwargs = self._check_kwargs(**kwargs) - - kwargs.update({ - 'created': kwargs.get('created', now), - 'modified': kwargs.get('modified', now), - }) super(Malware, self).__init__(**kwargs) @@ -260,13 +249,6 @@ class Relationship(_STIXBase): if target_ref and not kwargs.get('target_ref'): kwargs['target_ref'] = target_ref - # TODO: remove once we check all the fields in the right order - kwargs = self._check_kwargs(**kwargs) - - # TODO: do we care about the performance penalty of creating this - # if we won't need it? - now = datetime.datetime.now(tz=pytz.UTC) - # If actual STIX objects (vs. just the IDs) are passed in, extract the # ID values to use in the Relationship object. if kwargs.get('source_ref') and isinstance(kwargs['source_ref'], _STIXBase): @@ -275,9 +257,4 @@ class Relationship(_STIXBase): if kwargs.get('target_ref') and isinstance(kwargs['target_ref'], _STIXBase): kwargs['target_ref'] = kwargs['target_ref'].id - kwargs.update({ - 'created': kwargs.get('created', now), - 'modified': kwargs.get('modified', now), - }) - super(Relationship, self).__init__(**kwargs) From 5d7ed643bd879b2314ea550034f2b22406560e3b Mon Sep 17 00:00:00 2001 From: Greg Back Date: Thu, 2 Feb 2017 10:16:10 -0600 Subject: [PATCH 39/46] Check for required args first, and check for them all at once. This is necessary for versions of Python <3.6, where dictionaries are unordered by default, meaning we can't ensure the order in which fields are checked. --- README.md | 2 +- stix2/__init__.py | 11 +++++++---- stix2/test/test_stix2.py | 35 ++++++++++++++++------------------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index bc6436c..722cf7b 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ will result in an error: ```python >>> indicator = Indicator() -ValueError: Missing required field for Indicator: 'labels' +ValueError: Missing required field(s) for Indicator: (labels, pattern). ``` However, the required `valid_from` attribute on Indicators will be set to the diff --git a/stix2/__init__.py b/stix2/__init__.py index c9628c2..6e0d09a 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -74,12 +74,15 @@ class _STIXBase(collections.Mapping): if extra_kwargs: raise TypeError("unexpected keyword arguments: " + str(extra_kwargs)) + required_fields = [k for k, v in cls._properties.items() if v.get('required')] + missing_kwargs = set(required_fields) - set(kwargs) + if missing_kwargs: + msg = "Missing required field(s) for {type}: ({fields})." + field_list = ", ".join(x for x in sorted(list(missing_kwargs))) + raise ValueError(msg.format(type=class_name, fields=field_list)) + for prop_name, prop_metadata in cls._properties.items(): if prop_name not in kwargs: - if prop_metadata.get('required'): - msg = "Missing required field for {type}: '{field}'." - raise ValueError(msg.format(type=class_name, - field=prop_name)) if prop_metadata.get('default'): default = prop_metadata['default'] if default == NOW: diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index a748acd..e33b28e 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -150,29 +150,28 @@ def test_indicator_autogenerated_fields(indicator): def test_indicator_type_must_be_indicator(): with pytest.raises(ValueError) as excinfo: - indicator = stix2.Indicator(type='xxx') + indicator = stix2.Indicator(type='xxx', **INDICATOR_KWARGS) assert "Indicator must have type='indicator'." in str(excinfo) def test_indicator_id_must_start_with_indicator(): with pytest.raises(ValueError) as excinfo: - indicator = stix2.Indicator(id='my-prefix--') + indicator = stix2.Indicator(id='my-prefix--', **INDICATOR_KWARGS) assert "Indicator id values must begin with 'indicator--'." in str(excinfo) -def test_indicator_required_field_labels(): +def test_indicator_required_fields(): with pytest.raises(ValueError) as excinfo: indicator = stix2.Indicator() - assert "Missing required field for Indicator: 'labels'." in str(excinfo) + assert "Missing required field(s) for Indicator: (labels, pattern)." in str(excinfo) def test_indicator_required_field_pattern(): with pytest.raises(ValueError) as excinfo: - # Label is checked first, so make sure that is provided indicator = stix2.Indicator(labels=['malicious-activity']) - assert "Missing required field for Indicator: 'pattern'." in str(excinfo) + assert "Missing required field(s) for Indicator: (pattern)." in str(excinfo) def test_cannot_assign_to_indicator_attributes(indicator): @@ -240,29 +239,28 @@ def test_malware_autogenerated_fields(malware): def test_malware_type_must_be_malware(): with pytest.raises(ValueError) as excinfo: - malware = stix2.Malware(type='xxx') + malware = stix2.Malware(type='xxx', **MALWARE_KWARGS) assert "Malware must have type='malware'." in str(excinfo) def test_malware_id_must_start_with_malware(): with pytest.raises(ValueError) as excinfo: - malware = stix2.Malware(id='my-prefix--') + malware = stix2.Malware(id='my-prefix--', **MALWARE_KWARGS) assert "Malware id values must begin with 'malware--'." in str(excinfo) -def test_malware_required_field_labels(): +def test_malware_required_fields(): with pytest.raises(ValueError) as excinfo: malware = stix2.Malware() - assert "Missing required field for Malware: 'labels'." in str(excinfo) + assert "Missing required field(s) for Malware: (labels, name)." in str(excinfo) def test_malware_required_field_name(): with pytest.raises(ValueError) as excinfo: - # Label is checked first, so make sure that is provided malware = stix2.Malware(labels=['ransomware']) - assert "Missing required field for Malware: 'name'." in str(excinfo) + assert "Missing required field(s) for Malware: (name)." in str(excinfo) def test_cannot_assign_to_malware_attributes(malware): @@ -324,14 +322,14 @@ def test_relationship_autogenerated_fields(relationship): def test_relationship_type_must_be_relationship(): with pytest.raises(ValueError) as excinfo: - relationship = stix2.Relationship(type='xxx') + relationship = stix2.Relationship(type='xxx', **RELATIONSHIP_KWARGS) assert "Relationship must have type='relationship'." in str(excinfo) def test_relationship_id_must_start_with_relationship(): with pytest.raises(ValueError) as excinfo: - relationship = stix2.Relationship(id='my-prefix--') + relationship = stix2.Relationship(id='my-prefix--', **RELATIONSHIP_KWARGS) assert "Relationship id values must begin with 'relationship--'." in str(excinfo) @@ -339,24 +337,23 @@ def test_relationship_id_must_start_with_relationship(): def test_relationship_required_field_relationship_type(): with pytest.raises(ValueError) as excinfo: relationship = stix2.Relationship() - assert "Missing required field for Relationship: 'relationship_type'." in str(excinfo) + assert "Missing required field(s) for Relationship: (relationship_type, source_ref, target_ref)." in str(excinfo) -def test_relationship_required_field_source_ref(): +def test_relationship_missing_some_required_fields(): with pytest.raises(ValueError) as excinfo: # relationship_type is checked first, so make sure that is provided relationship = stix2.Relationship(relationship_type='indicates') - assert "Missing required field for Relationship: 'source_ref'." in str(excinfo) + assert "Missing required field(s) for Relationship: (source_ref, target_ref)." in str(excinfo) def test_relationship_required_field_target_ref(): with pytest.raises(ValueError) as excinfo: - # relationship_type and source_ref are checked first, so make sure those are provided relationship = stix2.Relationship( relationship_type='indicates', source_ref=INDICATOR_ID ) - assert "Missing required field for Relationship: 'target_ref'." in str(excinfo) + assert "Missing required field(s) for Relationship: (target_ref)." in str(excinfo) def test_cannot_assign_to_relationship_attributes(relationship): From 3bcc32781374bf561e93ab5cf18e4466192da153 Mon Sep 17 00:00:00 2001 From: Greg Back Date: Thu, 2 Feb 2017 10:28:16 -0600 Subject: [PATCH 40/46] Add tox support. --- tox.ini | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tox.ini diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..70e820e --- /dev/null +++ b/tox.ini @@ -0,0 +1,23 @@ +[tox] +envlist = py26,py27,py33,py34,py35,py36,pycodestyle + +[testenv] +deps = pytest +commands = pytest + +[testenv:pycodestyle] +deps = pycodestyle +commands = pycodestyle ./stix2 + +[pycodestyle] +ignore= +max-line-length=160 + +[travis] +python = + 2.6: py26 + 2.7: py27, pycodestyle + 3.3: py33 + 3.4: py34 + 3.5: py35 + 3.6: py36 From 1a46a4b073dac74c56c2179a3aedc004ca8ce95e Mon Sep 17 00:00:00 2001 From: Greg Back Date: Thu, 2 Feb 2017 10:58:46 -0600 Subject: [PATCH 41/46] Add external references. --- stix2/__init__.py | 11 +++ stix2/test/test_external_reference.py | 108 ++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 stix2/test/test_external_reference.py diff --git a/stix2/__init__.py b/stix2/__init__.py index 6e0d09a..6ef8328 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -136,6 +136,17 @@ class _STIXBase(collections.Mapping): separators=(",", ": ")) # Don't include spaces after commas. +class ExternalReference(_STIXBase): + _properties = { + 'source_name': { + 'required': True, + }, + 'description': {}, + 'url': {}, + 'external_id': {}, + } + + class Bundle(_STIXBase): _type = 'bundle' diff --git a/stix2/test/test_external_reference.py b/stix2/test/test_external_reference.py new file mode 100644 index 0000000..5b0e995 --- /dev/null +++ b/stix2/test/test_external_reference.py @@ -0,0 +1,108 @@ +"""Tests for stix.ExternalReference""" + +import pytest + +import stix2 + +VERIS = """{ + "external_id": "0001AA7F-C601-424A-B2B8-BE6C9F5164E7", + "source_name": "veris", + "url": "https://github.com/vz-risk/VCDB/blob/master/data/json/0001AA7F-C601-424A-B2B8-BE6C9F5164E7.json" +}""" + + +def test_external_reference_veris(): + ref = stix2.ExternalReference( + source_name="veris", + external_id="0001AA7F-C601-424A-B2B8-BE6C9F5164E7", + url="https://github.com/vz-risk/VCDB/blob/master/data/json/0001AA7F-C601-424A-B2B8-BE6C9F5164E7.json", + ) + + assert str(ref) == VERIS + + +CAPEC = """{ + "external_id": "CAPEC-550", + "source_name": "capec" +}""" + + +def test_external_reference_capec(): + ref = stix2.ExternalReference( + source_name="capec", + external_id="CAPEC-550", + ) + + assert str(ref) == CAPEC + + +CAPEC_URL = """{ + "external_id": "CAPEC-550", + "source_name": "capec", + "url": "http://capec.mitre.org/data/definitions/550.html" +}""" + + +def test_external_reference_capec_url(): + ref = stix2.ExternalReference( + source_name="capec", + external_id="CAPEC-550", + url="http://capec.mitre.org/data/definitions/550.html", + ) + + assert str(ref) == CAPEC_URL + + +THREAT_REPORT = """{ + "description": "Threat report", + "source_name": "ACME Threat Intel", + "url": "http://www.example.com/threat-report.pdf" +}""" + + +def test_external_reference_threat_report(): + ref = stix2.ExternalReference( + source_name="ACME Threat Intel", + description="Threat report", + url="http://www.example.com/threat-report.pdf", + ) + + assert str(ref) == THREAT_REPORT + + +BUGZILLA = """{ + "external_id": "1370", + "source_name": "ACME Bugzilla", + "url": "https://www.example.com/bugs/1370" +}""" + + +def test_external_reference_bugzilla(): + ref = stix2.ExternalReference( + source_name="ACME Bugzilla", + external_id="1370", + url="https://www.example.com/bugs/1370", + ) + + assert str(ref) == BUGZILLA + + +OFFLINE = """{ + "description": "Threat report", + "source_name": "ACME Threat Intel" +}""" + + +def test_external_reference_offline(): + ref = stix2.ExternalReference( + source_name="ACME Threat Intel", + description="Threat report", + ) + + assert str(ref) == OFFLINE + + +def test_external_reference_source_required(): + with pytest.raises(ValueError) as excinfo: + ref = stix2.ExternalReference() + assert "Missing required field(s) for ExternalReference: (source_name)." in str(excinfo) From b171f025c81f896b5ceee35f641f82034dbe786f Mon Sep 17 00:00:00 2001 From: Greg Back Date: Thu, 2 Feb 2017 11:07:57 -0600 Subject: [PATCH 42/46] Test for exact exception strings. --- stix2/test/test_external_reference.py | 2 +- stix2/test/test_stix2.py | 44 +++++++++++++-------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/stix2/test/test_external_reference.py b/stix2/test/test_external_reference.py index 5b0e995..33b948d 100644 --- a/stix2/test/test_external_reference.py +++ b/stix2/test/test_external_reference.py @@ -105,4 +105,4 @@ def test_external_reference_offline(): def test_external_reference_source_required(): with pytest.raises(ValueError) as excinfo: ref = stix2.ExternalReference() - assert "Missing required field(s) for ExternalReference: (source_name)." in str(excinfo) + assert str(excinfo.value) == "Missing required field(s) for ExternalReference: (source_name)." diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index e33b28e..e915284 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -152,39 +152,39 @@ def test_indicator_type_must_be_indicator(): with pytest.raises(ValueError) as excinfo: indicator = stix2.Indicator(type='xxx', **INDICATOR_KWARGS) - assert "Indicator must have type='indicator'." in str(excinfo) + assert str(excinfo.value) == "Indicator must have type='indicator'." def test_indicator_id_must_start_with_indicator(): with pytest.raises(ValueError) as excinfo: indicator = stix2.Indicator(id='my-prefix--', **INDICATOR_KWARGS) - assert "Indicator id values must begin with 'indicator--'." in str(excinfo) + assert str(excinfo.value) == "Indicator id values must begin with 'indicator--'." def test_indicator_required_fields(): with pytest.raises(ValueError) as excinfo: indicator = stix2.Indicator() - assert "Missing required field(s) for Indicator: (labels, pattern)." in str(excinfo) + assert str(excinfo.value) == "Missing required field(s) for Indicator: (labels, pattern)." def test_indicator_required_field_pattern(): with pytest.raises(ValueError) as excinfo: indicator = stix2.Indicator(labels=['malicious-activity']) - assert "Missing required field(s) for Indicator: (pattern)." in str(excinfo) + assert str(excinfo.value) == "Missing required field(s) for Indicator: (pattern)." def test_cannot_assign_to_indicator_attributes(indicator): with pytest.raises(ValueError) as excinfo: indicator.valid_from = datetime.datetime.now() - assert "Cannot modify properties after creation." in str(excinfo) + assert str(excinfo.value) == "Cannot modify properties after creation." def test_invalid_kwarg_to_indicator(): with pytest.raises(TypeError) as excinfo: indicator = stix2.Indicator(my_custom_property="foo", **INDICATOR_KWARGS) - assert "unexpected keyword arguments: ['my_custom_property']" in str(excinfo) + assert str(excinfo.value) == "unexpected keyword arguments: ['my_custom_property']" def test_created_modified_time_are_identical_by_default(): @@ -241,39 +241,39 @@ def test_malware_type_must_be_malware(): with pytest.raises(ValueError) as excinfo: malware = stix2.Malware(type='xxx', **MALWARE_KWARGS) - assert "Malware must have type='malware'." in str(excinfo) + assert str(excinfo.value) == "Malware must have type='malware'." def test_malware_id_must_start_with_malware(): with pytest.raises(ValueError) as excinfo: malware = stix2.Malware(id='my-prefix--', **MALWARE_KWARGS) - assert "Malware id values must begin with 'malware--'." in str(excinfo) + assert str(excinfo.value) == "Malware id values must begin with 'malware--'." def test_malware_required_fields(): with pytest.raises(ValueError) as excinfo: malware = stix2.Malware() - assert "Missing required field(s) for Malware: (labels, name)." in str(excinfo) + assert str(excinfo.value) == "Missing required field(s) for Malware: (labels, name)." def test_malware_required_field_name(): with pytest.raises(ValueError) as excinfo: malware = stix2.Malware(labels=['ransomware']) - assert "Missing required field(s) for Malware: (name)." in str(excinfo) + assert str(excinfo.value) == "Missing required field(s) for Malware: (name)." def test_cannot_assign_to_malware_attributes(malware): with pytest.raises(ValueError) as excinfo: malware.name = "Cryptolocker II" - assert "Cannot modify properties after creation." in str(excinfo) + assert str(excinfo.value) == "Cannot modify properties after creation." def test_invalid_kwarg_to_malware(): with pytest.raises(TypeError) as excinfo: malware = stix2.Malware(my_custom_property="foo", **MALWARE_KWARGS) - assert "unexpected keyword arguments: ['my_custom_property']" in str(excinfo) + assert str(excinfo.value) == "unexpected keyword arguments: ['my_custom_property']" EXPECTED_RELATIONSHIP = """{ @@ -324,27 +324,27 @@ def test_relationship_type_must_be_relationship(): with pytest.raises(ValueError) as excinfo: relationship = stix2.Relationship(type='xxx', **RELATIONSHIP_KWARGS) - assert "Relationship must have type='relationship'." in str(excinfo) + assert str(excinfo.value) == "Relationship must have type='relationship'." def test_relationship_id_must_start_with_relationship(): with pytest.raises(ValueError) as excinfo: relationship = stix2.Relationship(id='my-prefix--', **RELATIONSHIP_KWARGS) - assert "Relationship id values must begin with 'relationship--'." in str(excinfo) + assert str(excinfo.value) == "Relationship id values must begin with 'relationship--'." def test_relationship_required_field_relationship_type(): with pytest.raises(ValueError) as excinfo: relationship = stix2.Relationship() - assert "Missing required field(s) for Relationship: (relationship_type, source_ref, target_ref)." in str(excinfo) + assert str(excinfo.value) == "Missing required field(s) for Relationship: (relationship_type, source_ref, target_ref)." def test_relationship_missing_some_required_fields(): with pytest.raises(ValueError) as excinfo: # relationship_type is checked first, so make sure that is provided relationship = stix2.Relationship(relationship_type='indicates') - assert "Missing required field(s) for Relationship: (source_ref, target_ref)." in str(excinfo) + assert str(excinfo.value) == "Missing required field(s) for Relationship: (source_ref, target_ref)." def test_relationship_required_field_target_ref(): @@ -353,20 +353,20 @@ def test_relationship_required_field_target_ref(): relationship_type='indicates', source_ref=INDICATOR_ID ) - assert "Missing required field(s) for Relationship: (target_ref)." in str(excinfo) + assert str(excinfo.value) == "Missing required field(s) for Relationship: (target_ref)." def test_cannot_assign_to_relationship_attributes(relationship): with pytest.raises(ValueError) as excinfo: relationship.relationship_type = "derived-from" - assert "Cannot modify properties after creation." in str(excinfo) + assert str(excinfo.value) == "Cannot modify properties after creation." def test_invalid_kwarg_to_relationship(): with pytest.raises(TypeError) as excinfo: relationship = stix2.Relationship(my_custom_property="foo", **RELATIONSHIP_KWARGS) - assert "unexpected keyword arguments: ['my_custom_property']" in str(excinfo) + assert str(excinfo.value) == "unexpected keyword arguments: ['my_custom_property']" in str(excinfo) def test_create_relationship_from_objects_rather_than_ids(indicator, malware): @@ -443,21 +443,21 @@ def test_bundle_with_wrong_type(): with pytest.raises(ValueError) as excinfo: bundle = stix2.Bundle(type="not-a-bundle") - assert "Bundle must have type='bundle'." in str(excinfo) + assert str(excinfo.value) == "Bundle must have type='bundle'." def test_bundle_id_must_start_with_bundle(): with pytest.raises(ValueError) as excinfo: bundle = stix2.Bundle(id='my-prefix--') - assert "Bundle id values must begin with 'bundle--'." in str(excinfo) + assert str(excinfo.value) == "Bundle id values must begin with 'bundle--'." def test_bundle_with_wrong_spec_version(): with pytest.raises(ValueError) as excinfo: bundle = stix2.Bundle(spec_version="1.2") - assert "Bundle must have spec_version='2.0'." in str(excinfo) + assert str(excinfo.value) == "Bundle must have spec_version='2.0'." def test_create_bundle(indicator, malware, relationship): From 96e880b49bbd5939b05a919581bb68f26f09728f Mon Sep 17 00:00:00 2001 From: Greg Back Date: Fri, 10 Feb 2017 15:35:02 -0600 Subject: [PATCH 43/46] Refactor library into separate files. --- stix2/__init__.py | 278 +-------------------------------------- stix2/base.py | 104 +++++++++++++++ stix2/bundle.py | 24 ++++ stix2/common.py | 38 ++++++ stix2/sdo.py | 64 +++++++++ stix2/sro.py | 51 +++++++ stix2/test/test_stix2.py | 34 ++--- stix2/test/test_utils.py | 19 +++ stix2/utils.py | 26 ++++ 9 files changed, 343 insertions(+), 295 deletions(-) create mode 100644 stix2/base.py create mode 100644 stix2/bundle.py create mode 100644 stix2/common.py create mode 100644 stix2/sdo.py create mode 100644 stix2/sro.py create mode 100644 stix2/test/test_utils.py create mode 100644 stix2/utils.py diff --git a/stix2/__init__.py b/stix2/__init__.py index 6ef8328..3991a64 100644 --- a/stix2/__init__.py +++ b/stix2/__init__.py @@ -1,274 +1,6 @@ -import collections -import datetime -import json -import uuid +"""Python APIs for STIX 2.""" -import pytz - - -# Sentinel value for fields 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. -NOW = object() - -DEFAULT_ERROR = "{type} must have {field}='{expected}'." -COMMON_PROPERTIES = { - 'type': { - 'default': (lambda x: x._type), - 'validate': (lambda x, val: val == x._type) - }, - 'id': { - 'default': (lambda x: x._make_id()), - 'validate': (lambda x, val: val.startswith(x._type + "--")), - 'expected': (lambda x: x._type + "--"), - 'error_msg': "{type} {field} values must begin with '{expected}'." - }, - 'created': { - 'default': NOW, - }, - 'modified': { - 'default': NOW, - }, -} - - -def format_datetime(dt): - # TODO: how to handle naive datetime - - # 1. Convert to UTC - # 2. Format in isoformat - # 3. Strip off "+00:00" - # 4. Add "Z" - - # TODO: how to handle timestamps with subsecond 0's - return dt.astimezone(pytz.utc).isoformat()[:-6] + "Z" - - -class STIXJSONEncoder(json.JSONEncoder): - - def default(self, obj): - if isinstance(obj, (datetime.date, datetime.datetime)): - return format_datetime(obj) - elif isinstance(obj, _STIXBase): - return dict(obj) - else: - return super(STIXJSONEncoder, self).default(obj) - - -class _STIXBase(collections.Mapping): - """Base class for STIX object types""" - - @classmethod - def _make_id(cls): - return cls._type + "--" + str(uuid.uuid4()) - - def __init__(self, **kwargs): - cls = self.__class__ - class_name = cls.__name__ - - # Use the same timestamp for any auto-generated datetimes - now = datetime.datetime.now(tz=pytz.UTC) - - # Detect any keyword arguments not allowed for a specific type - extra_kwargs = list(set(kwargs) - set(cls._properties)) - if extra_kwargs: - raise TypeError("unexpected keyword arguments: " + str(extra_kwargs)) - - required_fields = [k for k, v in cls._properties.items() if v.get('required')] - missing_kwargs = set(required_fields) - set(kwargs) - if missing_kwargs: - msg = "Missing required field(s) for {type}: ({fields})." - field_list = ", ".join(x for x in sorted(list(missing_kwargs))) - raise ValueError(msg.format(type=class_name, fields=field_list)) - - for prop_name, prop_metadata in cls._properties.items(): - if prop_name not in kwargs: - if prop_metadata.get('default'): - default = prop_metadata['default'] - if default == NOW: - kwargs[prop_name] = now - else: - kwargs[prop_name] = default(cls) - elif prop_metadata.get('fixed'): - kwargs[prop_name] = prop_metadata['fixed'] - - if prop_metadata.get('validate'): - if not prop_metadata['validate'](cls, kwargs[prop_name]): - msg = prop_metadata.get('error_msg', DEFAULT_ERROR).format( - type=class_name, - field=prop_name, - expected=prop_metadata.get('expected', - prop_metadata['default'])(cls), - ) - raise ValueError(msg) - elif prop_metadata.get('fixed'): - if kwargs[prop_name] != prop_metadata['fixed']: - msg = prop_metadata.get('error_msg', DEFAULT_ERROR).format( - type=class_name, - field=prop_name, - expected=prop_metadata['fixed'] - ) - raise ValueError(msg) - - self._inner = kwargs - - 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): - return self.get(name) - - def __setattr__(self, name, value): - if name != '_inner': - raise ValueError("Cannot modify properties after creation.") - super(_STIXBase, self).__setattr__(name, value) - - def __str__(self): - # TODO: put keys in specific order. Probably need custom JSON encoder. - return json.dumps(self, indent=4, sort_keys=True, cls=STIXJSONEncoder, - separators=(",", ": ")) # Don't include spaces after commas. - - -class ExternalReference(_STIXBase): - _properties = { - 'source_name': { - 'required': True, - }, - 'description': {}, - 'url': {}, - 'external_id': {}, - } - - -class Bundle(_STIXBase): - - _type = 'bundle' - _properties = { - # Borrow the 'type' and 'id' definitions - 'type': COMMON_PROPERTIES['type'], - 'id': COMMON_PROPERTIES['id'], - 'spec_version': { - 'fixed': "2.0", - }, - 'objects': {}, - } - - def __init__(self, *args, **kwargs): - # Add any positional arguments to the 'objects' kwarg. - if args: - kwargs['objects'] = kwargs.get('objects', []) + list(args) - - super(Bundle, self).__init__(**kwargs) - - -class Indicator(_STIXBase): - - _type = 'indicator' - _properties = COMMON_PROPERTIES.copy() - _properties.update({ - 'labels': { - 'required': True, - }, - 'pattern': { - 'required': True, - }, - 'valid_from': { - 'default': NOW, - }, - }) - - def __init__(self, **kwargs): - # TODO: - # - created_by_ref - # - revoked - # - external_references - # - object_marking_refs - # - granular_markings - - # - name - # - description - # - valid_until - # - kill_chain_phases - - super(Indicator, self).__init__(**kwargs) - - -class Malware(_STIXBase): - - _type = 'malware' - _properties = COMMON_PROPERTIES.copy() - _properties.update({ - 'labels': { - 'required': True, - }, - 'name': { - 'required': True, - }, - }) - - def __init__(self, **kwargs): - # TODO: - # - created_by_ref - # - revoked - # - external_references - # - object_marking_refs - # - granular_markings - - # - description - # - kill_chain_phases - - super(Malware, self).__init__(**kwargs) - - -class Relationship(_STIXBase): - - _type = 'relationship' - _properties = COMMON_PROPERTIES.copy() - _properties.update({ - 'relationship_type': { - 'required': True, - }, - 'source_ref': { - 'required': True, - }, - 'target_ref': { - 'required': True, - }, - }) - - # Explicitly define the first three kwargs to make readable Relationship declarations. - def __init__(self, source_ref=None, relationship_type=None, target_ref=None, - **kwargs): - # TODO: - # - created_by_ref - # - revoked - # - external_references - # - object_marking_refs - # - granular_markings - - # - description - - # Allow (source_ref, relationship_type, target_ref) as positional args. - if source_ref and not kwargs.get('source_ref'): - kwargs['source_ref'] = source_ref - if relationship_type and not kwargs.get('relationship_type'): - kwargs['relationship_type'] = relationship_type - if target_ref and not kwargs.get('target_ref'): - kwargs['target_ref'] = target_ref - - # If actual STIX objects (vs. just the IDs) are passed in, extract the - # ID values to use in the Relationship object. - if kwargs.get('source_ref') and isinstance(kwargs['source_ref'], _STIXBase): - kwargs['source_ref'] = kwargs['source_ref'].id - - if kwargs.get('target_ref') and isinstance(kwargs['target_ref'], _STIXBase): - kwargs['target_ref'] = kwargs['target_ref'].id - - super(Relationship, self).__init__(**kwargs) +from .bundle import Bundle +from .common import ExternalReference +from .sdo import Indicator, Malware +from .sro import Relationship diff --git a/stix2/base.py b/stix2/base.py new file mode 100644 index 0000000..317372c --- /dev/null +++ b/stix2/base.py @@ -0,0 +1,104 @@ +"""Base class for type definitions in the stix2 library.""" + +import collections +import datetime as dt +import json +import uuid + +from .utils import format_datetime, get_timestamp, NOW + +__all__ = ['STIXJSONEncoder', '_STIXBase'] + +DEFAULT_ERROR = "{type} must have {field}='{expected}'." + + +class STIXJSONEncoder(json.JSONEncoder): + + def default(self, obj): + if isinstance(obj, (dt.date, dt.datetime)): + return format_datetime(obj) + elif isinstance(obj, _STIXBase): + return dict(obj) + else: + return super(STIXJSONEncoder, self).default(obj) + + +class _STIXBase(collections.Mapping): + """Base class for STIX object types""" + + @classmethod + def _make_id(cls): + return cls._type + "--" + str(uuid.uuid4()) + + def __init__(self, **kwargs): + cls = self.__class__ + class_name = cls.__name__ + + # Use the same timestamp for any auto-generated datetimes + now = get_timestamp() + + # Detect any keyword arguments not allowed for a specific type + extra_kwargs = list(set(kwargs) - set(cls._properties)) + if extra_kwargs: + raise TypeError("unexpected keyword arguments: " + str(extra_kwargs)) + + required_fields = [k for k, v in cls._properties.items() if v.get('required')] + missing_kwargs = set(required_fields) - set(kwargs) + if missing_kwargs: + msg = "Missing required field(s) for {type}: ({fields})." + field_list = ", ".join(x for x in sorted(list(missing_kwargs))) + raise ValueError(msg.format(type=class_name, fields=field_list)) + + for prop_name, prop_metadata in cls._properties.items(): + if prop_name not in kwargs: + if prop_metadata.get('default'): + default = prop_metadata['default'] + if default == NOW: + kwargs[prop_name] = now + else: + kwargs[prop_name] = default(cls) + elif prop_metadata.get('fixed'): + kwargs[prop_name] = prop_metadata['fixed'] + + if prop_metadata.get('validate'): + if not prop_metadata['validate'](cls, kwargs[prop_name]): + msg = prop_metadata.get('error_msg', DEFAULT_ERROR).format( + type=class_name, + field=prop_name, + expected=prop_metadata.get('expected', + prop_metadata['default'])(cls), + ) + raise ValueError(msg) + elif prop_metadata.get('fixed'): + if kwargs[prop_name] != prop_metadata['fixed']: + msg = prop_metadata.get('error_msg', DEFAULT_ERROR).format( + type=class_name, + field=prop_name, + expected=prop_metadata['fixed'] + ) + raise ValueError(msg) + + self._inner = kwargs + + 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): + return self.get(name) + + def __setattr__(self, name, value): + if name != '_inner': + raise ValueError("Cannot modify properties after creation.") + super(_STIXBase, self).__setattr__(name, value) + + def __str__(self): + # TODO: put keys in specific order. Probably need custom JSON encoder. + return json.dumps(self, indent=4, sort_keys=True, cls=STIXJSONEncoder, + separators=(",", ": ")) # Don't include spaces after commas. diff --git a/stix2/bundle.py b/stix2/bundle.py new file mode 100644 index 0000000..614ce0d --- /dev/null +++ b/stix2/bundle.py @@ -0,0 +1,24 @@ +"""STIX 2 Bundle object""" + +from .base import _STIXBase +from .common import TYPE_PROPERTY, ID_PROPERTY + + +class Bundle(_STIXBase): + + _type = 'bundle' + _properties = { + 'type': TYPE_PROPERTY, + 'id': ID_PROPERTY, + 'spec_version': { + 'fixed': "2.0", + }, + 'objects': {}, + } + + def __init__(self, *args, **kwargs): + # Add any positional arguments to the 'objects' kwarg. + if args: + kwargs['objects'] = kwargs.get('objects', []) + list(args) + + super(Bundle, self).__init__(**kwargs) diff --git a/stix2/common.py b/stix2/common.py new file mode 100644 index 0000000..924210a --- /dev/null +++ b/stix2/common.py @@ -0,0 +1,38 @@ +"""STIX 2 Common Data Types and Properties""" + +from .base import _STIXBase +from .utils import NOW + +TYPE_PROPERTY = { + 'default': (lambda x: x._type), + 'validate': (lambda x, val: val == x._type) +} + +ID_PROPERTY = { + 'default': (lambda x: x._make_id()), + 'validate': (lambda x, val: val.startswith(x._type + "--")), + 'expected': (lambda x: x._type + "--"), + 'error_msg': "{type} {field} values must begin with '{expected}'." +} + +COMMON_PROPERTIES = { + 'type': TYPE_PROPERTY, + 'id': ID_PROPERTY, + 'created': { + 'default': NOW, + }, + 'modified': { + 'default': NOW, + }, +} + + +class ExternalReference(_STIXBase): + _properties = { + 'source_name': { + 'required': True, + }, + 'description': {}, + 'url': {}, + 'external_id': {}, + } diff --git a/stix2/sdo.py b/stix2/sdo.py new file mode 100644 index 0000000..3a033fe --- /dev/null +++ b/stix2/sdo.py @@ -0,0 +1,64 @@ +"""STIX 2.0 Domain Objects""" + +from .base import _STIXBase +from .common import COMMON_PROPERTIES +from .utils import NOW + + +class Indicator(_STIXBase): + + _type = 'indicator' + _properties = COMMON_PROPERTIES.copy() + _properties.update({ + 'labels': { + 'required': True, + }, + 'pattern': { + 'required': True, + }, + 'valid_from': { + 'default': NOW, + }, + }) + + def __init__(self, **kwargs): + # TODO: + # - created_by_ref + # - revoked + # - external_references + # - object_marking_refs + # - granular_markings + + # - name + # - description + # - valid_until + # - kill_chain_phases + + super(Indicator, self).__init__(**kwargs) + + +class Malware(_STIXBase): + + _type = 'malware' + _properties = COMMON_PROPERTIES.copy() + _properties.update({ + 'labels': { + 'required': True, + }, + 'name': { + 'required': True, + }, + }) + + def __init__(self, **kwargs): + # TODO: + # - created_by_ref + # - revoked + # - external_references + # - object_marking_refs + # - granular_markings + + # - description + # - kill_chain_phases + + super(Malware, self).__init__(**kwargs) diff --git a/stix2/sro.py b/stix2/sro.py new file mode 100644 index 0000000..856cf3b --- /dev/null +++ b/stix2/sro.py @@ -0,0 +1,51 @@ +"""STIX 2.0 Relationship Objects.""" + +from .base import _STIXBase +from .common import COMMON_PROPERTIES + + +class Relationship(_STIXBase): + + _type = 'relationship' + _properties = COMMON_PROPERTIES.copy() + _properties.update({ + 'relationship_type': { + 'required': True, + }, + 'source_ref': { + 'required': True, + }, + 'target_ref': { + 'required': True, + }, + }) + + # Explicitly define the first three kwargs to make readable Relationship declarations. + def __init__(self, source_ref=None, relationship_type=None, target_ref=None, + **kwargs): + # TODO: + # - created_by_ref + # - revoked + # - external_references + # - object_marking_refs + # - granular_markings + + # - description + + # Allow (source_ref, relationship_type, target_ref) as positional args. + if source_ref and not kwargs.get('source_ref'): + kwargs['source_ref'] = source_ref + if relationship_type and not kwargs.get('relationship_type'): + kwargs['relationship_type'] = relationship_type + if target_ref and not kwargs.get('target_ref'): + kwargs['target_ref'] = target_ref + + # If actual STIX objects (vs. just the IDs) are passed in, extract the + # ID values to use in the Relationship object. + if kwargs.get('source_ref') and isinstance(kwargs['source_ref'], _STIXBase): + kwargs['source_ref'] = kwargs['source_ref'].id + + if kwargs.get('target_ref') and isinstance(kwargs['target_ref'], _STIXBase): + kwargs['target_ref'] = kwargs['target_ref'].id + + super(Relationship, self).__init__(**kwargs) diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index e915284..ec05a34 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -1,32 +1,32 @@ """Tests for the stix2 library""" -import datetime +import datetime as dt import uuid import pytest import pytz import stix2 +import stix2.utils -amsterdam = pytz.timezone('Europe/Amsterdam') -eastern = pytz.timezone('US/Eastern') -FAKE_TIME = datetime.datetime(2017, 1, 1, 12, 34, 56, tzinfo=pytz.utc) + +FAKE_TIME = dt.datetime(2017, 1, 1, 12, 34, 56, tzinfo=pytz.utc) # Inspired by: http://stackoverflow.com/a/24006251 @pytest.fixture def clock(monkeypatch): - class mydatetime(datetime.datetime): + class mydatetime(dt.datetime): @classmethod def now(cls, tz=None): return FAKE_TIME - monkeypatch.setattr(datetime, 'datetime', mydatetime) + monkeypatch.setattr(dt, 'datetime', mydatetime) def test_clock(clock): - assert datetime.datetime.now() == FAKE_TIME + assert dt.datetime.now() == FAKE_TIME @pytest.fixture @@ -51,16 +51,6 @@ def test_my_uuid4_fixture(uuid4): assert uuid.uuid4() == "00000000-0000-0000-0000-000000000104" -@pytest.mark.parametrize('dt,timestamp', [ - (datetime.datetime(2017, 1, 1, tzinfo=pytz.utc), '2017-01-01T00:00:00Z'), - (amsterdam.localize(datetime.datetime(2017, 1, 1)), '2016-12-31T23:00:00Z'), - (eastern.localize(datetime.datetime(2017, 1, 1, 12, 34, 56)), '2017-01-01T17:34:56Z'), - (eastern.localize(datetime.datetime(2017, 7, 1)), '2017-07-01T04:00:00Z'), -]) -def test_timestamp_formatting(dt, timestamp): - assert stix2.format_datetime(dt) == timestamp - - INDICATOR_ID = "indicator--01234567-89ab-cdef-0123-456789abcdef" MALWARE_ID = "malware--fedcba98-7654-3210-fedc-ba9876543210" RELATIONSHIP_ID = "relationship--00000000-1111-2222-3333-444444444444" @@ -114,8 +104,8 @@ EXPECTED_INDICATOR = """{ def test_indicator_with_all_required_fields(): - now = datetime.datetime(2017, 1, 1, 0, 0, 0, tzinfo=pytz.utc) - epoch = datetime.datetime(1970, 1, 1, 0, 0, 0, tzinfo=pytz.utc) + now = dt.datetime(2017, 1, 1, 0, 0, 0, tzinfo=pytz.utc) + epoch = dt.datetime(1970, 1, 1, 0, 0, 0, tzinfo=pytz.utc) indicator = stix2.Indicator( type="indicator", @@ -176,7 +166,7 @@ def test_indicator_required_field_pattern(): def test_cannot_assign_to_indicator_attributes(indicator): with pytest.raises(ValueError) as excinfo: - indicator.valid_from = datetime.datetime.now() + indicator.valid_from = dt.datetime.now() assert str(excinfo.value) == "Cannot modify properties after creation." @@ -207,7 +197,7 @@ EXPECTED_MALWARE = """{ def test_malware_with_all_required_fields(): - now = datetime.datetime(2016, 5, 12, 8, 17, 27, tzinfo=pytz.utc) + now = dt.datetime(2016, 5, 12, 8, 17, 27, tzinfo=pytz.utc) malware = stix2.Malware( type="malware", @@ -288,7 +278,7 @@ EXPECTED_RELATIONSHIP = """{ def test_relationship_all_required_fields(): - now = datetime.datetime(2016, 4, 6, 20, 6, 37, tzinfo=pytz.utc) + now = dt.datetime(2016, 4, 6, 20, 6, 37, tzinfo=pytz.utc) relationship = stix2.Relationship( type='relationship', diff --git a/stix2/test/test_utils.py b/stix2/test/test_utils.py new file mode 100644 index 0000000..3eee491 --- /dev/null +++ b/stix2/test/test_utils.py @@ -0,0 +1,19 @@ +import datetime as dt + +import pytest +import pytz + +import stix2.utils + +amsterdam = pytz.timezone('Europe/Amsterdam') +eastern = pytz.timezone('US/Eastern') + + +@pytest.mark.parametrize('dttm,timestamp', [ + (dt.datetime(2017, 1, 1, tzinfo=pytz.utc), '2017-01-01T00:00:00Z'), + (amsterdam.localize(dt.datetime(2017, 1, 1)), '2016-12-31T23:00:00Z'), + (eastern.localize(dt.datetime(2017, 1, 1, 12, 34, 56)), '2017-01-01T17:34:56Z'), + (eastern.localize(dt.datetime(2017, 7, 1)), '2017-07-01T04:00:00Z'), +]) +def test_timestamp_formatting(dttm, timestamp): + assert stix2.utils.format_datetime(dttm) == timestamp diff --git a/stix2/utils.py b/stix2/utils.py new file mode 100644 index 0000000..0acd5de --- /dev/null +++ b/stix2/utils.py @@ -0,0 +1,26 @@ +"""Utility functions and classes for the stix2 library.""" + +import datetime as dt + +import pytz + +# Sentinel value for fields 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. +NOW = object() + + +def get_timestamp(): + return dt.datetime.now(tz=pytz.UTC) + + +def format_datetime(dttm): + # TODO: how to handle naive datetime + + # 1. Convert to UTC + # 2. Format in ISO format + # 3. Strip off "+00:00" + # 4. Add "Z" + + # TODO: how to handle timestamps with subsecond 0's + return dttm.astimezone(pytz.utc).isoformat()[:-6] + "Z" From bc66db94aa6228572f8b9d949a012e19a7661118 Mon Sep 17 00:00:00 2001 From: Greg Back Date: Fri, 10 Feb 2017 15:58:17 -0600 Subject: [PATCH 44/46] Add generic __repr__ to _STIXBase. --- stix2/base.py | 5 +++++ stix2/test/test_stix2.py | 22 ++++++++++++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/stix2/base.py b/stix2/base.py index 317372c..3adc0e4 100644 --- a/stix2/base.py +++ b/stix2/base.py @@ -102,3 +102,8 @@ class _STIXBase(collections.Mapping): # TODO: put keys in specific order. Probably need custom JSON encoder. return json.dumps(self, indent=4, sort_keys=True, cls=STIXJSONEncoder, separators=(",", ": ")) # Don't include spaces after commas. + + def __repr__(self): + props = [(k, self[k]) for k in sorted(self._properties)] + return "{0}({1})".format(self.__class__.__name__, + ", ".join(["{0!s}={1!r}".format(k, v) for k, v in props])) diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index ec05a34..03a887b 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -91,21 +91,31 @@ def relationship(uuid4, clock): EXPECTED_INDICATOR = """{ - "created": "2017-01-01T00:00:00Z", + "created": "2017-01-01T00:00:01Z", "id": "indicator--01234567-89ab-cdef-0123-456789abcdef", "labels": [ "malicious-activity" ], - "modified": "2017-01-01T00:00:00Z", + "modified": "2017-01-01T00:00:01Z", "pattern": "[file:hashes.MD5 = 'd41d8cd98f00b204e9800998ecf8427e']", "type": "indicator", - "valid_from": "1970-01-01T00:00:00Z" + "valid_from": "1970-01-01T00:00:01Z" }""" +EXPECTED_INDICATOR_REPR = "Indicator(" + " ".join(""" + created=datetime.datetime(2017, 1, 1, 0, 0, 1, tzinfo=), + id='indicator--01234567-89ab-cdef-0123-456789abcdef', + labels=['malicious-activity'], + modified=datetime.datetime(2017, 1, 1, 0, 0, 1, tzinfo=), + pattern="[file:hashes.MD5 = 'd41d8cd98f00b204e9800998ecf8427e']", + type='indicator', + valid_from=datetime.datetime(1970, 1, 1, 0, 0, 1, tzinfo=) +""".split()) + ")" + def test_indicator_with_all_required_fields(): - now = dt.datetime(2017, 1, 1, 0, 0, 0, tzinfo=pytz.utc) - epoch = dt.datetime(1970, 1, 1, 0, 0, 0, tzinfo=pytz.utc) + now = dt.datetime(2017, 1, 1, 0, 0, 1, tzinfo=pytz.utc) + epoch = dt.datetime(1970, 1, 1, 0, 0, 1, tzinfo=pytz.utc) indicator = stix2.Indicator( type="indicator", @@ -118,6 +128,7 @@ def test_indicator_with_all_required_fields(): ) assert str(indicator) == EXPECTED_INDICATOR + assert repr(indicator) == EXPECTED_INDICATOR_REPR def test_indicator_autogenerated_fields(indicator): @@ -291,7 +302,6 @@ def test_relationship_all_required_fields(): ) assert str(relationship) == EXPECTED_RELATIONSHIP - def test_relationship_autogenerated_fields(relationship): assert relationship.type == 'relationship' assert relationship.id == 'relationship--00000000-0000-0000-0000-000000000001' From dbe98c664a0c8cd898b6e338decec3319eb918a4 Mon Sep 17 00:00:00 2001 From: Greg Back Date: Fri, 10 Feb 2017 16:09:37 -0600 Subject: [PATCH 45/46] More repr tests. --- stix2/base.py | 2 +- stix2/test/test_external_reference.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/stix2/base.py b/stix2/base.py index 3adc0e4..f23da74 100644 --- a/stix2/base.py +++ b/stix2/base.py @@ -104,6 +104,6 @@ class _STIXBase(collections.Mapping): separators=(",", ": ")) # Don't include spaces after commas. def __repr__(self): - props = [(k, self[k]) for k in sorted(self._properties)] + 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])) diff --git a/stix2/test/test_external_reference.py b/stix2/test/test_external_reference.py index 33b948d..353984a 100644 --- a/stix2/test/test_external_reference.py +++ b/stix2/test/test_external_reference.py @@ -34,6 +34,7 @@ def test_external_reference_capec(): ) assert str(ref) == CAPEC + assert repr(ref) == "ExternalReference(external_id='CAPEC-550', source_name='capec')" CAPEC_URL = """{ @@ -100,6 +101,9 @@ def test_external_reference_offline(): ) assert str(ref) == OFFLINE + assert repr(ref) == "ExternalReference(description='Threat report', source_name='ACME Threat Intel')" + # Yikes! This works + assert eval("stix2." + repr(ref)) == ref def test_external_reference_source_required(): From 031cdc9931850e3d7eaa541df5592da05d144adc Mon Sep 17 00:00:00 2001 From: Greg Back Date: Fri, 10 Feb 2017 16:12:02 -0600 Subject: [PATCH 46/46] Add blank line --- stix2/test/test_stix2.py | 1 + 1 file changed, 1 insertion(+) diff --git a/stix2/test/test_stix2.py b/stix2/test/test_stix2.py index 03a887b..164aca9 100644 --- a/stix2/test/test_stix2.py +++ b/stix2/test/test_stix2.py @@ -302,6 +302,7 @@ def test_relationship_all_required_fields(): ) assert str(relationship) == EXPECTED_RELATIONSHIP + def test_relationship_autogenerated_fields(relationship): assert relationship.type == 'relationship' assert relationship.id == 'relationship--00000000-0000-0000-0000-000000000001'