Repository: oasis-open/cti-python-stix2 (python)

Issue:
Timestamp format sometimes incorrect for `created` and `modified`
The STIX specification says:

> The created timestamp MUST be precise to the nearest millisecond (exactly three digits after the decimal place in seconds).
> The modified timestamp MUST be precise to the nearest millisecond (exactly three digits after the decimal place in seconds).

This is to avoid ambiguity in whether one timestamp equals another for purposes of versioning.

python-stix2 doesn't seem to always do this (unless I'm missing something).


Pull request:
Add timestamp precision for `created` and `modified`
Fix #24

Patch:
diff --git a/stix2/base.py b/stix2/base.py
index cb17f11..7de193b 100644
--- a/stix2/base.py
+++ b/stix2/base.py
@@ -176,7 +176,7 @@ class _STIXBase(collections.Mapping):
         if 'modified' not in kwargs:
             kwargs['modified'] = get_timestamp()
         else:
-            new_modified_property = parse_into_datetime(kwargs['modified'])
+            new_modified_property = parse_into_datetime(kwargs['modified'], precision='millisecond')
             if new_modified_property < self.modified:
                 raise InvalidValueError(cls, 'modified', "The new modified datetime cannot be before the current modified datatime.")
         new_obj_inner.update(kwargs)
diff --git a/stix2/common.py b/stix2/common.py
index c8c243d..7c6e747 100644
--- a/stix2/common.py
+++ b/stix2/common.py
@@ -7,8 +7,8 @@ from .utils import NOW
 
 COMMON_PROPERTIES = {
     # 'type' and 'id' should be defined on each individual type
-    'created': TimestampProperty(default=lambda: NOW),
-    'modified': TimestampProperty(default=lambda: NOW),
+    'created': TimestampProperty(default=lambda: NOW, precision='millisecond'),
+    'modified': TimestampProperty(default=lambda: NOW, precision='millisecond'),
     'external_references': ListProperty(ExternalReference),
     'revoked': BooleanProperty(),
     'labels': ListProperty(StringProperty),
diff --git a/stix2/observables.py b/stix2/observables.py
index 086dc45..a8f3b67 100644
--- a/stix2/observables.py
+++ b/stix2/observables.py
@@ -215,7 +215,7 @@ class WindowsPEBinaryExt(_Extension):
         'imphash': StringProperty(),
         'machine_hex': HexProperty(),
         'number_of_sections': IntegerProperty(),
-        'time_date_stamp': TimestampProperty(),
+        'time_date_stamp': TimestampProperty(precision='second'),
         'pointer_to_symbol_table_hex': HexProperty(),
         'number_of_symbols': IntegerProperty(),
         'size_of_optional_header': IntegerProperty(),
diff --git a/stix2/properties.py b/stix2/properties.py
index 9d54cf0..80e5345 100644
--- a/stix2/properties.py
+++ b/stix2/properties.py
@@ -1,18 +1,15 @@
 import base64
 import binascii
 import collections
-import datetime as dt
 import inspect
 import re
 import uuid
 
-from dateutil import parser
-import pytz
 from six import text_type
 
 from .base import _Observable, _STIXBase
 from .exceptions import DictionaryKeyError
-from .utils import get_dict
+from .utils import get_dict, parse_into_datetime
 
 
 class Property(object):
@@ -215,26 +212,12 @@ class BooleanProperty(Property):
 
 class TimestampProperty(Property):
 
-    def clean(self, value):
-        if isinstance(value, dt.date):
-            if hasattr(value, 'hour'):
-                return value
-            else:
-                # Add a time component
-                return dt.datetime.combine(value, dt.time(), tzinfo=pytz.utc)
+    def __init__(self, precision=None, **kwargs):
+        self.precision = precision
+        super(TimestampProperty, self).__init__(**kwargs)
 
-        # value isn't a date or datetime object so assume it's a string
-        try:
-            parsed = parser.parse(value)
-        except TypeError:
-            # Unknown format
-            raise ValueError("must be a datetime object, date object, or "
-                             "timestamp string in a recognizable format.")
-        if parsed.tzinfo:
-            return parsed.astimezone(pytz.utc)
-        else:
-            # Doesn't have timezone info in the string; assume UTC
-            return pytz.utc.localize(parsed)
+    def clean(self, value):
+        return parse_into_datetime(value, self.precision)
 
 
 class ObservableProperty(Property):
diff --git a/stix2/utils.py b/stix2/utils.py
index bb41937..12b889c 100644
--- a/stix2/utils.py
+++ b/stix2/utils.py
@@ -12,15 +12,29 @@ import pytz
 NOW = object()
 
 
+class STIXdatetime(dt.datetime):
+    def __new__(cls, *args, **kwargs):
+        precision = kwargs.pop('precision', None)
+        if isinstance(args[0], dt.datetime):  # Allow passing in a datetime object
+            dttm = args[0]
+            args = (dttm.year, dttm.month, dttm.day, dttm.hour, dttm.minute,
+                    dttm.second, dttm.microsecond, dttm.tzinfo)
+        # self will be an instance of STIXdatetime, not dt.datetime
+        self = dt.datetime.__new__(cls, *args, **kwargs)
+        self.precision = precision
+        return self
+
+
 def get_timestamp():
-    return dt.datetime.now(tz=pytz.UTC)
+    return STIXdatetime.now(tz=pytz.UTC)
 
 
 def format_datetime(dttm):
     # 1. Convert to timezone-aware
     # 2. Convert to UTC
     # 3. Format in ISO format
-    # 4. Add subsecond value if non-zero
+    # 4. Ensure correct precision
+    # 4a. Add subsecond value if non-zero and precision not defined
     # 5. Add "Z"
 
     if dttm.tzinfo is None or dttm.tzinfo.utcoffset(dttm) is None:
@@ -29,32 +43,53 @@ def format_datetime(dttm):
     else:
         zoned = dttm.astimezone(pytz.utc)
     ts = zoned.strftime("%Y-%m-%dT%H:%M:%S")
-    if zoned.microsecond > 0:
-        ms = zoned.strftime("%f")
+    ms = zoned.strftime("%f")
+    precision = getattr(dttm, "precision", None)
+    if precision == 'second':
+        pass  # Alredy precise to the second
+    elif precision == "millisecond":
+        ts = ts + '.' + ms[:3]
+    elif zoned.microsecond > 0:
         ts = ts + '.' + ms.rstrip("0")
     return ts + "Z"
 
 
-def parse_into_datetime(value):
+def parse_into_datetime(value, precision=None):
     if isinstance(value, dt.date):
         if hasattr(value, 'hour'):
-            return value
+            ts = value
         else:
             # Add a time component
-            return dt.datetime.combine(value, dt.time(0, 0, tzinfo=pytz.utc))
-
-    # value isn't a date or datetime object so assume it's a string
-    try:
-        parsed = parser.parse(value)
-    except (TypeError, ValueError):
-        # Unknown format
-        raise ValueError("must be a datetime object, date object, or "
-                         "timestamp string in a recognizable format.")
-    if parsed.tzinfo:
-        return parsed.astimezone(pytz.utc)
+            ts = dt.datetime.combine(value, dt.time(0, 0, tzinfo=pytz.utc))
     else:
-        # Doesn't have timezone info in the string; assume UTC
-        return pytz.utc.localize(parsed)
+        # value isn't a date or datetime object so assume it's a string
+        try:
+            parsed = parser.parse(value)
+        except (TypeError, ValueError):
+            # Unknown format
+            raise ValueError("must be a datetime object, date object, or "
+                             "timestamp string in a recognizable format.")
+        if parsed.tzinfo:
+            ts = parsed.astimezone(pytz.utc)
+        else:
+            # Doesn't have timezone info in the string; assume UTC
+            ts = pytz.utc.localize(parsed)
+
+    # Ensure correct precision
+    if not precision:
+        return ts
+    ms = ts.microsecond
+    if precision == 'second':
+        ts = ts.replace(microsecond=0)
+    elif precision == 'millisecond':
+        ms_len = len(str(ms))
+        if ms_len > 3:
+            # Truncate to millisecond precision
+            factor = 10 ** (ms_len - 3)
+            ts = ts.replace(microsecond=(ts.microsecond // factor) * factor)
+        else:
+            ts = ts.replace(microsecond=0)
+    return STIXdatetime(ts, precision=precision)
 
 
 def get_dict(data):


Test patch:
diff --git a/stix2/test/conftest.py b/stix2/test/conftest.py
index d1f3330..9f61bc2 100644
--- a/stix2/test/conftest.py
+++ b/stix2/test/conftest.py
@@ -1,4 +1,3 @@
-import datetime as dt
 import uuid
 
 import pytest
@@ -13,12 +12,12 @@ from .constants import (FAKE_TIME, INDICATOR_KWARGS, MALWARE_KWARGS,
 @pytest.fixture
 def clock(monkeypatch):
 
-    class mydatetime(dt.datetime):
+    class mydatetime(stix2.utils.STIXdatetime):
         @classmethod
         def now(cls, tz=None):
             return FAKE_TIME
 
-    monkeypatch.setattr(dt, 'datetime', mydatetime)
+    monkeypatch.setattr(stix2.utils, 'STIXdatetime', mydatetime)
 
 
 @pytest.fixture
diff --git a/stix2/test/test_attack_pattern.py b/stix2/test/test_attack_pattern.py
index 7510888..5bd5af2 100644
--- a/stix2/test/test_attack_pattern.py
+++ b/stix2/test/test_attack_pattern.py
@@ -9,7 +9,7 @@ from .constants import ATTACK_PATTERN_ID
 
 
 EXPECTED = """{
-    "created": "2016-05-12T08:17:27Z",
+    "created": "2016-05-12T08:17:27.000Z",
     "description": "...",
     "external_references": [
         {
@@ -18,7 +18,7 @@ EXPECTED = """{
         }
     ],
     "id": "attack-pattern--0c7b5b88-8ff7-4a4d-aa9d-feb398cd0061",
-    "modified": "2016-05-12T08:17:27Z",
+    "modified": "2016-05-12T08:17:27.000Z",
     "name": "Spear Phishing",
     "type": "attack-pattern"
 }"""
@@ -27,8 +27,8 @@ EXPECTED = """{
 def test_attack_pattern_example():
     ap = stix2.AttackPattern(
         id="attack-pattern--0c7b5b88-8ff7-4a4d-aa9d-feb398cd0061",
-        created="2016-05-12T08:17:27Z",
-        modified="2016-05-12T08:17:27Z",
+        created="2016-05-12T08:17:27.000Z",
+        modified="2016-05-12T08:17:27.000Z",
         name="Spear Phishing",
         external_references=[{
             "source_name": "capec",
@@ -45,8 +45,8 @@ def test_attack_pattern_example():
     {
         "type": "attack-pattern",
         "id": "attack-pattern--0c7b5b88-8ff7-4a4d-aa9d-feb398cd0061",
-        "created": "2016-05-12T08:17:27Z",
-        "modified": "2016-05-12T08:17:27Z",
+        "created": "2016-05-12T08:17:27.000Z",
+        "modified": "2016-05-12T08:17:27.000Z",
         "description": "...",
         "external_references": [
             {
diff --git a/stix2/test/test_bundle.py b/stix2/test/test_bundle.py
index d52a955..fc3e350 100644
--- a/stix2/test/test_bundle.py
+++ b/stix2/test/test_bundle.py
@@ -7,30 +7,30 @@ EXPECTED_BUNDLE = """{
     "id": "bundle--00000000-0000-0000-0000-000000000004",
     "objects": [
         {
-            "created": "2017-01-01T12:34:56Z",
+            "created": "2017-01-01T12:34:56.000Z",
             "id": "indicator--00000000-0000-0000-0000-000000000001",
             "labels": [
                 "malicious-activity"
             ],
-            "modified": "2017-01-01T12:34:56Z",
+            "modified": "2017-01-01T12:34:56.000Z",
             "pattern": "[file:hashes.MD5 = 'd41d8cd98f00b204e9800998ecf8427e']",
             "type": "indicator",
             "valid_from": "2017-01-01T12:34:56Z"
         },
         {
-            "created": "2017-01-01T12:34:56Z",
+            "created": "2017-01-01T12:34:56.000Z",
             "id": "malware--00000000-0000-0000-0000-000000000002",
             "labels": [
                 "ransomware"
             ],
-            "modified": "2017-01-01T12:34:56Z",
+            "modified": "2017-01-01T12:34:56.000Z",
             "name": "Cryptolocker",
             "type": "malware"
         },
         {
-            "created": "2017-01-01T12:34:56Z",
+            "created": "2017-01-01T12:34:56.000Z",
             "id": "relationship--00000000-0000-0000-0000-000000000003",
-            "modified": "2017-01-01T12:34:56Z",
+            "modified": "2017-01-01T12:34:56.000Z",
             "relationship_type": "indicates",
             "source_ref": "indicator--01234567-89ab-cdef-0123-456789abcdef",
             "target_ref": "malware--fedcba98-7654-3210-fedc-ba9876543210",
diff --git a/stix2/test/test_campaign.py b/stix2/test/test_campaign.py
index 9920019..30b9444 100644
--- a/stix2/test/test_campaign.py
+++ b/stix2/test/test_campaign.py
@@ -9,11 +9,11 @@ from .constants import CAMPAIGN_ID
 
 
 EXPECTED = """{
-    "created": "2016-04-06T20:03:00Z",
+    "created": "2016-04-06T20:03:00.000Z",
     "created_by_ref": "identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
     "description": "Campaign by Green Group against a series of targets in the financial services sector.",
     "id": "campaign--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f",
-    "modified": "2016-04-06T20:03:00Z",
+    "modified": "2016-04-06T20:03:00.000Z",
     "name": "Green Group Attacks Against Finance",
     "type": "campaign"
 }"""
diff --git a/stix2/test/test_course_of_action.py b/stix2/test/test_course_of_action.py
index 263eae2..e7a1b22 100644
--- a/stix2/test/test_course_of_action.py
+++ b/stix2/test/test_course_of_action.py
@@ -9,11 +9,11 @@ from .constants import COURSE_OF_ACTION_ID
 
 
 EXPECTED = """{
-    "created": "2016-04-06T20:03:48Z",
+    "created": "2016-04-06T20:03:48.000Z",
     "created_by_ref": "identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
     "description": "This is how to add a filter rule to block inbound access to TCP port 80 to the existing UDP 1434 filter ...",
     "id": "course-of-action--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f",
-    "modified": "2016-04-06T20:03:48Z",
+    "modified": "2016-04-06T20:03:48.000Z",
     "name": "Add TCP port 80 Filter Rule to the existing Block UDP 1434 Filter",
     "type": "course-of-action"
 }"""
@@ -23,8 +23,8 @@ def test_course_of_action_example():
     coa = stix2.CourseOfAction(
         id="course-of-action--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f",
         created_by_ref="identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
-        created="2016-04-06T20:03:48Z",
-        modified="2016-04-06T20:03:48Z",
+        created="2016-04-06T20:03:48.000Z",
+        modified="2016-04-06T20:03:48.000Z",
         name="Add TCP port 80 Filter Rule to the existing Block UDP 1434 Filter",
         description="This is how to add a filter rule to block inbound access to TCP port 80 to the existing UDP 1434 filter ..."
     )
@@ -35,11 +35,11 @@ def test_course_of_action_example():
 @pytest.mark.parametrize("data", [
     EXPECTED,
     {
-        "created": "2016-04-06T20:03:48Z",
+        "created": "2016-04-06T20:03:48.000Z",
         "created_by_ref": "identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
         "description": "This is how to add a filter rule to block inbound access to TCP port 80 to the existing UDP 1434 filter ...",
         "id": "course-of-action--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f",
-        "modified": "2016-04-06T20:03:48Z",
+        "modified": "2016-04-06T20:03:48.000Z",
         "name": "Add TCP port 80 Filter Rule to the existing Block UDP 1434 Filter",
         "type": "course-of-action"
     },
diff --git a/stix2/test/test_fixtures.py b/stix2/test/test_fixtures.py
index 9078972..83d5f85 100644
--- a/stix2/test/test_fixtures.py
+++ b/stix2/test/test_fixtures.py
@@ -1,11 +1,12 @@
-import datetime as dt
 import uuid
 
+from stix2 import utils
+
 from .constants import FAKE_TIME
 
 
 def test_clock(clock):
-    assert dt.datetime.now() == FAKE_TIME
+    assert utils.STIXdatetime.now() == FAKE_TIME
 
 
 def test_my_uuid4_fixture(uuid4):
diff --git a/stix2/test/test_identity.py b/stix2/test/test_identity.py
index b2c166c..ed51958 100644
--- a/stix2/test/test_identity.py
+++ b/stix2/test/test_identity.py
@@ -9,10 +9,10 @@ from .constants import IDENTITY_ID
 
 
 EXPECTED = """{
-    "created": "2015-12-21T19:59:11Z",
+    "created": "2015-12-21T19:59:11.000Z",
     "id": "identity--311b2d2d-f010-5473-83ec-1edf84858f4c",
     "identity_class": "individual",
-    "modified": "2015-12-21T19:59:11Z",
+    "modified": "2015-12-21T19:59:11.000Z",
     "name": "John Smith",
     "type": "identity"
 }"""
@@ -21,8 +21,8 @@ EXPECTED = """{
 def test_identity_example():
     identity = stix2.Identity(
         id="identity--311b2d2d-f010-5473-83ec-1edf84858f4c",
-        created="2015-12-21T19:59:11Z",
-        modified="2015-12-21T19:59:11Z",
+        created="2015-12-21T19:59:11.000Z",
+        modified="2015-12-21T19:59:11.000Z",
         name="John Smith",
         identity_class="individual",
     )
@@ -33,10 +33,10 @@ def test_identity_example():
 @pytest.mark.parametrize("data", [
     EXPECTED,
     {
-        "created": "2015-12-21T19:59:11Z",
+        "created": "2015-12-21T19:59:11.000Z",
         "id": "identity--311b2d2d-f010-5473-83ec-1edf84858f4c",
         "identity_class": "individual",
-        "modified": "2015-12-21T19:59:11Z",
+        "modified": "2015-12-21T19:59:11.000Z",
         "name": "John Smith",
         "type": "identity"
     },
@@ -56,8 +56,8 @@ def test_parse_no_type():
         stix2.parse("""
         {
             "id": "identity--311b2d2d-f010-5473-83ec-1edf84858f4c",
-            "created": "2015-12-21T19:59:11Z",
-            "modified": "2015-12-21T19:59:11Z",
+            "created": "2015-12-21T19:59:11.000Z",
+            "modified": "2015-12-21T19:59:11.000Z",
             "name": "John Smith",
             "identity_class": "individual"
         }""")
diff --git a/stix2/test/test_indicator.py b/stix2/test/test_indicator.py
index 5db50e6..5daa0f6 100644
--- a/stix2/test/test_indicator.py
+++ b/stix2/test/test_indicator.py
@@ -10,22 +10,22 @@ from .constants import FAKE_TIME, INDICATOR_ID, INDICATOR_KWARGS
 
 
 EXPECTED_INDICATOR = """{
-    "created": "2017-01-01T00:00:01Z",
+    "created": "2017-01-01T00:00:01.000Z",
     "id": "indicator--01234567-89ab-cdef-0123-456789abcdef",
     "labels": [
         "malicious-activity"
     ],
-    "modified": "2017-01-01T00:00:01Z",
+    "modified": "2017-01-01T00:00:01.000Z",
     "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>),
+    created=STIXdatetime(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>),
+    modified=STIXdatetime(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>)
@@ -48,6 +48,8 @@ def test_indicator_with_all_required_properties():
 
     assert str(ind) == EXPECTED_INDICATOR
     rep = re.sub(r"(\[|=| )u('|\"|\\\'|\\\")", r"\g<1>\g<2>", repr(ind))
+    print(rep)
+    print(EXPECTED_INDICATOR_REPR)
     assert rep == EXPECTED_INDICATOR_REPR
 
 
diff --git a/stix2/test/test_intrusion_set.py b/stix2/test/test_intrusion_set.py
index 3241ced..a6eee7f 100644
--- a/stix2/test/test_intrusion_set.py
+++ b/stix2/test/test_intrusion_set.py
@@ -12,7 +12,7 @@ EXPECTED = """{
     "aliases": [
         "Zookeeper"
     ],
-    "created": "2016-04-06T20:03:48Z",
+    "created": "2016-04-06T20:03:48.000Z",
     "created_by_ref": "identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
     "description": "Incidents usually feature a shared TTP of a bobcat being released...",
     "goals": [
@@ -21,7 +21,7 @@ EXPECTED = """{
         "damage"
     ],
     "id": "intrusion-set--4e78f46f-a023-4e5f-bc24-71b3ca22ec29",
-    "modified": "2016-04-06T20:03:48Z",
+    "modified": "2016-04-06T20:03:48.000Z",
     "name": "Bobcat Breakin",
     "type": "intrusion-set"
 }"""
@@ -31,8 +31,8 @@ def test_intrusion_set_example():
     intrusion_set = stix2.IntrusionSet(
         id="intrusion-set--4e78f46f-a023-4e5f-bc24-71b3ca22ec29",
         created_by_ref="identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
-        created="2016-04-06T20:03:48Z",
-        modified="2016-04-06T20:03:48Z",
+        created="2016-04-06T20:03:48.000Z",
+        modified="2016-04-06T20:03:48.000Z",
         name="Bobcat Breakin",
         description="Incidents usually feature a shared TTP of a bobcat being released...",
         aliases=["Zookeeper"],
@@ -48,7 +48,7 @@ def test_intrusion_set_example():
         "aliases": [
             "Zookeeper"
         ],
-        "created": "2016-04-06T20:03:48Z",
+        "created": "2016-04-06T20:03:48.000Z",
         "created_by_ref": "identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
         "description": "Incidents usually feature a shared TTP of a bobcat being released...",
         "goals": [
@@ -57,7 +57,7 @@ def test_intrusion_set_example():
             "damage"
         ],
         "id": "intrusion-set--4e78f46f-a023-4e5f-bc24-71b3ca22ec29",
-        "modified": "2016-04-06T20:03:48Z",
+        "modified": "2016-04-06T20:03:48.000Z",
         "name": "Bobcat Breakin",
         "type": "intrusion-set"
     },
diff --git a/stix2/test/test_malware.py b/stix2/test/test_malware.py
index 266d012..ff0b394 100644
--- a/stix2/test/test_malware.py
+++ b/stix2/test/test_malware.py
@@ -10,12 +10,12 @@ from .constants import FAKE_TIME, MALWARE_ID, MALWARE_KWARGS
 
 
 EXPECTED_MALWARE = """{
-    "created": "2016-05-12T08:17:27Z",
+    "created": "2016-05-12T08:17:27.000Z",
     "id": "malware--fedcba98-7654-3210-fedc-ba9876543210",
     "labels": [
         "ransomware"
     ],
-    "modified": "2016-05-12T08:17:27Z",
+    "modified": "2016-05-12T08:17:27.000Z",
     "name": "Cryptolocker",
     "type": "malware"
 }"""
@@ -109,8 +109,8 @@ def test_invalid_kwarg_to_malware():
     {
         "type": "malware",
         "id": "malware--fedcba98-7654-3210-fedc-ba9876543210",
-        "created": "2016-05-12T08:17:27Z",
-        "modified": "2016-05-12T08:17:27Z",
+        "created": "2016-05-12T08:17:27.000Z",
+        "modified": "2016-05-12T08:17:27.000Z",
         "labels": ["ransomware"],
         "name": "Cryptolocker",
     },
diff --git a/stix2/test/test_markings.py b/stix2/test/test_markings.py
index f1f07db..c2e0276 100644
--- a/stix2/test/test_markings.py
+++ b/stix2/test/test_markings.py
@@ -40,7 +40,7 @@ EXPECTED_GRANULAR_MARKING = """{
 }"""
 
 EXPECTED_CAMPAIGN_WITH_GRANULAR_MARKINGS = """{
-    "created": "2016-04-06T20:03:00Z",
+    "created": "2016-04-06T20:03:00.000Z",
     "created_by_ref": "identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
     "description": "Campaign by Green Group against a series of targets in the financial services sector.",
     "granular_markings": [
@@ -52,7 +52,7 @@ EXPECTED_CAMPAIGN_WITH_GRANULAR_MARKINGS = """{
         }
     ],
     "id": "campaign--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f",
-    "modified": "2016-04-06T20:03:00Z",
+    "modified": "2016-04-06T20:03:00.000Z",
     "name": "Green Group Attacks Against Finance",
     "type": "campaign"
 }"""
diff --git a/stix2/test/test_observed_data.py b/stix2/test/test_observed_data.py
index 0ed9954..75f3070 100644
--- a/stix2/test/test_observed_data.py
+++ b/stix2/test/test_observed_data.py
@@ -10,12 +10,12 @@ from .constants import OBSERVED_DATA_ID
 
 
 EXPECTED = """{
-    "created": "2016-04-06T19:58:16Z",
+    "created": "2016-04-06T19:58:16.000Z",
     "created_by_ref": "identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
     "first_observed": "2015-12-21T19:00:00Z",
     "id": "observed-data--b67d30ff-02ac-498a-92f9-32f845f448cf",
     "last_observed": "2015-12-21T19:00:00Z",
-    "modified": "2016-04-06T19:58:16Z",
+    "modified": "2016-04-06T19:58:16.000Z",
     "number_observed": 50,
     "objects": {
         "0": {
@@ -31,8 +31,8 @@ def test_observed_data_example():
     observed_data = stix2.ObservedData(
         id="observed-data--b67d30ff-02ac-498a-92f9-32f845f448cf",
         created_by_ref="identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
-        created="2016-04-06T19:58:16Z",
-        modified="2016-04-06T19:58:16Z",
+        created="2016-04-06T19:58:16.000Z",
+        modified="2016-04-06T19:58:16.000Z",
         first_observed="2015-12-21T19:00:00Z",
         last_observed="2015-12-21T19:00:00Z",
         number_observed=50,
@@ -48,12 +48,12 @@ def test_observed_data_example():
 
 
 EXPECTED_WITH_REF = """{
-    "created": "2016-04-06T19:58:16Z",
+    "created": "2016-04-06T19:58:16.000Z",
     "created_by_ref": "identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
     "first_observed": "2015-12-21T19:00:00Z",
     "id": "observed-data--b67d30ff-02ac-498a-92f9-32f845f448cf",
     "last_observed": "2015-12-21T19:00:00Z",
-    "modified": "2016-04-06T19:58:16Z",
+    "modified": "2016-04-06T19:58:16.000Z",
     "number_observed": 50,
     "objects": {
         "0": {
@@ -76,8 +76,8 @@ def test_observed_data_example_with_refs():
     observed_data = stix2.ObservedData(
         id="observed-data--b67d30ff-02ac-498a-92f9-32f845f448cf",
         created_by_ref="identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
-        created="2016-04-06T19:58:16Z",
-        modified="2016-04-06T19:58:16Z",
+        created="2016-04-06T19:58:16.000Z",
+        modified="2016-04-06T19:58:16.000Z",
         first_observed="2015-12-21T19:00:00Z",
         last_observed="2015-12-21T19:00:00Z",
         number_observed=50,
@@ -102,8 +102,8 @@ def test_observed_data_example_with_bad_refs():
         stix2.ObservedData(
             id="observed-data--b67d30ff-02ac-498a-92f9-32f845f448cf",
             created_by_ref="identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
-            created="2016-04-06T19:58:16Z",
-            modified="2016-04-06T19:58:16Z",
+            created="2016-04-06T19:58:16.000Z",
+            modified="2016-04-06T19:58:16.000Z",
             first_observed="2015-12-21T19:00:00Z",
             last_observed="2015-12-21T19:00:00Z",
             number_observed=50,
@@ -130,11 +130,11 @@ def test_observed_data_example_with_bad_refs():
     {
         "type": "observed-data",
         "id": "observed-data--b67d30ff-02ac-498a-92f9-32f845f448cf",
-        "created": "2016-04-06T19:58:16Z",
+        "created": "2016-04-06T19:58:16.000Z",
         "created_by_ref": "identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
         "first_observed": "2015-12-21T19:00:00Z",
         "last_observed": "2015-12-21T19:00:00Z",
-        "modified": "2016-04-06T19:58:16Z",
+        "modified": "2016-04-06T19:58:16.000Z",
         "number_observed": 50,
         "objects": {
             "0": {
@@ -466,12 +466,12 @@ def test_parse_basic_tcp_traffic_with_error(data):
 
 
 EXPECTED_PROCESS_OD = """{
-    "created": "2016-04-06T19:58:16Z",
+    "created": "2016-04-06T19:58:16.000Z",
     "created_by_ref": "identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
     "first_observed": "2015-12-21T19:00:00Z",
     "id": "observed-data--b67d30ff-02ac-498a-92f9-32f845f448cf",
     "last_observed": "2015-12-21T19:00:00Z",
-    "modified": "2016-04-06T19:58:16Z",
+    "modified": "2016-04-06T19:58:16.000Z",
     "number_observed": 50,
     "objects": {
         "0": {
@@ -499,8 +499,8 @@ def test_observed_data_with_process_example():
     observed_data = stix2.ObservedData(
         id="observed-data--b67d30ff-02ac-498a-92f9-32f845f448cf",
         created_by_ref="identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
-        created="2016-04-06T19:58:16Z",
-        modified="2016-04-06T19:58:16Z",
+        created="2016-04-06T19:58:16.000Z",
+        modified="2016-04-06T19:58:16.000Z",
         first_observed="2015-12-21T19:00:00Z",
         last_observed="2015-12-21T19:00:00Z",
         number_observed=50,
diff --git a/stix2/test/test_relationship.py b/stix2/test/test_relationship.py
index 1ad792c..362348b 100644
--- a/stix2/test/test_relationship.py
+++ b/stix2/test/test_relationship.py
@@ -10,9 +10,9 @@ from .constants import (FAKE_TIME, INDICATOR_ID, MALWARE_ID, RELATIONSHIP_ID,
 
 
 EXPECTED_RELATIONSHIP = """{
-    "created": "2016-04-06T20:06:37Z",
+    "created": "2016-04-06T20:06:37.000Z",
     "id": "relationship--00000000-1111-2222-3333-444444444444",
-    "modified": "2016-04-06T20:06:37Z",
+    "modified": "2016-04-06T20:06:37.000Z",
     "relationship_type": "indicates",
     "source_ref": "indicator--01234567-89ab-cdef-0123-456789abcdef",
     "target_ref": "malware--fedcba98-7654-3210-fedc-ba9876543210",
diff --git a/stix2/test/test_report.py b/stix2/test/test_report.py
index cec217a..4a1f905 100644
--- a/stix2/test/test_report.py
+++ b/stix2/test/test_report.py
@@ -9,14 +9,14 @@ from .constants import INDICATOR_KWARGS, REPORT_ID
 
 
 EXPECTED = """{
-    "created": "2015-12-21T19:59:11Z",
+    "created": "2015-12-21T19:59:11.000Z",
     "created_by_ref": "identity--a463ffb3-1bd9-4d94-b02d-74e4f1658283",
     "description": "A simple report with an indicator and campaign",
     "id": "report--84e4d88f-44ea-4bcd-bbf3-b2c1c320bcb3",
     "labels": [
         "campaign"
     ],
-    "modified": "2015-12-21T19:59:11Z",
+    "modified": "2015-12-21T19:59:11.000Z",
     "name": "The Black Vine Cyberespionage Group",
     "object_refs": [
         "indicator--26ffb872-1dd9-446e-b6f5-d58527e5b5d2",
@@ -32,8 +32,8 @@ def test_report_example():
     report = stix2.Report(
         id="report--84e4d88f-44ea-4bcd-bbf3-b2c1c320bcb3",
         created_by_ref="identity--a463ffb3-1bd9-4d94-b02d-74e4f1658283",
-        created="2015-12-21T19:59:11Z",
-        modified="2015-12-21T19:59:11Z",
+        created="2015-12-21T19:59:11.000Z",
+        modified="2015-12-21T19:59:11.000Z",
         name="The Black Vine Cyberespionage Group",
         description="A simple report with an indicator and campaign",
         published="2016-01-20T17:00:00Z",
@@ -95,14 +95,14 @@ def test_report_example_objects_in_object_refs_with_bad_id():
 @pytest.mark.parametrize("data", [
     EXPECTED,
     {
-        "created": "2015-12-21T19:59:11Z",
+        "created": "2015-12-21T19:59:11.000Z",
         "created_by_ref": "identity--a463ffb3-1bd9-4d94-b02d-74e4f1658283",
         "description": "A simple report with an indicator and campaign",
         "id": "report--84e4d88f-44ea-4bcd-bbf3-b2c1c320bcb3",
         "labels": [
             "campaign"
         ],
-        "modified": "2015-12-21T19:59:11Z",
+        "modified": "2015-12-21T19:59:11.000Z",
         "name": "The Black Vine Cyberespionage Group",
         "object_refs": [
             "indicator--26ffb872-1dd9-446e-b6f5-d58527e5b5d2",
diff --git a/stix2/test/test_sighting.py b/stix2/test/test_sighting.py
index 6c9b7d8..2036457 100644
--- a/stix2/test/test_sighting.py
+++ b/stix2/test/test_sighting.py
@@ -9,9 +9,9 @@ from .constants import INDICATOR_ID, SIGHTING_ID, SIGHTING_KWARGS
 
 
 EXPECTED_SIGHTING = """{
-    "created": "2016-04-06T20:06:37Z",
+    "created": "2016-04-06T20:06:37.000Z",
     "id": "sighting--bfbc19db-ec35-4e45-beed-f8bde2a772fb",
-    "modified": "2016-04-06T20:06:37Z",
+    "modified": "2016-04-06T20:06:37.000Z",
     "sighting_of_ref": "indicator--01234567-89ab-cdef-0123-456789abcdef",
     "type": "sighting",
     "where_sighted_refs": [
@@ -20,9 +20,9 @@ EXPECTED_SIGHTING = """{
 }"""
 
 BAD_SIGHTING = """{
-    "created": "2016-04-06T20:06:37Z",
+    "created": "2016-04-06T20:06:37.000Z",
     "id": "sighting--bfbc19db-ec35-4e45-beed-f8bde2a772fb",
-    "modified": "2016-04-06T20:06:37Z",
+    "modified": "2016-04-06T20:06:37.000Z",
     "sighting_of_ref": "indicator--01234567-89ab-cdef-0123-456789abcdef",
     "type": "sighting",
     "where_sighted_refs": [
diff --git a/stix2/test/test_threat_actor.py b/stix2/test/test_threat_actor.py
index 93e8179..1bab744 100644
--- a/stix2/test/test_threat_actor.py
+++ b/stix2/test/test_threat_actor.py
@@ -9,14 +9,14 @@ from .constants import THREAT_ACTOR_ID
 
 
 EXPECTED = """{
-    "created": "2016-04-06T20:03:48Z",
+    "created": "2016-04-06T20:03:48.000Z",
     "created_by_ref": "identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
     "description": "The Evil Org threat actor group",
     "id": "threat-actor--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f",
     "labels": [
         "crime-syndicate"
     ],
-    "modified": "2016-04-06T20:03:48Z",
+    "modified": "2016-04-06T20:03:48.000Z",
     "name": "Evil Org",
     "type": "threat-actor"
 }"""
@@ -26,8 +26,8 @@ def test_threat_actor_example():
     threat_actor = stix2.ThreatActor(
         id="threat-actor--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f",
         created_by_ref="identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
-        created="2016-04-06T20:03:48Z",
-        modified="2016-04-06T20:03:48Z",
+        created="2016-04-06T20:03:48.000Z",
+        modified="2016-04-06T20:03:48.000Z",
         name="Evil Org",
         description="The Evil Org threat actor group",
         labels=["crime-syndicate"],
@@ -39,14 +39,14 @@ def test_threat_actor_example():
 @pytest.mark.parametrize("data", [
     EXPECTED,
     {
-        "created": "2016-04-06T20:03:48Z",
+        "created": "2016-04-06T20:03:48.000Z",
         "created_by_ref": "identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
         "description": "The Evil Org threat actor group",
         "id": "threat-actor--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f",
         "labels": [
             "crime-syndicate"
         ],
-        "modified": "2016-04-06T20:03:48Z",
+        "modified": "2016-04-06T20:03:48.000Z",
         "name": "Evil Org",
         "type": "threat-actor"
     },
diff --git a/stix2/test/test_tool.py b/stix2/test/test_tool.py
index d7d3bef..04da7b3 100644
--- a/stix2/test/test_tool.py
+++ b/stix2/test/test_tool.py
@@ -9,13 +9,13 @@ from .constants import TOOL_ID
 
 
 EXPECTED = """{
-    "created": "2016-04-06T20:03:48Z",
+    "created": "2016-04-06T20:03:48.000Z",
     "created_by_ref": "identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
     "id": "tool--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f",
     "labels": [
         "remote-access"
     ],
-    "modified": "2016-04-06T20:03:48Z",
+    "modified": "2016-04-06T20:03:48.000Z",
     "name": "VNC",
     "type": "tool"
 }"""
@@ -25,8 +25,8 @@ def test_tool_example():
     tool = stix2.Tool(
         id="tool--8e2e2d2b-17d4-4cbf-938f-98ee46b3cd3f",
         created_by_ref="identity--f431f809-377b-45e0-aa1c-6a4751cae5ff",
-        created="2016-04-06T20:03:48Z",
-        modified="2016-04-06T20:03:48Z",
+        created="2016-04-06T20:03:48.000Z",
+        modified="2016-04-06T20:03:48.000Z",
         name="VNC",
         labels=["remote-access"],
     )
diff --git a/stix2/test/test_utils.py b/stix2/test/test_utils.py
index dbc0ed5..c73bcd2 100644
--- a/stix2/test/test_utils.py
+++ b/stix2/test/test_utils.py
@@ -17,6 +17,8 @@ eastern = pytz.timezone('US/Eastern')
     (eastern.localize(dt.datetime(2017, 7, 1)), '2017-07-01T04:00:00Z'),
     (dt.datetime(2017, 7, 1), '2017-07-01T00:00:00Z'),
     (dt.datetime(2017, 7, 1, 0, 0, 0, 1), '2017-07-01T00:00:00.000001Z'),
+    (stix2.utils.STIXdatetime(2017, 7, 1, 0, 0, 0, 1, precision='millisecond'), '2017-07-01T00:00:00.000Z'),
+    (stix2.utils.STIXdatetime(2017, 7, 1, 0, 0, 0, 1, precision='second'), '2017-07-01T00:00:00Z'),
 ])
 def test_timestamp_formatting(dttm, timestamp):
     assert stix2.utils.format_datetime(dttm) == timestamp
@@ -33,6 +35,17 @@ def test_parse_datetime(timestamp, dttm):
     assert stix2.utils.parse_into_datetime(timestamp) == dttm
 
 
+@pytest.mark.parametrize('timestamp, dttm, precision', [
+    ('2017-01-01T01:02:03.000001', dt.datetime(2017, 1, 1, 1, 2, 3, 0, tzinfo=pytz.utc), 'millisecond'),
+    ('2017-01-01T01:02:03.001', dt.datetime(2017, 1, 1, 1, 2, 3, 1000, tzinfo=pytz.utc), 'millisecond'),
+    ('2017-01-01T01:02:03.1', dt.datetime(2017, 1, 1, 1, 2, 3, 100000, tzinfo=pytz.utc), 'millisecond'),
+    ('2017-01-01T01:02:03.45', dt.datetime(2017, 1, 1, 1, 2, 3, 450000, tzinfo=pytz.utc), 'millisecond'),
+    ('2017-01-01T01:02:03.45', dt.datetime(2017, 1, 1, 1, 2, 3, tzinfo=pytz.utc), 'second'),
+])
+def test_parse_datetime_precision(timestamp, dttm, precision):
+    assert stix2.utils.parse_into_datetime(timestamp, precision) == dttm
+
+
 @pytest.mark.parametrize('ts', [
     'foobar',
     1,
diff --git a/stix2/test/test_vulnerability.py b/stix2/test/test_vulnerability.py
index 751460c..27ab85f 100644
--- a/stix2/test/test_vulnerability.py
+++ b/stix2/test/test_vulnerability.py
@@ -9,7 +9,7 @@ from .constants import VULNERABILITY_ID
 
 
 EXPECTED = """{
-    "created": "2016-05-12T08:17:27Z",
+    "created": "2016-05-12T08:17:27.000Z",
     "external_references": [
         {
             "external_id": "CVE-2016-1234",
@@ -17,7 +17,7 @@ EXPECTED = """{
         }
     ],
     "id": "vulnerability--0c7b5b88-8ff7-4a4d-aa9d-feb398cd0061",
-    "modified": "2016-05-12T08:17:27Z",
+    "modified": "2016-05-12T08:17:27.000Z",
     "name": "CVE-2016-1234",
     "type": "vulnerability"
 }"""
@@ -26,8 +26,8 @@ EXPECTED = """{
 def test_vulnerability_example():
     vulnerability = stix2.Vulnerability(
         id="vulnerability--0c7b5b88-8ff7-4a4d-aa9d-feb398cd0061",
-        created="2016-05-12T08:17:27Z",
-        modified="2016-05-12T08:17:27Z",
+        created="2016-05-12T08:17:27.000Z",
+        modified="2016-05-12T08:17:27.000Z",
         name="CVE-2016-1234",
         external_references=[
             stix2.ExternalReference(source_name='cve',