diff --git a/README.rst b/README.rst index fc01fbe7..6df07068 100644 --- a/README.rst +++ b/README.rst @@ -7,18 +7,17 @@ A python library for parsing, manipulating, and generating STIX v1.2 content. :Documentation: http://stix.readthedocs.org :Information: https://stixproject.github.io/ -|travis badge| |landscape.io badge| |version badge| |downloads badge| +|travis_badge| |landscape_io_badge| |version_badge| -.. |travis badge| image:: https://api.travis-ci.org/STIXProject/python-stix.svg?branch=master +.. |travis_badge| image:: https://api.travis-ci.org/STIXProject/python-stix.svg?branch=master :target: https://travis-ci.org/STIXProject/python-stix :alt: Build Status -.. |landscape.io badge| image:: https://landscape.io/github/STIXProject/python-stix/master/landscape.svg +.. |landscape_io_badge| image:: https://landscape.io/github/STIXProject/python-stix/master/landscape.svg :target: https://landscape.io/github/STIXProject/python-stix/master :alt: Code Health -.. |version badge| image:: https://img.shields.io/pypi/v/stix.svg?maxAge=3600 - :target: https://pypi.python.org/pypi/stix/ -.. |downloads badge| image:: https://img.shields.io/pypi/dm/stix.svg?maxAge=3600 +.. |version_badge| image:: https://img.shields.io/pypi/v/stix.svg?maxAge=3600 :target: https://pypi.python.org/pypi/stix/ + :alt: Version Installation diff --git a/requirements.txt b/requirements.txt index c27803bd..14df8061 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -maec>=4.1.0.13.dev4,<4.1.1.0 # For tests that include MAEC +maec>=4.1.0.13,<4.1.1.0 # For tests that include MAEC nose==1.3.7 sphinx==1.3.6 sphinx_rtd_theme==0.2.4 diff --git a/stix/bindings/extensions/malware/maec_4_1.py b/stix/bindings/extensions/malware/maec_4_1.py index e32a0ad1..0e0d5a5d 100644 --- a/stix/bindings/extensions/malware/maec_4_1.py +++ b/stix/bindings/extensions/malware/maec_4_1.py @@ -15,12 +15,6 @@ from stix.bindings import register_extension import stix.bindings.ttp as ttp_binding -try: - from maec.bindings.maec_package import PackageType - maec_installed = True -except ImportError: - PackageType = None - maec_installed = False XML_NS = "http://stix.mitre.org/extensions/Malware#MAEC4.1-1" @@ -28,10 +22,11 @@ # Data representation classes. # + @register_extension class MAEC4_1InstanceType(ttp_binding.MalwareInstanceType): """The MAEC4.1InstanceType provides an extension to ttp_binding.MalwareInstanceType - which imports and leverages the MAEC 4.0.1 schema for structured + which imports and leverages the MAEC 4.1 schema for structured characterization of Malware.""" subclass = None superclass = ttp_binding.MalwareInstanceType @@ -89,11 +84,7 @@ def exportChildren(self, lwrite, level, nsmap, namespace_=XML_NS, name_='MAEC4.1 else: eol_ = '' if self.MAEC is not None: - if maec_installed and isinstance(self.MAEC, PackageType): - self.MAEC.export(lwrite, level, namespace_='stix-maec:', name_='MAEC', pretty_print=pretty_print) - else: - showIndent(lwrite, level, pretty_print) - lwrite(etree_.tostring(self.MAEC, pretty_print=pretty_print).decode()) + self.MAEC.export(lwrite, level, namespace_='stix-maec:', name_='MAEC', pretty_print=pretty_print) def build(self, node): self.__sourcenode__ = node already_processed = set() @@ -105,12 +96,11 @@ def buildAttributes(self, node, attrs, already_processed): super(MAEC4_1InstanceType, self).buildAttributes(node, attrs, already_processed) def buildChildren(self, child_, node, nodeName_, fromsubclass_=False): if nodeName_ == 'MAEC': - if maec_installed: - obj_ = PackageType.factory() - obj_.build(child_) - self.set_MAEC(obj_) - else: - self.set_MAEC(child_) + # Fails hard if maec library is not installed in your Python environment. + from maec.bindings.maec_package import PackageType + obj_ = PackageType.factory() + obj_.build(child_) + self.set_MAEC(obj_) super(MAEC4_1InstanceType, self).buildChildren(child_, node, nodeName_, True) # end class MAEC4_1InstanceType diff --git a/stix/extensions/malware/maec_4_1_malware.py b/stix/extensions/malware/maec_4_1_malware.py index b1f873c8..57e0751a 100644 --- a/stix/extensions/malware/maec_4_1_malware.py +++ b/stix/extensions/malware/maec_4_1_malware.py @@ -1,231 +1,37 @@ # Copyright (c) 2017, The MITRE Corporation. All rights reserved. # See LICENSE.txt for complete terms. -# stdlib -from distutils.version import LooseVersion - # external -from lxml import etree -import mixbox.xml -from mixbox.vendor.six import BytesIO, iteritems, binary_type +from mixbox import fields + # internal import stix -import stix.utils as utils -import stix.ttp.malware_instance +from stix.bindings.extensions.malware import maec_4_1 as maec_instance_binding from stix.ttp.malware_instance import MalwareInstance -import stix.bindings.extensions.malware.maec_4_1 as ext_binding -from mixbox import fields -from stix.bindings.extensions.malware.maec_4_1 import maec_installed -from lxml.etree import _ElementTree - -_MIN_PYTHON_MAEC_VERSION = '4.1.0.12' - - -class UnsupportedVersion(Exception): - def __init__(self, message, expected, found): - super(UnsupportedVersion, self).__init__(message) - self.expected = expected - self.found = found -def _check_maec_version(): - """Checks that the installed python-maec has a version greater than or - equal to the minimum supported version. - - Note: - We do this rather than having a python-maec dependency requirement - listed in setup.py because MAEC is used as an extension to STIX and - not a core component to STIX (like CybOX). - - Raises: - ImportError: If python-maec is not installed. - UnsupportedVersion: If the python-maec installation does not satisfy - the version requirements. - +@stix.register_extension +class MAECInstance(MalwareInstance): """ - import maec - - found = maec.__version__ - expected = _MIN_PYTHON_MAEC_VERSION - - if LooseVersion(found) >= LooseVersion(expected): - return + The MAECInstance object provides an extension to the MalwareInstanceType + which imports and leverages the MAEC 4.1 schema for structured + characterization of Malware. - fmt = ("Unsupported python-maec version installed: '%s'. Minimum version " - "is '%s'.") - error = fmt % (found, expected) - raise UnsupportedVersion(error, expected=expected, found=found) - - -try: - # Check that the correct version of python-maec is installed. - _check_maec_version() - - # Import maecPackage into global space - from maec.package.package import Package as maecPackage - - _MAEC_INSTALLED = True -except ImportError: - maecPackage, Package = None, None - _MAEC_INSTALLED = False - - -def is_maec(obj): - """Checks if the input object is python-maec object. - - Returns: - True if python-maec is ins + This class extension is automatically registered by the + MalwareInstanceFactory. + Warnings: + Interacting with the ``maec`` field will fail if the maec library is + not installed in your Python environment. """ - if not _MAEC_INSTALLED: - return False - - return isinstance(obj, maecPackage) - -def validate_maec_input(instance, value): - if value is None: - return - elif _MAEC_INSTALLED and is_maec(value): - return - elif mixbox.xml.is_element(value) or mixbox.xml.is_etree(value): - return - else: - error = ( - "Cannot set maec to '{0}'. Expected 'lxml.etree._Element' or " - "'maec.package.package.Package'." - ) - error = error.format(type(value)) - raise ValueError(error) - -@stix.register_extension -class MAECInstance(MalwareInstance): - _binding = ext_binding + _binding = maec_instance_binding _binding_class = _binding.MAEC4_1InstanceType - _namespace = 'http://stix.mitre.org/extensions/Malware#MAEC4.1-1' - _xml_ns_prefix = "stix-maec" + _namespace = "http://stix.mitre.org/extensions/Malware#MAEC4.1-1" _XSI_TYPE = "stix-maec:MAEC4.1InstanceType" - _TAG_MAEC = "{%s}MAEC" % _namespace - maec = fields.TypedField("MAEC", preset_hook=validate_maec_input) + maec = fields.TypedField("MAEC", type_="maec.package.package.Package") def __init__(self, maec=None): super(MAECInstance, self).__init__() - self.__input_namespaces__ = {} - self.__input_schemalocations__ = {} self.maec = maec - - def _parse_etree(self, root): - node_tag = root.tag - - if node_tag != self._TAG_MAEC: - self._cast_maec(root) - - self._collect_namespaces(root) - self._collect_schemalocs(root) - - def _cast_maec(self, node): - ns_maec = "http://maec.mitre.org/XMLSchema/maec-package-2" - node_ns = etree.QName(node).namespace - - if node_ns == ns_maec: - etree.register_namespace(self._xml_ns_prefix, self._namespace) - node.tag = self._TAG_MAEC - else: - error = "Cannot set maec. Expected tag '{0}' found '{1}'." - error = error.format(self._TAG_MAEC, node.tag) - raise ValueError(error) - - def _collect_schemalocs(self, node): - try: - schemaloc = mixbox.xml.get_schemaloc_pairs(node) - self.__input_schemalocations__ = dict(schemaloc) - except KeyError: - self.__input_schemalocations__ = {} - - def _collect_namespaces(self, node): - self.__input_namespaces__ = dict(iteritems(node.nsmap)) - - @classmethod - def from_obj(cls, obj): - if not obj: - return None - - return_obj = cls() - - if _MAEC_INSTALLED: - obj.MAEC = maecPackage.from_obj(obj.MAEC) - else: - obj.MAEC = obj.MAEC - - return_obj = super(MAECInstance, cls).from_obj(obj) - - return return_obj - - def to_obj(self, ns_info=None): - return_obj = super(MAECInstance, self).to_obj(ns_info=ns_info) - - if mixbox.xml.is_element(self.maec) or mixbox.xml.is_etree(self.maec): - tree = mixbox.xml.get_etree(self.maec) - root = mixbox.xml.get_etree_root(tree) - self._parse_etree(root) - self.maec = root - - if _MAEC_INSTALLED and isinstance(self.maec, maecPackage): - return_obj.MAEC = self.maec.to_obj(ns_info=ns_info) - else: - return_obj.MAEC = self.maec - - return return_obj - - @classmethod - def _maec_from_dict(cls, d): - if _MAEC_INSTALLED: - return maecPackage.from_dict(d) - - raise ValueError( - "Unable to parse 'maec' value in dictionary. Please " - "install python-maec to parse dictionary value." - ) - - @classmethod - def from_dict(cls, d, return_obj=None): - if not d: - return None - - d = d.copy() - - maec = d.get('maec') - - if maec is None: - pass - elif isinstance(maec, dict): - d['maec'] = cls._maec_from_dict(maec) - elif isinstance(maec, binary_type): - d['maec'] = mixbox.xml.get_etree_root(BytesIO(maec)) - else: - raise TypeError("Unknown type for 'maec' entry.") - - return_obj = super(MAECInstance, cls).from_dict(d) - - return return_obj - - def to_dict(self): - d = super(MAECInstance, self).to_dict() - - if self.maec is not None: - if mixbox.xml.is_element(self.maec) or mixbox.xml.is_etree(self.maec): - tree = mixbox.xml.get_etree(self.maec) - root = mixbox.xml.get_etree_root(tree) - self._parse_etree(root) - self.maec = root - - if _MAEC_INSTALLED and isinstance(self.maec, maecPackage): - d['maec'] = self.maec.to_dict() - else: - d['maec'] = etree.tostring(self.maec) - - if self._XSI_TYPE: - d['xsi:type'] = self._XSI_TYPE - - return d diff --git a/stix/indicator/indicator.py b/stix/indicator/indicator.py index a9549717..5842209f 100644 --- a/stix/indicator/indicator.py +++ b/stix/indicator/indicator.py @@ -281,7 +281,7 @@ def observables(self, value): elif utils.is_sequence(value): if len(value) == 1: - self.observable = value + self.add_observable(value[0]) return observable_comp = ObservableComposition() diff --git a/stix/report/__init__.py b/stix/report/__init__.py index bf994581..c2d18b3b 100644 --- a/stix/report/__init__.py +++ b/stix/report/__init__.py @@ -69,7 +69,7 @@ class Report(stix.Entity): indicators = fields.TypedField("Indicators", type_="stix.report.Indicators") incidents = fields.TypedField("Incidents", type_="stix.report.Incidents") threat_actors = fields.TypedField("Threat_Actors", type_="stix.report.ThreatActors") - ttps = fields.TypedField("TTPs", type_="stix.core.ttps.TTPs") + ttps = fields.TypedField("TTPs", type_="stix.report.TTPs") related_reports = fields.TypedField("Related_Reports", type_="stix.report.RelatedReports") def __init__(self, id_=None, idref=None, timestamp=None, header=None, @@ -207,6 +207,7 @@ def add(self, entity): error = error.format(type(entity)) raise TypeError(error) + class Campaigns(stix.EntityList): _binding = report_binding _namespace = 'http://stix.mitre.org/Report-1' @@ -243,7 +244,6 @@ class Indicators(stix.EntityList): _binding = report_binding _namespace = 'http://stix.mitre.org/Report-1' _binding_class = _binding.IndicatorsType - _contained_type = Indicator indicator = fields.TypedField("Indicator", Indicator, multiple=True, key_name="indicators") @@ -255,4 +255,10 @@ class ThreatActors(stix.EntityList): threat_actor = fields.TypedField("Threat_Actor", ThreatActor, multiple=True, key_name="threat_actors") -from stix.core.ttps import TTPs + +class TTPs(stix.EntityList): + _binding = report_binding + _namespace = 'http://stix.mitre.org/Report-1' + _binding_class = _binding.TTPsType + + ttp = fields.TypedField("TTP", TTP, multiple=True, key_name="ttps") diff --git a/stix/test/core/stix_package_test.py b/stix/test/core/stix_package_test.py index bfde2d15..47e31db4 100644 --- a/stix/test/core/stix_package_test.py +++ b/stix/test/core/stix_package_test.py @@ -12,7 +12,7 @@ from . import stix_header_test -from stix import core +from stix import core, report from stix.core import stix_package from stix.campaign import Campaign from stix.coa import CourseOfAction @@ -39,6 +39,7 @@ class COAsTests(EntityTestCase, unittest.TestCase): {'idref': 'example:test-1'} ] + class ExploitTargetsTests(EntityTestCase, unittest.TestCase): klass = stix_package.ExploitTargets @@ -46,6 +47,7 @@ class ExploitTargetsTests(EntityTestCase, unittest.TestCase): {'idref': 'example:test-1'} ] + class IncidentsTests(EntityTestCase, unittest.TestCase): klass = stix_package.Incidents @@ -53,6 +55,7 @@ class IncidentsTests(EntityTestCase, unittest.TestCase): {'idref': 'example:test-1'} ] + class IndicatorsTests(EntityTestCase, unittest.TestCase): klass = stix_package.Indicators @@ -68,6 +71,7 @@ class ThreatActorsTests(EntityTestCase, unittest.TestCase): {'idref': 'example:test-1'} ] + class TTPsTests(EntityTestCase, unittest.TestCase): klass = stix_package.TTPs @@ -188,6 +192,9 @@ def test_related_package_idref_deprecation(self): package = core.STIXPackage() package.add_related_package(core.STIXPackage(idref='foo')) + def test_setting_report_ttps_fails_on_stix_package(self): + self.assertRaises(TypeError, core.STIXPackage(), report.TTPs(), 'TTPs must be a , not a ') + if __name__ == "__main__": unittest.main() diff --git a/stix/test/extensions/malware/maec_4_1_malware_test.py b/stix/test/extensions/malware/maec_4_1_malware_test.py index d8e9084b..8d74d4c0 100644 --- a/stix/test/extensions/malware/maec_4_1_malware_test.py +++ b/stix/test/extensions/malware/maec_4_1_malware_test.py @@ -43,75 +43,76 @@ def test_add_name_type(self): self.assertTrue("Remote Access Trojan" in maec_xml) -class PythonMAECEtreeTests(unittest.TestCase): - XML = ( - """ - - - - - - - - MD5 - 9d7006e30fdf15e9c8e03e62534b3a3e - - - - - - - - """ - ) - - def _test_xml(self, obj): - xml = obj.to_xml() - parser = mixbox.xml.get_xml_parser() - tree = etree.parse(BytesIO(xml), parser=parser) - root = tree.getroot() - - xpath = "//cyboxCommon:Type" - nodes = root.xpath(xpath, namespaces={'cyboxCommon': 'http://cybox.mitre.org/common-2'}) - - self.assertTrue(nodes is not None) - self.assertEqual(len(nodes), 1) - self.assertEqual(nodes[0].text, "MD5") - - - def test_etree(self): - parser = mixbox.xml.get_xml_parser() - tree = etree.parse(StringIO(self.XML), parser=parser) - - ext = MAECInstance() - ext.maec = tree - self._test_xml(ext) - - - def test_etree_dict(self): - parser = mixbox.xml.get_xml_parser() - tree = etree.parse(StringIO(self.XML), parser=parser) - ext = MAECInstance() - ext.maec = tree - - d = ext.to_dict() - ext2 = MAECInstance.from_dict(d) - self._test_xml(ext2) +# This test no longer reflects the proper usage of the MAECInstance object +# class PythonMAECEtreeTests(unittest.TestCase): +# XML = ( +# """ +# +# +# +# +# +# +# +# MD5 +# 9d7006e30fdf15e9c8e03e62534b3a3e +# +# +# +# +# +# +# +# """ +# ) +# +# def _test_xml(self, obj): +# xml = obj.to_xml() +# parser = mixbox.xml.get_xml_parser() +# tree = etree.parse(BytesIO(xml), parser=parser) +# root = tree.getroot() +# +# xpath = "//cyboxCommon:Type" +# nodes = root.xpath(xpath, namespaces={'cyboxCommon': 'http://cybox.mitre.org/common-2'}) +# +# self.assertTrue(nodes is not None) +# self.assertEqual(len(nodes), 1) +# self.assertEqual(nodes[0].text, "MD5") +# +# +# def test_etree(self): +# parser = mixbox.xml.get_xml_parser() +# tree = etree.parse(StringIO(self.XML), parser=parser) +# +# ext = MAECInstance() +# ext.maec = tree +# self._test_xml(ext) +# +# +# def test_etree_dict(self): +# parser = mixbox.xml.get_xml_parser() +# tree = etree.parse(StringIO(self.XML), parser=parser) +# ext = MAECInstance() +# ext.maec = tree +# +# d = ext.to_dict() +# ext2 = MAECInstance.from_dict(d) +# self._test_xml(ext2) class PythonMAECInPackageTests(unittest.TestCase): diff --git a/stix/test/report_test.py b/stix/test/report_test.py index 264276db..f99ecb7f 100644 --- a/stix/test/report_test.py +++ b/stix/test/report_test.py @@ -4,10 +4,11 @@ import unittest from stix import report +from stix.core.ttps import TTPs from stix.test import EntityTestCase, data_marking_test -from stix.test.common import (kill_chains_test, information_source_test, - structured_text_test, related_test) +from stix.test.common import (information_source_test, structured_text_test, + related_test) class HeaderTests(EntityTestCase, unittest.TestCase): @@ -50,6 +51,7 @@ class COAsTests(EntityTestCase, unittest.TestCase): {'idref': 'example:test-1'} ] + class ExploitTargetsTests(EntityTestCase, unittest.TestCase): klass = report.ExploitTargets @@ -57,6 +59,7 @@ class ExploitTargetsTests(EntityTestCase, unittest.TestCase): {'idref': 'example:test-1'} ] + class IncidentsTests(EntityTestCase, unittest.TestCase): klass = report.Incidents @@ -64,6 +67,7 @@ class IncidentsTests(EntityTestCase, unittest.TestCase): {'idref': 'example:test-1'} ] + class IndicatorsTests(EntityTestCase, unittest.TestCase): klass = report.Indicators @@ -83,12 +87,9 @@ class ThreatActorsTests(EntityTestCase, unittest.TestCase): class TTPsTests(EntityTestCase, unittest.TestCase): klass = report.TTPs - _full_dict = { - 'kill_chains': kill_chains_test.KillChainsTests._full_dict, - 'ttps': [ - {'idref': 'example:test-1'} - ] - } + _full_dict = [ + {'idref': 'example:test-1'} + ] class ReportTests(EntityTestCase, unittest.TestCase): @@ -116,6 +117,9 @@ class ReportTests(EntityTestCase, unittest.TestCase): 'version': "1.0" } + def test_report_with_stix_core_ttps_fails(self): + self.assertRaises(TypeError, self.klass().ttps, TTPs(), 'TTPs must be a , not a ') + if __name__ == "__main__": unittest.main() diff --git a/stix/ttp/malware_instance.py b/stix/ttp/malware_instance.py index 0d3b6ca7..d80359bd 100644 --- a/stix/ttp/malware_instance.py +++ b/stix/ttp/malware_instance.py @@ -3,7 +3,6 @@ # internal import stix -import stix.utils as utils from stix.common import vocabs from stix.common import StructuredTextList, VocabString @@ -17,7 +16,7 @@ class MalwareInstance(stix.Entity): _binding = ttp_binding _binding_class = _binding.MalwareInstanceType _namespace = "http://stix.mitre.org/TTP-1" - _XSI_TYPE = None # defined by subclasses + _XSI_TYPE = None # defined by subclasses id_ = fields.IdField("id") idref = fields.IdrefField("idref") @@ -105,11 +104,21 @@ def lookup_class(xsi_type): return stix.lookup_extension(xsi_type) + def to_dict(self): + d = super(MalwareInstance, self).to_dict() + + if self._XSI_TYPE: + d["xsi:type"] = self._XSI_TYPE + + return d + class MalwareInstanceFactory(entities.EntityFactory): @classmethod def entity_class(cls, key): + from stix.extensions.malware.maec_4_1_malware import MAECInstance # noqa return stix.lookup_extension(key, default=MalwareInstance) + # Backwards compatibility add_extension = stix.add_extension