A STIX 2.1 bundle is a graph of typed objects, and the actionable intelligence — the IPs, domains, and hashes a SOC can actually match against telemetry — is buried inside indicator pattern strings that are their own small language. This guide parses STIX 2.1 into ECS indicators, as one precise technique within threat intel feed mapping and the broader SOC log architecture & taxonomy discipline.

Root-Cause Context

STIX 2.1 models intelligence as a bundle of objects: STIX Domain Objects (SDOs) like indicator, malware, and threat-actor, STIX Cyber-observable Objects (SCOs) like ipv4-addr and file, and relationship objects that connect them. The values a SOC wants to match live in the indicator object’s pattern field, written in the STIX patterning language — for example [ipv4-addr:value = '203.0.113.9'] or [file:hashes.'SHA-256' = 'abc...']. Extracting the matchable value means parsing that pattern, not just reading a field.

Two things make naive parsing fail. First, patterns can be compound — [ipv4-addr:value = '1.2.3.4' OR ipv4-addr:value = '5.6.7.8'] — so one indicator yields multiple values. Second, the pattern’s object-path vocabulary (ipv4-addr:value, domain-name:value, file:hashes.'MD5') must be mapped onto ECS indicator fields, and the mapping is not one-to-one with the SCO type names. A correct parser extracts each comparison from the pattern, resolves its object path to an ECS field, and preserves the indicator’s valid_until and confidence so stale intelligence expires.

STIX 2.1 bundle parsed into ECS indicators A STIX bundle contains indicator objects whose pattern strings are parsed into comparisons. Each comparison's object path — such as ipv4-addr:value or file hashes SHA-256 — is resolved to an ECS threat.indicator field. A relationship object links the indicator to a malware object, whose name is carried onto the resulting ECS indicators as context. indicator (SDO) pattern: [ipv4-addr:value=… valid_until · confidence Parse pattern comparisons · OR split ECS indicators threat.indicator.* malware (SDO) via relationship → context indicates

Prerequisites

The parser targets Python 3.11+ and uses only the standard library — no STIX SDK is required to extract indicators, though one can be used in production. It emits ECS indicators consumed by the parent threat intel feed mapping guides.

python3 --version   # 3.11+; standard library only

Production-Ready Implementation

The parser reads a bundle, extracts each indicator’s pattern comparisons, resolves object paths to ECS fields, and attaches related-object context via relationships.

from __future__ import annotations

import logging
import re
from typing import Optional

logger = logging.getLogger("soc.intel.stix")

# STIX object-path -> ECS threat.indicator field.
PATH_TO_ECS: dict[str, str] = {
    "ipv4-addr:value": "threat.indicator.ip",
    "ipv6-addr:value": "threat.indicator.ip",
    "domain-name:value": "threat.indicator.url.domain",
    "url:value": "threat.indicator.url.full",
    "file:hashes.'MD5'": "threat.indicator.file.hash.md5",
    "file:hashes.'SHA-256'": "threat.indicator.file.hash.sha256",
    "email-addr:value": "threat.indicator.email.address",
}

# Matches "<object-path> = '<value>'" comparisons inside a STIX pattern.
_COMPARISON = re.compile(r"(?P<path>[\w\-]+:[\w\.'\-]+)\s*=\s*'(?P<value>[^']+)'")


def parse_pattern(pattern: str) -> list[dict]:
    """Extract ECS indicators from a STIX 2.1 pattern string."""
    indicators: list[dict] = []
    for m in _COMPARISON.finditer(pattern):
        path, value = m.group("path"), m.group("value")
        field = PATH_TO_ECS.get(path)
        if field is None:
            logger.warning("ERR_STIX_421 unmapped object path=%s", path)
            continue
        indicators.append({field: value})
    if not indicators:
        logger.warning("ERR_STIX_422 no extractable comparison in pattern")
    return indicators


