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.
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
ORpattern 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
indicatesrelationship 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.