You know that moment after you fix a messy integration and think, surely, that was the hard part? Yeah, about that.
Last time in Schema Chaos and the Art of STIX Maintenance, I tried to make Vulmatch sing nicely in OpenCTI and accidentally discovered a small museum exhibit of custom fields. I survived, I wrote some Extension Definitions, and I promised myself future-me would never suffer like that again.
This post is the how behind that promise: a practical, developer-first tour of STIX Extensions — when to use them, how to design them, and how to keep downstream tools from throwing a tantrum.
What I will show you:
- Create new SDOs, SCOs, and SROs cleanly and consistently.
- Extend existing Core STIX objects without breaking schemas or validators.
- What you shouldn’t do
1. When the object you need doesn’t exist, make a new one
STIX ships with plenty of SDOs, but sometimes your data model is screaming for something that isn’t there. Case in point: weaknesses (CWEs). There’s no native SDO for that.
Some people extend the Vulnerability object to represent CWEs — and that’s a valid approach. However, we quickly realised two things:
- Many users were confused by seeing the Vulnerability object used for anything other than CVEs.
- CWEs have their own rich set of fields that deserve a dedicated object.
So we decided to define one — properly — with an Extension Definition that points to a schema everyone can read and validate against.
Example: Creating a new Weakness object
The first step is to define a new Extension Definition for our new Weakness object.
To generate the Extension Definition programmatically, you can use the stix2 Python library (as described in this post). Here’s a minimal example:
import stix2
from stix2 import ExtensionDefinition
WeaknessExtensionDefinition = ExtensionDefinition(
id="extension-definition--31725edc-7d81-5db7-908a-9134f322284a",
created_by_ref="identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
created="2020-01-01T00:00:00.000Z",
modified="2020-01-01T00:00:00.000Z",
name="Weakness",
description="This extension creates a new SDO that can be used to represent weaknesses (for CWEs).",
schema="https://raw.githubusercontent.com/muchdogesec/stix4doge/main/schemas/sdos/weakness.json",
version="1.0",
extension_types=[
"new-sdo"
],
object_marking_refs=[
"marking-definition--94868c89-83c2-464b-929b-a1a8aa3c8487",
"marking-definition--97ba4e8b-04f6-57e8-8f6e-3a0f0a7dc0fb"
]
)
print(WeaknessExtensionDefinition)
Which creates the following object:
{
"type": "extension-definition",
"spec_version": "2.1",
"id": "extension-definition--31725edc-7d81-5db7-908a-9134f322284a",
"created_by_ref": "identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
"created": "2020-01-01T00:00:00.000Z",
"modified": "2020-01-01T00:00:00.000Z",
"name": "Weakness",
"description": "This extension creates a new SDO that can be used to represent weaknesses (for CWEs).",
"schema": "https://raw.githubusercontent.com/muchdogesec/stix2extensions/main/schemas/sdos/weakness.json",
"version": "1.0",
"extension_types": [
"new-sdo"
],
"object_marking_refs": [
"marking-definition--94868c89-83c2-464b-929b-a1a8aa3c8487",
"marking-definition--60c0f466-511a-5419-9f7e-4814e696da40"
]
}
The main job of an Extension Definition object is to link to the schema of the new object (in this case, weakness). This tells consumers how each property is defined, and gives producers enough information to build compatible objects.
If you’re new to STIX schemas, the core STIX object schemas are the perfect starting point. Take the Vulnerability SDO schema, for example — it’s a great model for structure and property design.
When building your Extension Definition, one key property to understand is extension_types. It can be one of the following:
new-sdo— defines a new STIX Domain Objectnew-sco— defines a new STIX Cyber-observable Objectnew-sro— defines a new STIX Relationship Object
If you’re not sure when to use which, read my beginner’s guide to STIX objects.
Now all the ground-work has been laid, here is an example of a custom STIX Weakness SDO, again using the stix2 Python library and its @CustomObject decorator — the recommended way to define custom STIX objects in code:
import stix2
from stix2 import CustomObject
from stix2.properties import (
BooleanProperty, ExtensionsProperty, ReferenceProperty,
IDProperty, IntegerProperty, ListProperty, StringProperty,
TimestampProperty, TypeProperty,
)
from stix2.v21.common import (
ExternalReference,
)
from stix2.utils import NOW
# Deterministic UUIDv5 for this weakness
NAMESPACE = uuid.UUID("97ba4e8b-04f6-57e8-8f6e-3a0f0a7dc0fb")
VALUE = "CWE-117"
WEAKNESS_UUID = uuid.uuid5(NAMESPACE, VALUE) # -> b2a7329d-bf9d-5b02-9a05-89bd6cde71a3
WEAKNESS_ID = f"weakness--{WEAKNESS_UUID}"
_type = 'weakness'
@CustomObject('weakness', [
('type', TypeProperty(_type, spec_version='2.1')),
('spec_version', StringProperty(fixed='2.1')),
('id', IDProperty(_type, spec_version='2.1')),
('created_by_ref', ReferenceProperty(valid_types='identity', spec_version='2.1')),
('created', TimestampProperty(default=lambda: NOW, precision='millisecond', precision_constraint='min')),
('modified', TimestampProperty(default=lambda: NOW, precision='millisecond', precision_constraint='min')),
('name', StringProperty(required=True)),
('description', StringProperty()),
('modes_of_introduction', ListProperty(StringProperty)),
('common_consequences', ListProperty(StringProperty)),
('detection_methods', ListProperty(StringProperty)),
('likelihood_of_exploit', ListProperty(StringProperty)),
('external_references', ListProperty(ExternalReference)),
('object_marking_refs', ListProperty(ReferenceProperty(valid_types='marking-definition', spec_version='2.1'))),
('extensions', ExtensionsProperty(spec_version='2.1'))
])
class Weakness(object):
def __init__(self, **kwargs):
pass
WeaknessSDO = Weakness(
id=WEAKNESS_ID, # "weakness--b2a7329d-bf9d-5b02-9a05-89bd6cde71a3"
created_by_ref="identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
created="2020-01-01T00:00:00.000Z",
modified="2020-01-01T00:00:00.000Z",
name="CWE-117 Demo",
description="A demo weakness",
modes_of_introduction=[
"Implementation"
],
likelihood_of_exploit=[
"Medium"
],
common_consequences=[
"Confidentiality",
"Integrity"
],
detection_methods=[
"Automated Static Analysis"
],
external_references=[
{
"source_name": "cwe",
"url": "http://cwe.mitre.org/data/definitions/117.html",
"external_id": "CWE-117"
}
],
object_marking_refs=[
"marking-definition--94868c89-83c2-464b-929b-a1a8aa3c8487",
"marking-definition--97ba4e8b-04f6-57e8-8f6e-3a0f0a7dc0fb"
],
extensions= {
"extension-definition--31725edc-7d81-5db7-908a-9134f322284a": {
"extension_type": "new-sdo"
}
}
)
print(WeaknessSDO)
Let’s break down what’s happening here for those new to stix2:
@CustomObject— this decorator tells thestix2library you’re defining a new custom STIX object type (in this case,weakness). The list that follows defines all its allowed properties.- Property classes like
StringProperty,ListProperty, andReferencePropertydefine the type and validation rules for each field, following the STIX 2.1 data model. spec_version,created, andmodifiedare required by STIX for versioning and traceability.ExtensionsPropertyallows your object to reference one or more Extension Definitions, like the one we created earlier.- Finally, I instantiate the
Weaknessclass with real values to create a STIX-compliant object that can be serialized, validated, and shared.
If you’re wondering why we use a UUIDv5 for the ID (and not default to stix2 default behaviour to use a random UUIDv4), read this post.
💡 Pro-Tip: This pattern is identical to how the stix2 library defines its built-in classes for native STIX objects. You can browse them for inspiration in the stix2 Python repo for SDOs, SCOs, and SROs.
Here is the Weakness object produced:
{
"type": "weakness",
"spec_version": "2.1",
"id": "weakness--b2a7329d-bf9d-5b02-9a05-89bd6cde71a3",
"created_by_ref": "identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
"created": "2020-01-01T00:00:00.000Z",
"modified": "2020-01-01T00:00:00.000Z",
"name": "CWE-117 Demo",
"description": "A demo weakness",
"modes_of_introduction": [
"Implementation"
],
"likelihood_of_exploit": [
"Medium"
],
"common_consequences": [
"Confidentiality",
"Integrity"
],
"detection_methods": [
"Automated Static Analysis"
],
"external_references": [
{
"source_name": "cwe",
"url": "http://cwe.mitre.org/data/definitions/117.html",
"external_id": "CWE-117"
}
],
"object_marking_refs": [
"marking-definition--94868c89-83c2-464b-929b-a1a8aa3c8487",
"marking-definition--97ba4e8b-04f6-57e8-8f6e-3a0f0a7dc0fb"
],
"extensions": {
"extension-definition--31725edc-7d81-5db7-908a-9134f322284a": {
"extension_type": "new-sdo"
}
}
}
See how the extensions property references the ID of the Extension Definition we created earlier, telling readers where to look if they need more information about the objects structure.
A note on bundling custom objects and properties
When working with custom objects and custom properties using the stix2 Python library, remember to enable allow_custom=True when creating a STIX Bundle of objects.
Without it, your custom object will trigger validation errors.
Here’s a minimal example of bundling our Weakness SDO and Extension Definition SMO:
from stix2 import Bundle
BundleObjects = Bundle(
objects=WeaknessSDO,WeaknessExtensionDefinition
allow_custom=True
)
print(BundleObjects)
2. When you need a couple of new properties, add them
Sometimes a full‑blown object is overkill. You just need, say, a score or two on an Indicator.
You have two clean options with Extension Definitions:
property-extension— custom properties insideextensions[<your-extension-id>].- Pros: Most compatible with strict STIX validators
- Cons: Many downstream products don’t parse out any values in the
extensionsproperty
toplevel-property-extension— custom properties at the top level of the SDO.- Pros: Looks clean
- Cons: Many tools reject non‑spec top‑level properties
2.1 Using property-extension
To compare the two, here is an example property-extension.
First the Extension Definition:
import stix2
from stix2 import ExtensionDefinition
PropertyExtensionDefinition = ExtensionDefinition(
id="extension-definition--d83fce45-ef58-4c6c-a3f4-1fbc32e98c6e",
created_by_ref="identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
created="2020-01-01T00:00:00.000Z",
modified="2020-01-01T00:00:00.000Z",
name="Adding demo scoring properties to Indicator",
description="This schema adds two custom properties to a STIX Indicator object",
schema="https://raw.githubusercontent.com/example.json",
version="1.0",
extension_types=[
"property-extension"
],
object_marking_refs=[
"marking-definition--94868c89-83c2-464b-929b-a1a8aa3c8487",
"marking-definition--97ba4e8b-04f6-57e8-8f6e-3a0f0a7dc0fb"
]
)
print(PropertyExtensionDefinition)
{
"id": "extension-definition--d83fce45-ef58-4c6c-a3f4-1fbc32e98c6e",
"type": "extension-definition",
"spec_version": "2.1",
"name": "Adding demo scoring properties to Indicator",
"description": "This schema adds two custom properties to a STIX object",
"created": "2020-01-01T00:00:00.000Z",
"modified": "2020-01-01T00:00:00.000Z",
"created_by_ref": "identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
"schema": "https://raw.githubusercontent.com/example.json",
"version": "1.0",
"extension_types": [
"property-extension"
],
"object_marking_refs": [
"marking-definition--94868c89-83c2-464b-929b-a1a8aa3c8487",
"marking-definition--97ba4e8b-04f6-57e8-8f6e-3a0f0a7dc0fb"
],
}
And then the Indicator with the custom properties:
import stix2
from stix2 import Indicator
IndicatorPropertyExtension = Indicator(
id="indicator--e97bfccf-8970-4a3c-9cd1-5b5b97ed5d0c",
created_by_ref="identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
created="2020-01-01T00:00:00.000Z",
modified="2020-01-01T00:00:00.000Z",
name="File hash for Poison Ivy variant",
description="This file hash indicates that a sample of Poison Ivy is present.",
labels=[
"malicious-activity"
],
pattern="[file:hashes.'SHA-256' = 'ef537f25c895bfa782526529a9b63d97aa631564d5d789c2b765448c8635fb6c']",
pattern_type="stix",
valid_from="2020-01-01T00:00:00.000Z",
object_marking_refs=[
"marking-definition--94868c89-83c2-464b-929b-a1a8aa3c8487",
"marking-definition--97ba4e8b-04f6-57e8-8f6e-3a0f0a7dc0fb"
],
extensions= {
"extension-definition--d83fce45-ef58-4c6c-a3f4-1fbc32e98c6e": {
"extension_type": "property-extension",
"impact": 5,
"maliciousness": 8
}
}
)
print(IndicatorPropertyExtension)
{
"type": "indicator",
"spec_version": "2.1",
"id": "indicator--e97bfccf-8970-4a3c-9cd1-5b5b97ed5d0c",
"created": "2020-01-01T00:00:00.000Z",
"modified": "2020-01-01T00:00:00.000Z",
"created_by_ref": "identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
"name": "File hash for Poison Ivy variant",
"description": "This file hash indicates that a sample of Poison Ivy is present.",
"labels": [
"malicious-activity"
],
"pattern": "[file:hashes.'SHA-256' = 'ef537f25c895bfa782526529a9b63d97aa631564d5d789c2b765448c8635fb6c']",
"pattern_type": "stix",
"valid_from": "2020-01-01T00:00:00.000Z",
"extensions": {
"extension-definition--d83fce45-ef58-4c6c-a3f4-1fbc32e98c6e" : {
"extension_type": "property-extension",
"impact": 5,
"maliciousness": 8
}
},
"object_marking_refs": [
"marking-definition--94868c89-83c2-464b-929b-a1a8aa3c8487",
"marking-definition--97ba4e8b-04f6-57e8-8f6e-3a0f0a7dc0fb"
]
}
Why this works well: your non-spec fields (impact, maliciousness) are safely scoped under your extension’s ID, so validators don’t panic and downstream tools that ignore extensions can still ingest the core object.
Why this can cause issues: many downstream tools strip the extension.
Using toplevel-property-extension
Now lets look at using the toplevel-property-extension approach.
Here is my Extension Definition:
import stix2
from stix2 import ExtensionDefinition
TopLevelPropertyExtensionDefinition = ExtensionDefinition(
id="extension-definition--71736db5-10db-43d3-b0e3-65cf81601fe1",
created_by_ref="identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
created="2020-01-01T00:00:00.000Z",
modified="2020-01-01T00:00:00.000Z",
name="Adding demo scoring properties to Indicator",
description="This schema adds two custom properties to a STIX Indicator object",
schema="https://raw.githubusercontent.com/another_example.json",
version="1.0",
extension_types=[
"toplevel-property-extension"
],
object_marking_refs=[
"marking-definition--94868c89-83c2-464b-929b-a1a8aa3c8487",
"marking-definition--97ba4e8b-04f6-57e8-8f6e-3a0f0a7dc0fb"
]
)
print(TopLevelPropertyExtensionDefinition)
{
"id": "extension-definition--71736db5-10db-43d3-b0e3-65cf81601fe1",
"type": "extension-definition",
"spec_version": "2.1",
"name": "Adding demo scoring properties to Indicator",
"description": "This schema adds two custom properties to a STIX Indicator object",
"created": "2020-01-01T00:00:00.000Z",
"modified": "2020-01-01T00:00:00.000Z",
"created_by_ref": "identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
"schema": "https://raw.githubusercontent.com/another_example.json",
"version": "1.0",
"extension_types": [
"toplevel-property-extension"
],
"extension_properties" : [
"toxicity",
"rank"
],
"object_marking_refs": [
"marking-definition--94868c89-83c2-464b-929b-a1a8aa3c8487",
"marking-definition--97ba4e8b-04f6-57e8-8f6e-3a0f0a7dc0fb"
]
}
And what an Indicator could look like;
import stix2
from stix2 import Indicator
IndicatorTopLevelPropertyExtension = Indicator(
id="indicator--66a63e16-92d7-4b2f-bd3d-21540d6b3fc7",
created_by_ref="identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
created="2020-01-01T00:00:00.000Z",
modified="2020-01-01T00:00:00.000Z",
name="File hash for Poison Ivy variant",
description="This file hash indicates that a sample of Poison Ivy is present.",
labels=[
"malicious-activity"
],
pattern="[file:hashes.'SHA-256' = 'ef537f25c895bfa782526529a9b63d97aa631564d5d789c2b765448c8635fb6c']",
pattern_type="stix",
valid_from="2020-01-01T00:00:00.000Z",
object_marking_refs=[
"marking-definition--94868c89-83c2-464b-929b-a1a8aa3c8487",
"marking-definition--97ba4e8b-04f6-57e8-8f6e-3a0f0a7dc0fb"
],
impact=1,
maliciousness=2,
extensions= {
"extension-definition--71736db5-10db-43d3-b0e3-65cf81601fe1": {
"extension_type": "toplevel-property-extension"
}
}
)
print(IndicatorTopLevelPropertyExtension)
{
"type": "indicator",
"spec_version": "2.1",
"id": "indicator--66a63e16-92d7-4b2f-bd3d-21540d6b3fc7",
"created": "2020-01-01T00:00:00.000Z",
"modified": "2020-01-01T00:00:00.000Z",
"created_by_ref": "identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5",
"name": "File hash for Poison Ivy variant",
"description": "This file hash indicates that a sample of Poison Ivy is present.",
"labels": [
"malicious-activity"
],
"pattern": "[file:hashes.'SHA-256' = 'ef537f25c895bfa782526529a9b63d97aa631564d5d789c2b765448c8635fb6c']",
"pattern_type": "stix",
"valid_from": "2020-01-01T00:00:00.000Z",
"impact": 1,
"maliciousness": 2,
"extensions": {
"extension-definition--71736db5-10db-43d3-b0e3-65cf81601fe1" : {
"extension_type": "toplevel-property-extension"
}
},
"object_marking_refs": [
"marking-definition--94868c89-83c2-464b-929b-a1a8aa3c8487",
"marking-definition--97ba4e8b-04f6-57e8-8f6e-3a0f0a7dc0fb"
]
}
Why this works well: it’s easier to read and is more familiar due to legacy STIX schemas (more on that in section 3).
Why this can cause issues: many ingest pipelines hard-reject top-level properties that aren’t in the core spec.
Quick decision guide
property-extension? toplevel-property-extension?
- Need maximum interoperability? Use
property-extension. - Targeting a specific platform that supports (or won’t hard reject them) custom top level properties? Consider
toplevel-property-extension.
3. Legacy custom objects and properties (aka what you shouldn’t do)
In older versions of the ecosystem, producers extended STIX by:
- Creating custom objects with a
typeprefixed byx-(e.g.,"type": "x-my-custom-object").
Adding custom properties prefixed with x_ (e.g., "x_custom_property": "value").
You’ll still see this in the wild. The MITRE ATT&CK STIX 2.1 dataset uses custom objects (x-mitre-tactic, x-mitre-data-source…) and custom properties (x_mitre_version, x_mitre_shortname). It works, but there is no formal schema contract, so consumers must reverse-engineer meaning. Of course if you’re as big as MITRE that’s not such an issue, but you’re not MITRE.
If you’re designing new content today, use Extension Definitions. They give you:
- A schema URL (contract for producers/consumers).
- Clear namespacing (no collisions).
- Better validator and tool compatibility over time.
TL;DR
- Don’t bolt random
x_fields on your objects and call it a day. - Publish an Extension Definition that points to a clear schema.
- Use new-sdo/new-sco/new-sro when the thing doesn’t exist; otherwise add fields with a property-extension (nested) for maximum compatibility.
- Remember
allow_custom=Truewhen bundling.
Your future you — and every downstream tool — will thank you.
Vulmatch
The Vulnerability Search Engine.
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.
