You know that moment after you wire up a “simple” parser, run it once, and suddenly decide you might actually like this STIX thing? Same. This is the post I wish someone handed me on day one — the fastest way to go from zero to “shipping valid STIX 2.1” with Python.
By the end you’ll be able to:
- Create SDOs and SCOs with the
stix2library. - Understand UUIDs (v4 vs v5) and not get bitten by deduping.
- Version objects the right way (minor vs major).
- Link things with Relationship SROs.
- Store, query, and bundle STIX like a grown-up.
Overview
The STIX2 Python library from OASIS is the quickest way to produce valid STIX 2.1. You’ll use it a lot — for prototyping, for pipelines, for tests, for everything.
This guide sticks to the 80/20: just enough API + lots of runnable examples.
Preparation
Create a venv and install:
mkdir stix2_python_tutorial
python3 -m venv stix2_python_tutorial
source stix2_python_tutorial/bin/activate
pip3 install stix2
Now let’s make one SDO and one SCO.
Creating SDOs and SCOs
SDO: Attack Pattern
generate_sdo.py
# python3 generate_sdo.py
## Start by importing all the things you will need
### https://stix2.readthedocs.io/en/latest/api/v21/stix2.v21.sdo.html#stix2.v21.sdo.AttackPattern
### https://stix2.readthedocs.io/en/latest/api/stix2.v21.html?highlight=tlp#stix2.v21.TLPMarking
from stix2 import AttackPattern, TLP_GREEN
## Create AttackPattern SDO using the files
AttackPatternDemo = AttackPattern(
created_by_ref="identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
name="Spear Phishing",
description="Used for tutorial content",
object_marking_refs=[
TLP_GREEN
]
)
## Print all the objects to the command line
print(AttackPatternDemo.serialize(pretty=True))
{
"type": "attack-pattern",
"spec_version": "2.1",
"id": "attack-pattern--794709ca-2407-4da8-a6ec-e4b1e074a18d",
"created_by_ref": "identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
"created": "2020-01-01T07:38:55.364693Z",
"modified": "2020-01-01T07:38:55.364693Z",
"name": "Spear Phishing",
"description": "Used for tutorial content",
"object_marking_refs": [
"marking-definition--613f2e26-407d-48c7-9eca-b8e91df99dc9"
]
}
The library fills in type, spec_version, id, created, modified (you can pass these manually too).
If you pass a bad value to what is expected (e.g., created_by_ref="dogesec"), you’ll get a helpful validation error. The STIX 2.1 specification defines the properties you can use for each object type.
For reference, the library exposes TLP v1 constants (TLP_WHITE/GREEN/AMBER/RED). TLP v2 is yet to be implemented.
Each time this script is run, the id of the object will change (more about that in ID generation).
SCO: IPv4 Address
generate_sco.py
# python3 generate_sco.py
## Start by importing all the things you will need
### IPv4 SCO https://stix2.readthedocs.io/en/latest/api/stix2.v21.html#stix2.v21.IPv4Address
from stix2 import IPv4Address
## Create IPv4Address SCO using the files
IPv4AddressDemo = IPv4Address(
value="177.60.40.7"
)
## Print all the objects to the command line
print(IPv4AddressDemo.serialize(pretty=True))
{
"type": "ipv4-addr",
"spec_version": "2.1",
"id": "ipv4-addr--dc63603e-e634-5357-b239-d4b562bc5445",
"value": "177.60.40.7"
}
Each time this script is run, the id of the object will remain the same (more about that in ID generation).
A long (but vital) note on ID generation
As you’ve now seen, STIX IDs look like: object-type--UUID. It’s important to understand how the stix2 library generates these IDs:
- SDOs/SROs/SMOs → UUIDv4 by default (random).
- SCOs → UUIDv5 by default (deterministic), based on ID Contributing Properties.
Why you should care: two producers describing the same IPv4 should produce the same SCO ID.
Deterministic IDs for SCOs
domain_uuid_demo.py
# python3 domain_uuid_demo.py
# Docs:
# DomainName SCO: https://stix2.readthedocs.io/en/latest/api/v21/stix2.v21.observables.html#stix2.v21.observables.DomainName
from stix2 import DomainName
DomainNameDemo = DomainName(value="google.com")
print(DomainNameDemo.serialize(pretty=True))
{
"type": "domain-name",
"spec_version": "2.1",
"id": "domain-name--dd686e37-6889-53bd-8ae1-b1a503452613",
"value": "google.com"
}
You can run this script again and again, the same ID will be produced.
So why is this happening?
The stix2 library is using UUIDv5 logic to generate SCOs:
- namespace =
00abedb4-aa42-466c-9c01-fed23315a9b7 - values = ID Contributing Properties
The ID Contributing Properties are defined in the STIX specification for each SCO.

