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/README.md b/README.md index 7a24035..5eb72e3 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,158 @@ This repository provides Python APIs for serializing and de-serializing STIX 2 JSON content, along with higher-level APIs for common tasks, including data markings, versioning, and for resolving STIX IDs across multiple data sources. +## 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 objects will 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='xxx', ...) + 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(s) for Indicator: (labels, pattern). +``` + +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: + +```python +from stix2 import bundle + +bundle = Bundle(indicator, malware, relationship) +``` + +### Serializing STIX objects + +The string representation of all STIX classes is a valid STIX JSON object. + +```python +indicator = Indicator(...) + +print(str(indicator)) +``` + +### Versioning + +TBD + ## Governance diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d10154e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +pytest +tox +pytest-cov + +-e . diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..22ee0ac --- /dev/null +++ b/setup.py @@ -0,0 +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 new file mode 100644 index 0000000..3991a64 --- /dev/null +++ b/stix2/__init__.py @@ -0,0 +1,6 @@ +"""Python APIs for STIX 2.""" + +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..f23da74 --- /dev/null +++ b/stix2/base.py @@ -0,0 +1,109 @@ +"""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. + + def __repr__(self): + 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/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/__init__.py b/stix2/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stix2/test/test_external_reference.py b/stix2/test/test_external_reference.py new file mode 100644 index 0000000..353984a --- /dev/null +++ b/stix2/test/test_external_reference.py @@ -0,0 +1,112 @@ +"""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 + assert repr(ref) == "ExternalReference(external_id='CAPEC-550', source_name='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 + 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(): + with pytest.raises(ValueError) as excinfo: + ref = stix2.ExternalReference() + 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 new file mode 100644 index 0000000..164aca9 --- /dev/null +++ b/stix2/test/test_stix2.py @@ -0,0 +1,473 @@ +"""Tests for the stix2 library""" + +import datetime as dt +import uuid + +import pytest +import pytz + +import stix2 +import stix2.utils + + +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(dt.datetime): + @classmethod + def now(cls, tz=None): + return FAKE_TIME + + monkeypatch.setattr(dt, 'datetime', mydatetime) + + +def test_clock(clock): + assert dt.datetime.now() == FAKE_TIME + + +@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" + + +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'], + pattern="[file:hashes.MD5 = 'd41d8cd98f00b204e9800998ecf8427e']", +) + +# Minimum required args for a Malware instance +MALWARE_KWARGS = dict( + labels=['ransomware'], + name="Cryptolocker", +) + +# Minimum required args for a Relationship instance +RELATIONSHIP_KWARGS = dict( + relationship_type="indicates", + source_ref=INDICATOR_ID, + target_ref=MALWARE_ID, +) + + +@pytest.fixture +def indicator(uuid4, clock): + return stix2.Indicator(**INDICATOR_KWARGS) + + +@pytest.fixture +def malware(uuid4, clock): + return stix2.Malware(**MALWARE_KWARGS) + + +@pytest.fixture +def relationship(uuid4, clock): + return stix2.Relationship(**RELATIONSHIP_KWARGS) + + +EXPECTED_INDICATOR = """{ + "created": "2017-01-01T00:00:01Z", + "id": "indicator--01234567-89ab-cdef-0123-456789abcdef", + "labels": [ + "malicious-activity" + ], + "modified": "2017-01-01T00:00:01Z", + "pattern": "[file:hashes.MD5 = 'd41d8cd98f00b204e9800998ecf8427e']", + "type": "indicator", + "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, 1, tzinfo=pytz.utc) + epoch = dt.datetime(1970, 1, 1, 0, 0, 1, tzinfo=pytz.utc) + + indicator = stix2.Indicator( + type="indicator", + id=INDICATOR_ID, + labels=['malicious-activity'], + pattern="[file:hashes.MD5 = 'd41d8cd98f00b204e9800998ecf8427e']", + created=now, + modified=now, + valid_from=epoch, + ) + + assert str(indicator) == EXPECTED_INDICATOR + assert repr(indicator) == EXPECTED_INDICATOR_REPR + + +def test_indicator_autogenerated_fields(indicator): + assert indicator.type == 'indicator' + 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 == FAKE_TIME + + assert indicator['type'] == 'indicator' + 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'] == FAKE_TIME + + +def test_indicator_type_must_be_indicator(): + with pytest.raises(ValueError) as excinfo: + indicator = stix2.Indicator(type='xxx', **INDICATOR_KWARGS) + + 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 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 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 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 = dt.datetime.now() + + 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 str(excinfo.value) == "unexpected keyword arguments: ['my_custom_property']" + + +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", + "labels": [ + "ransomware" + ], + "modified": "2016-05-12T08:17:27Z", + "name": "Cryptolocker", + "type": "malware" +}""" + + +def test_malware_with_all_required_fields(): + now = dt.datetime(2016, 5, 12, 8, 17, 27, tzinfo=pytz.utc) + + malware = stix2.Malware( + type="malware", + id=MALWARE_ID, + created=now, + modified=now, + labels=["ransomware"], + name="Cryptolocker", + ) + + assert str(malware) == EXPECTED_MALWARE + + +def test_malware_autogenerated_fields(malware): + assert malware.type == 'malware' + 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'] == 'malware--00000000-0000-0000-0000-000000000001' + assert malware['created'] == FAKE_TIME + assert malware['modified'] == FAKE_TIME + 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', **MALWARE_KWARGS) + + 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 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 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 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 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 str(excinfo.value) == "unexpected keyword arguments: ['my_custom_property']" + + +EXPECTED_RELATIONSHIP = """{ + "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" +}""" + + +def test_relationship_all_required_fields(): + now = dt.datetime(2016, 4, 6, 20, 6, 37, tzinfo=pytz.utc) + + relationship = stix2.Relationship( + type='relationship', + id=RELATIONSHIP_ID, + 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 == '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'] == '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 + + +def test_relationship_type_must_be_relationship(): + with pytest.raises(ValueError) as excinfo: + relationship = stix2.Relationship(type='xxx', **RELATIONSHIP_KWARGS) + + 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 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 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 str(excinfo.value) == "Missing required field(s) for Relationship: (source_ref, target_ref)." + + +def test_relationship_required_field_target_ref(): + with pytest.raises(ValueError) as excinfo: + relationship = stix2.Relationship( + relationship_type='indicates', + source_ref=INDICATOR_ID + ) + 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 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 str(excinfo.value) == "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--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--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--00000000-0000-0000-0000-000000000004", + "objects": [ + { + "created": "2017-01-01T12:34:56Z", + "id": "indicator--00000000-0000-0000-0000-000000000001", + "labels": [ + "malicious-activity" + ], + "modified": "2017-01-01T12:34:56Z", + "pattern": "[file:hashes.MD5 = 'd41d8cd98f00b204e9800998ecf8427e']", + "type": "indicator", + "valid_from": "2017-01-01T12:34:56Z" + }, + { + "created": "2017-01-01T12:34:56Z", + "id": "malware--00000000-0000-0000-0000-000000000002", + "labels": [ + "ransomware" + ], + "modified": "2017-01-01T12:34:56Z", + "name": "Cryptolocker", + "type": "malware" + }, + { + "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", + "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" + assert bundle.objects is None + + +def test_bundle_with_wrong_type(): + with pytest.raises(ValueError) as excinfo: + bundle = stix2.Bundle(type="not-a-bundle") + + 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 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 str(excinfo.value) == "Bundle must have spec_version='2.0'." + + +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 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" 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