Merge existing 'master' branch

stix2.1
Greg Back 2017-02-13 10:17:20 -06:00
commit fe61b00d78
16 changed files with 1176 additions and 0 deletions

59
.gitignore vendored Normal file
View File

@ -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/

152
README.md
View File

@ -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

5
requirements.txt Normal file
View File

@ -0,0 +1,5 @@
pytest
tox
pytest-cov
-e .

15
setup.py Normal file
View File

@ -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",
)

6
stix2/__init__.py Normal file
View File

@ -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

109
stix2/base.py Normal file
View File

@ -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]))

24
stix2/bundle.py Normal file
View File

@ -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)

38
stix2/common.py Normal file
View File

@ -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': {},
}

64
stix2/sdo.py Normal file
View File

@ -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)

51
stix2/sro.py Normal file
View File

@ -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)

0
stix2/test/__init__.py Normal file
View File

View File

@ -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)."

473
stix2/test/test_stix2.py Normal file
View File

@ -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=<UTC>),
id='indicator--01234567-89ab-cdef-0123-456789abcdef',
labels=['malicious-activity'],
modified=datetime.datetime(2017, 1, 1, 0, 0, 1, tzinfo=<UTC>),
pattern="[file:hashes.MD5 = 'd41d8cd98f00b204e9800998ecf8427e']",
type='indicator',
valid_from=datetime.datetime(1970, 1, 1, 0, 0, 1, tzinfo=<UTC>)
""".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

19
stix2/test/test_utils.py Normal file
View File

@ -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

26
stix2/utils.py Normal file
View File

@ -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"

23
tox.ini Normal file
View File

@ -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