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:

  1. Create new SDOs, SCOs, and SROs cleanly and consistently.
  2. Extend existing Core STIX objects without breaking schemas or validators.
  3. 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:

  1. Many users were confused by seeing the Vulnerability object used for anything other than CVEs.
  2. 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 Object
  • new-sco — defines a new STIX Cyber-observable Object
  • new-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 the stix2 library 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, and ReferenceProperty define the type and validation rules for each field, following the STIX 2.1 data model.
  • spec_version, created, and modified are required by STIX for versioning and traceability.
  • ExtensionsProperty allows your object to reference one or more Extension Definitions, like the one we created earlier.
  • Finally, I instantiate the Weakness class 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 inside extensions[<your-extension-id>].
    • Pros: Most compatible with strict STIX validators
    • Cons: Many downstream products don’t parse out any values in the extensions property
  • 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 type prefixed by x- (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=True when 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.

dogesec community

Posted by:

David Greenwood

David Greenwood, Do Only Good Everyday



Never miss an update


Sign up to receive new articles in your inbox as they published.

Your subscription could not be saved. Please try again.
Your subscription has been successful.