def parse_bundle(bundle: dict) -> list[dict]:
    """Parse a STIX bundle into enriched ECS indicators."""
    objects = bundle.get("objects", [])
    # Index malware/attack-pattern SDOs for relationship context.
    context_by_id = {
        o["id"]: o.get("name", "")
        for o in objects
        if o.get("type") in {"malware", "attack-pattern", "threat-actor"}
    }
    # Map indicator id -> related context via relationships.
    rel: dict[str, str] = {}
    for o in objects:
        if o.get("type") == "relationship" and o.get("relationship_type") == "indicates":
            src, tgt = o.get("source_ref", ""), o.get("target_ref", "")
            if tgt in context_by_id:
                rel[src] = context_by_id[tgt]

    results: list[dict] = []
    for o in objects:
        if o.get("type") != "indicator":
            continue
        for ind in parse_pattern(o.get("pattern", "")):
            ind["threat.indicator.provider"] = o.get("created_by_ref", "unknown")
            if o.get("valid_until"):
                ind["threat.indicator.valid_until"] = o["valid_until"]
            if o["id"] in rel:
                ind["threat.indicator.name"] = rel[o["id"]]     # e.g. malware name
            results.append(ind)
    return results


if __name__ == "__main__":
    bundle = {
        "type": "bundle",
        "objects": [
            {"type": "indicator", "id": "indicator--1",
             "pattern": "[ipv4-addr:value = '203.0.113.9' OR ipv4-addr:value = '198.51.100.4']",
             "valid_until": "2026-12-31T00:00:00Z"},
        ],
    }
    out = parse_bundle(bundle)
    assert len(out) == 2                       # OR pattern -> two indicators
    assert out[0]["threat.indicator.ip"] == "203.0.113.9"

The regex extracts every path = 'value' comparison, so a compound OR pattern naturally yields multiple indicators without special-casing. Relationship resolution runs in one pass over the bundle, indexing context objects first so an indicates relationship can carry a malware name onto the extracted indicators as threat.indicator.name.

Error-Code Reference

Code Meaning Action
ERR_STIX_421 Object path in the pattern has no ECS mapping Log and count; extend PATH_TO_ECS for relevant paths
ERR_STIX_422 Pattern contained no extractable comparison DLQ the indicator; inspect for an unsupported operator
ERR_STIX_423 Indicator value fails ECS type validation DLQ; the extracted value is malformed
ERR_STIX_424 Expired indicator (valid_until in the past) Drop from matching; retain for historical context
ERR_STIX_425 Bundle missing the objects array Reject the bundle; not valid STIX 2.1

Operational Notes

  • Parse the pattern, do not read a field. The matchable value lives inside the STIX patterning language; extract each comparison rather than assuming a flat value field.
  • Split compound patterns. An OR pattern is multiple indicators; handling it as one loses matchable values.
  • Honor valid_until. Expired indicators (ERR_STIX_424) must leave the matching set so stale intelligence does not generate false positives; keep them for historical lookups only.
  • Carry relationship context. An indicates relationship links an indicator to the malware or actor it belongs to; propagate that name so a match arrives with attribution.

Verification Checklist

FAQ

Do I need the STIX Python SDK to parse indicators?

Not to extract matchable indicators. The values a SOC matches against telemetry live in the indicator pattern string, which you can parse directly for the common equality comparisons, as shown here. The official SDK is valuable for full validation, versioning, and constructing STIX, but for the read-path task of turning a feed into ECS indicators a focused pattern parser is lighter and easier to bound. Use the SDK when you need complete spec conformance or to produce STIX.

How do I handle complex STIX patterns with AND, FOLLOWEDBY, or qualifiers?

For the enrichment use case, extract the individual observable comparisons (the path = 'value' leaves) and treat each as a candidate indicator, since those are what match against telemetry. Complex operators like FOLLOWEDBY and time qualifiers express behavioral sequences that belong in the correlation engine’s temporal correlation windows, not in a flat indicator match. Extract the observables here and let sequence logic live where it belongs.