You can see value is the only property listed for ID Contributing Properties for domain-name SCOs.
This means the ID will always be the same, unless the value property is modified.
domain_uuid_contrib_demo.py
from stix2 import DomainName
DomainNameDemo = DomainName(
value="google.com",
resolves_to_refs=["ipv4-addr--dc63603e-e634-5357-b239-d4b562bc5445"] # must be a list
)
print(DomainNameDemo.serialize(pretty=True))
{
"type": "domain-name",
"spec_version": "2.1",
"id": "domain-name--dd686e37-6889-53bd-8ae1-b1a503452613",
"resolves_to_refs": [
"ipv4-addr--dc63603e-e634-5357-b239-d4b562bc5445"
],
"value": "google.com"
}
See the ID remains unchanged.
It’s important to note that some SCOs have ID Contributing Properties that are used for ID generation.
For SDOs/SROs: when to choose UUIDv5
The STIX specification recommends UUIDv4 (random) when generating SDOs and SROs.
However, in many cases you want stable IDs (e.g., generating the same SDO deterministically in a build). A good example of this is CVE ID generation for Vulnerability objects in Vulmatch. We use our own namespace and pass the CVE ID as the value. This is beneficial because we can calculate the STIX ID of a Vulnerability object during search (without querying the database).
# python3 generate_sdo_with_uuidv5.py
import uuid
from uuid import UUID
from stix2 import AttackPattern, TLP_GREEN
namespace = UUID("d2916708-57b9-5636-8689-62f049e9f727") # your namespace
value = "Some fixed value"
generated_id = "attack-pattern--" + str(uuid.uuid5(namespace, value))
AttackPatternUUID5Demo = AttackPattern(
id=generated_id,
created_by_ref="identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
name="Spear Phishing",
description="Used for tutorial content",
object_marking_refs=[TLP_GREEN],
)
print(AttackPatternUUID5Demo.serialize(pretty=True))
{
"type": "attack-pattern",
"spec_version": "2.1",
"id": "attack-pattern--6b948b5a-3c09-5365-b48a-da95c3964cb5",
"created_by_ref": "identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
"created": "2020-01-01T11:21:07.478851Z",
"modified": "2020-01-01T11:21:07.478851Z",
"name": "Spear Phishing",
"description": "Used for tutorial content",
"object_marking_refs": [
"marking-definition--34098fce-860f-48ae-8e50-ebd3cc5e41da"
]
}
Use this intentionally; don’t fight randomness unless you need stable references.
Versioning (a close cousin of IDs)
SDOs/SROs/Language Content SMOs have id, created, modified properties.
- Minor change → same
id, updatedmodified. - Major change → new object (new
id).- usually only required when a serious error exists in the original object (e.g. wrong identity used)
- in this case the original object (if not SCO) is also marked with the
revokedproperty
Example of a minor change
For example, let’s use the original Attack Pattern object I generated earlier;
{
"type": "attack-pattern",
"spec_version": "2.1",
"id": "attack-pattern--794709ca-2407-4da8-a6ec-e4b1e074a18d",
"created_by_ref": "identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
"created": "2020-01-01T07:38:55.364693Z",
"modified": "2020-01-01T07:38:55.364693Z",
"name": "Spear Phishing",
"description": "Used for tutorial content",
"object_marking_refs": [
"marking-definition--613f2e26-407d-48c7-9eca-b8e91df99dc9"
]
}
To update this, and ensure the UUID persists, I can use the new_version function in the stix2 library.
update_sdo.py
# python3 update_sdo.py
## Start by importing all the things you will need
### https://stix2.readthedocs.io/en/latest/api/v21/stix2.v21.sdo.html#stix2.v21.sdo.AttackPattern
### https://stix2.readthedocs.io/en/latest/api/stix2.v21.html?highlight=tlp#stix2.v21.TLPMarking
from stix2 import AttackPattern, TLP_GREEN, new_version
## Create Attack Pattern SDO using the files
AttackPatternDemo = AttackPattern(
created_by_ref="identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
name="Spear Phishing",
description="Used for tutorial content",
object_marking_refs=[
TLP_GREEN
]
)
## Print all the objects to the command line
print(AttackPatternDemo.serialize(pretty=True))
## Update the Attack Pattern SDO
UpdatedAttackPatternDemo = new_version(
AttackPatternDemo,
description="new description")
## Print all the objects to the command line
print(UpdatedAttackPatternDemo.serialize(pretty=True))
Prints:
{
"type": "attack-pattern",
"spec_version": "2.1",
"id": "attack-pattern--f6455edf-222b-48c3-8604-d672929cd40e",
"created_by_ref": "identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
"created": "2020-01-01T08:28:30.688249Z",
"modified": "2020-01-01T08:28:30.688249Z",
"name": "Spear Phishing",
"description": "Used for tutorial content",
"object_marking_refs": [
"marking-definition--34098fce-860f-48ae-8e50-ebd3cc5e41da"
]
}
{
"type": "attack-pattern",
"spec_version": "2.1",
"id": "attack-pattern--f6455edf-222b-48c3-8604-d672929cd40e",
"created_by_ref": "identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
"created": "2020-01-01T08:28:30.688249Z",
"modified": "2020-02-01T07:38:55.364693Z",
"name": "Spear Phishing",
"description": "new description",
"object_marking_refs": [
"marking-definition--34098fce-860f-48ae-8e50-ebd3cc5e41da"
]
}
As you can see the id and created properties persist, but notice how the modified time changes.
SCOs don’t have modified properties. If you change an ID contributing property, you’ve made a major change (new ID).
Creating Relationship SROs
Once you have the two objects you want to join, you can create an SRO to link them.
generate_sro.py
# python3 generate_sro.py
# Docs:
# Relationship SRO: https://stix2.readthedocs.io/en/latest/api/v21/stix2.v21.sro.html#stix2.v21.sro.Relationship
from stix2 import Relationship
RelationshipDemo = Relationship(
created_by_ref="identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
relationship_type="indicates",
source_ref="indicator--2f559518-c844-4c4e-bca3-cc97520c164a",
target_ref="malware--09d22009-b575-4880-889f-6c539157dbc7",
)
print(RelationshipDemo.serialize(pretty=True))
{
"type": "relationship",
"spec_version": "2.1",
"id": "relationship--44ceafde-0027-45cc-bab9-b46c5e001ceb",
"created_by_ref": "identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
"created": "2020-01-01T15:25:53.686507Z",
"modified": "2020-01-01T15:25:53.686507Z",
"relationship_type": "indicates",
"source_ref": "indicator--2f559518-c844-4c4e-bca3-cc97520c164a",
"target_ref": "malware--09d22009-b575-4880-889f-6c539157dbc7"
}
Don’t forget to consult the STIX specification for recommended links between source and target objects and the associated relationship_type for them.
Bundling everything together
Bundles are how you ship multiple STIX objects in one payload.
bundle_filesystem_objects.py
from stix2 import AttackPattern, ThreatActor, Relationship, TLP_GREEN, Bundle
ap = AttackPattern(
created_by_ref="identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
name="Spear Phishing",
object_marking_refs=[TLP_GREEN],
)
ta = ThreatActor(
created_by_ref="identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
name="A bad guy",
threat_actor_types=["sensationalist"],
object_marking_refs=[TLP_GREEN],
)
rel = Relationship(
relationship_type="uses",
source_ref=ta,
target_ref=ap,
)
bundle = Bundle(objects=[ap, ta, rel])
print(bundle.serialize(pretty=True))
{
"type": "bundle",
"id": "bundle--ed31bd4b-46ab-4965-8796-12b4d5ad9fcc",
"objects": [
{
"type": "attack-pattern",
"spec_version": "2.1",
"id": "attack-pattern--b2c77df1-7aac-4b02-bdf1-6e71cb023d61",
"created_by_ref": "identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
"created": "2020-01-01T08:28:30.688249Z",
"modified": "2020-01-01T08:28:30.688249Z",
"name": "Spear Phishing",
"object_marking_refs": [
"marking-definition--34098fce-860f-48ae-8e50-ebd3cc5e41da"
]
},
{
"type": "threat-actor",
"spec_version": "2.1",
"id": "threat-actor--db09d012-6be1-4c08-bd0e-15f6910f1758",
"created_by_ref": "identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
"created": "2020-01-01T08:28:30.688249Z",
"modified": "2020-01-01T08:28:30.688249Z",
"name": "A bad guy",
"threat_actor_types": [
"sensationalist"
],
"object_marking_refs": [
"marking-definition--34098fce-860f-48ae-8e50-ebd3cc5e41da"
]
},
{
"type": "relationship",
"spec_version": "2.1",
"id": "relationship--4a58e575-8ace-49b3-9137-26c76aaa25b8",
"created_by_ref": "identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
"created": "2020-01-01T08:28:30.688249Z",
"modified": "2020-01-01T08:28:30.688249Z",
"relationship_type": "uses",
"source_ref": "threat-actor--db09d012-6be1-4c08-bd0e-15f6910f1758",
"target_ref": "attack-pattern--b2c77df1-7aac-4b02-bdf1-6e71cb023d61"
}
]
}
Cheat Codes
Have a look at our txt2stix code. txt2stix creates a variety of STIX objects using all the ways ID generation is described in this post.
TL;DR
- Use
stix2to create valid SDOs/SCOs fast. - IDs: v4 random for SDO/SRO, v5 deterministic for SCOs (don’t fight that).
- Versioning: minor → same ID, new
modified; major → new object - Ship: bundle objects into a single JSON when you share.
That’s it — you’re dangerous now.
Obstracts
The RSS reader for threat intelligence teams. Turn any blog into machine readable STIX 2.1 data ready for use with your security stack.
Discuss this post
Head on over to the dogesec community to discuss this post.
Never miss an update
Sign up to receive new articles in your inbox as they published.
