Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

stix-rust is a Rust toolkit — with Python, Java, and TypeScript bindings — for working with STIX 2.1, the OASIS standard for representing cyber threat intelligence. It does three things:

  1. Parse STIX patterning-language patterns into a typed, serializable AST.
  2. Import STIX objects (SDOs, SCOs) and bundles into a flexible object model.
  3. Match patterns against sets of observed objects and tell you what bound.

What is STIX?

STIX (Structured Threat Information eXpression) is a standardized JSON language for describing threat intelligence — indicators, malware, observed data, and the relationships between them. Two pieces matter most here:

  • STIX objects — JSON documents like an ipv4-addr, a file, or an observed-data SDO, usually delivered together in a bundle.

  • The patterning language — a query-like syntax used inside indicator objects to describe what to look for:

    [ipv4-addr:value = '198.51.100.1' OR domain-name:value = 'evil.example']
    

stix-rust parses those patterns and evaluates them against observed objects to answer: “does this threat intelligence match what we saw?”

Shape of the toolkit

The core is five Rust crates with clean dependency edges; four language bindings wrap a shared FFI facade:

LayerPieces
Core cratesstix-pattern (parser), stix-model (objects), stix-matcher (engine), stix (umbrella)
FFI facadestix-ffi — opaque handles + JSON deep structure, wrapped by every binding
BindingsPython · Java · TypeScript Node · TypeScript wasm

Every language gets the same conceptual API: an Engine that parses patterns and bundles into handles, matches them, and accepts custom object type registrations with validation/computed-property hooks.

Status, honestly

The parser handles the complete STIX 2.1 patterning grammar. The matcher implements all comparison operators, boolean logic, object-path resolution (including reference traversal), and observation-level AND/OR.

Two grammar features parse but do not yet match: FOLLOWEDBY sequencing and the temporal qualifiers (WITHIN, REPEATS, START..STOP). Reaching them at match time returns an explicit unsupported error rather than silently passing. See Limitations & Caveats for the full list of sharp edges — reading that page before production use is strongly recommended.

Getting Started

Install

Rust

cargo add stix-rust

Or from source:

[dependencies]
stix = { git = "https://github.com/benjamin-small/stix-rust", package = "stix-rust" }

The package is stix-rust; the library name is stix, so code reads use stix::… either way.

Python

pip install stix-rust

Or from source:

pip install "maturin>=1.5,<2.0"
cd bindings/python && maturin develop

TypeScript

npm install @stix-rust/node    # native Node addon
npm install @stix-rust/wasm    # portable WebAssembly (Node + browser)

Or from source: cd bindings/typescript-node && npm install && npm run build (same for typescript-wasm).

Java

Maven Central: not yet published — source-only for now; the coordinate below is the planned artifact.

// Gradle (Kotlin DSL)
implementation("io.github.benjaminsmall:stix:0.1.0")

Or from source: cd bindings/java && gradle test builds the native library and runs the suite; see the Java page for library-loading details.

Quick start (Rust)

#![allow(unused)]
fn main() {
use stix::{parse, matcher::match_bundle, model::Bundle};

let pattern = parse("[ipv4-addr:value = '198.51.100.5']")?;

let bundle = Bundle::from_json_str(r#"{
  "type": "bundle",
  "objects": [
    { "type": "ipv4-addr", "id": "ipv4-addr--a1", "value": "198.51.100.5" },
    { "type": "observed-data", "id": "observed-data--a1",
      "first_observed": "2020-03-01T12:00:00Z",
      "last_observed":  "2020-03-01T12:10:00Z",
      "number_observed": 1,
      "object_refs": ["ipv4-addr--a1"] }
  ]
}"#)?;

let result = match_bundle(&pattern, &bundle)?;
assert!(result.is_match());
}

That’s the whole loop: parse a pattern, import a bundle, match. The same three steps exist in every binding — see the language pages for the identical example in Python, Java, and TypeScript.

Next stops:

Patterns

stix-pattern parses the complete STIX 2.1 patterning grammar into a typed AST. This page covers what the language expresses and how the parser structures it.

Anatomy of a pattern

[file:name LIKE '%.exe' AND file:size > 1024] OR [ipv4-addr:value ISSUBSET '10.0.0.0/8']

A pattern is a tree of observation expressions — each [ … ] block is one observation test — combined with AND, OR, and FOLLOWEDBY, optionally wrapped in qualifiers. Inside the brackets live comparison expressions: property tests combined with their own AND/OR.

Supported constructs

ConstructExamples
Comparison operators= != < <= > >= IN LIKE MATCHES ISSUBSET ISSUPERSET EXISTS
Negationfile:name NOT = 'x'
Boolean (inside [ ])[a = 1 AND b = 2], grouping with ( )
Object pathsfile:hashes.'SHA-256', network-traffic:protocols[0], x:list[*]
Reference traversalnetwork-traffic:src_ref.value
Typed literalst'2020-01-01T00:00:00Z' (timestamp), b'aGk=' (base64), h'cafe' (hex)
Observation operators[a] AND [b], [a] OR [b], [a] FOLLOWEDBY [b]
QualifiersWITHIN 60 SECONDS, REPEATS 5 TIMES, START t'…' STOP t'…'

FOLLOWEDBY and the qualifiers parse but are not yet matched — see Limitations.

Precedence

Observation level, loosest to tightest:

FOLLOWEDBY  <  OR  <  AND  <  qualifiers (postfix)  <  [ … ] / ( … )

Comparison level (inside [ ]): OR < AND < individual test. So [a = 1 OR b = 2 AND c = 3] parses as a = 1 OR (b = 2 AND c = 3) — use parentheses when in doubt.

Object paths

A path starts with the object type, then walks properties:

  • .key — property access (quote keys with special characters: hashes.'SHA-256')
  • [0] — list index
  • [*]any list element (the test passes if any element satisfies it)
  • A step through a _ref property dereferences to the referenced object (requires an object store at match time; see Matching).

The AST is data

parse() returns a Pattern that is fully serde-serializable — useful for tooling, caching, and the language bindings (every binding exposes the AST as a native object/dict/Map). A small example:

#![allow(unused)]
fn main() {
let pattern = stix::parse("[file:size > 1024]").unwrap();
println!("{}", serde_json::to_string_pretty(&pattern).unwrap());
}
{
  "expression": {
    "Observation": {
      "Test": {
        "path": { "object_type": "file", "steps": [ { "Key": "size" } ] },
        "operator": "GreaterThan",
        "negated": false,
        "value": { "Literal": { "Integer": 1024 } }
      }
    }
  }
}

Parse errors carry a byte-offset span into the source string:

parse error at bytes 19..20: expected a literal value

Objects & Bundles

stix-model imports STIX objects and bundles into a model that is typed where it helps and flexible everywhere else.

The trichotomy: typed, generic, custom

Every imported object is one of three shapes:

ShapeWhenWhat you get
TypedRecognized types (currently observed-data)A real Rust struct (ObservedData) with typed fields
GenericEvery other type — including custom x-* typesA value-backed property map preserving all properties
CustomTypes you register yourselfYour struct (see Custom Object Types)

The crucial design point: all three implement the ObjectView trait —

#![allow(unused)]
fn main() {
pub trait ObjectView {
    fn id(&self) -> Option<&str>;
    fn type_(&self) -> Option<&str>;
    fn property(&self, name: &str) -> Option<StixValue>;
}
}

— and the matcher consumes only ObjectView. That’s why unknown and custom types match out of the box, and why typed objects can synthesize properties on demand (a property need not literally exist in the JSON to be matchable).

StixValue

property() returns a StixValue — a JSON-shaped dynamic value: null, bool, integer, float, string, list, or object. Two things to know:

  • Integers and floats are distinct (numeric comparisons promote, but the values are stored as parsed).
  • Timestamps, hex, and binary are carried as strings at this layer; the matcher compares them against pattern literals as strings.

Bundles and the object store

#![allow(unused)]
fn main() {
use stix::model::{Bundle, ObjectStore, ObjectView};

let bundle = Bundle::from_json_str(json)?;      // validates type == "bundle"
let store  = ObjectStore::from_bundle(&bundle); // id → object index

let obj = store.get("ipv4-addr--a1").unwrap();
assert_eq!(obj.property("value").unwrap().as_str(), Some("198.51.100.5"));
}

The ObjectStore is how reference properties resolve: when a pattern path walks through src_ref, the matcher looks the id up in the store. No store → reference paths resolve to nothing (not an error — an empty result).

ObservedData, the typed SDO

observed-data is the one built-in typed struct because the matcher needs its fields: first_observed / last_observed / number_observed (temporal metadata) and object_refs (which SCOs were seen together). It tolerates STIX 2.0’s inline objects map as well, and retains unknown properties via a flattened map so nothing is lost on round-trip.

Matching

stix-matcher evaluates a parsed pattern against observations and reports whether — and where — it matched.

Observations

An observation is a set of objects seen together, plus temporal metadata (first_observed, last_observed, number_observed). This mirrors the STIX semantics and the MITRE reference implementation: each observed-data SDO is one observation, its object_refs naming the objects in it.

Four entry points

All normalize to observations internally; pick by what you have:

Entry pointInputUse when
match_bundle(&pattern, &bundle)a whole Bundleyou have bundle JSON — the common case; derives observations from its observed-data SDOs
match_observed_data(&pattern, &sdos, &store)observed-data SDOs + an ObjectStoreMITRE-compatible; you manage the store
match_observations(&pattern, &observations)pre-built Observationsyou construct observations yourself
match_scos(&pattern, &scos)a flat object listquick tests; the list is treated as one observation

The result is a MatchResult: is_match() plus observations() — the indices of the observations that participated in the match.

How a [ … ] block evaluates: binding enumeration

Within one observation, if a comparison expression references multiple constraints on the same object type, they must be satisfied by one object, not spread across several. The matcher implements this by binding enumeration: for each distinct object type in the expression, it tries each candidate object of that type, and the expression matches if some assignment makes the boolean tree true.

Concretely, the pattern [file:name = 'evil.exe' AND file:size = 10]:

// MATCHES — one file satisfies both constraints
{ "objects": [ { "type": "file", "name": "evil.exe", "size": 10 } ] }

// DOES NOT MATCH — constraints hold only across two different files
{ "objects": [
  { "type": "file", "name": "evil.exe", "size": 99 },
  { "type": "file", "name": "ok.txt",   "size": 10 } ] }

Constraints on different types ([ipv4-addr:value = … AND domain-name:value = …]) bind independently — one object per type, all within the same observation.

Observation-level logic

  • A [ … ] block matches if any observation in the input satisfies it.
  • [a] AND [b] requires both blocks to be satisfied — possibly by different observations.
  • [a] OR [b] requires either.
  • [a] FOLLOWEDBY [b] and the WITHIN/REPEATS/START..STOP qualifiers parse but return an explicit Unsupported error at match time — they never silently pass. See Limitations.

Operator semantics worth knowing

  • LIKE uses SQL wildcards (%, _) and is anchored (whole-value).
  • MATCHES is a regular expression, unanchored. An invalid regex never matches.
  • ISSUBSET / ISSUPERSET operate on IP addresses and CIDR ranges (IPv4/IPv6, never mixed families).
  • EXISTS tests that a path resolves to any value at all.
  • NOT before an operator negates that single test.
  • Numeric comparisons promote integers to floats; strings (including timestamps) compare lexicographically.

Custom Object Types

Custom and unknown STIX types (x-* or anything else) already parse and match with zero registration — they become generic value-backed objects that preserve every property. Registration is for the three things the generic path can’t give you: typed access, validation, and computed properties.

Rust: typed structs

Register your own struct for a type; parsed bundles then carry your type, and you can downcast back to it:

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use stix::model::{ModelRegistry, ObjectView, StixValue};

#[derive(Debug, Serialize, Deserialize)]
struct AcmeWidget { #[serde(rename = "type")] type_: String, id: String, risk_score: i64 }

impl ObjectView for AcmeWidget {
    fn id(&self) -> Option<&str> { Some(&self.id) }
    fn type_(&self) -> Option<&str> { Some(&self.type_) }
    fn property(&self, name: &str) -> Option<StixValue> {
        match name {
            "risk_score" => Some(StixValue::Integer(self.risk_score)),
            // a computed property — synthesized, not stored in the JSON:
            "risk_band" => Some(StixValue::String(
                if self.risk_score > 80 { "high" } else { "low" }.into())),
            _ => None,
        }
    }
}

let mut registry = ModelRegistry::new();
registry.register::<AcmeWidget>("x-acme-widget");
let bundle = registry.parse_bundle(json)?;

// typed access after parsing:
if let Some(w) = bundle.objects[0].downcast_ref::<AcmeWidget>() { /* w.risk_score */ }
}

Because risk_band is exposed through ObjectView, the pattern [x-acme-widget:risk_band = 'high'] matches a property that never existed in the JSON. A runnable version lives in the repo: cargo run -p stix-rust --example custom_model.

Rust: data-level hooks

No struct needed — register a Value → Result<Value> hook for validation and enrichment. It runs once per object at parse time; the result is stored as data, so matching stays callback-free:

#![allow(unused)]
fn main() {
registry.register_handler("x-acme-widget", |mut obj| {
    if obj.get("risk_score").is_none() {
        return Err(stix::model::ModelError::InvalidObject("missing risk_score".into()));
    }
    let score = obj["risk_score"].as_i64().unwrap_or(0);
    obj["risk_band"] = serde_json::json!(if score > 80 { "high" } else { "low" });
    Ok(obj)
});
}

A hook rejection surfaces from parse_bundle as a validation error.

The same idea in every binding

Each binding exposes the identical import-time hook — a host function taking and returning the object; throwing rejects it (a ValidationError / ValidationException):

# Python
engine.register_type("x-acme-widget", lambda obj: {**obj,
    "risk_band": "high" if obj.get("risk_score", 0) > 80 else "low"})
// TypeScript (Node and wasm)
engine.registerType("x-acme-widget", (obj) => ({
  ...obj, risk_band: obj.risk_score > 80 ? "high" : "low" }));
// Java
engine.registerType("x-acme-widget", obj -> {
    long score = ((Number) obj.getOrDefault("risk_score", 0)).longValue();
    obj.put("risk_band", score > 80 ? "high" : "low");
    return obj;
});

Hooks always run at parse/import time, synchronously — never during matching — so there is no cross-language callback overhead on the hot path.

Python

The stix module (PyO3 + maturin). Deep structure — the pattern AST and bundle objects — arrives as native dict/list. Ships type stubs (py.typed).

Install

pip install stix-rust

From source instead: pip install "maturin>=1.5,<2.0" && cd bindings/python && maturin develop

Worked example

import stix

engine = stix.Engine()

# 1. parse a pattern; the AST is a dict
pattern = engine.parse_pattern("[ipv4-addr:value = '198.51.100.5']")
print(pattern.ast["expression"])

# 2. import a bundle; iterate its objects
bundle = engine.parse_bundle(open("bundle.json").read())
print(len(bundle), [o["type"] for o in bundle])
print(bundle.object(0))          # dict, or None if out of range

# 3. match — hit and miss
result = engine.match_bundle(pattern, bundle)
print(result.matched, result.observations)
miss = engine.parse_pattern("[ipv4-addr:value = '203.0.113.9']")
assert not engine.match_bundle(miss, bundle).matched

# 4. custom type with a computed property
def normalize(obj):
    obj["risk_band"] = "high" if obj.get("risk_score", 0) > 80 else "low"
    return obj
engine.register_type("x-acme-widget", normalize)
banded = engine.parse_bundle(widget_bundle_json)
hit = engine.parse_pattern("[x-acme-widget:risk_band = 'high']")
assert engine.match_bundle(hit, banded).matched

Errors

ExceptionRaised by
stix.ParseErrorinvalid pattern syntax
stix.ModelErrorinvalid JSON / not a bundle
stix.MatchErrormatching failure (e.g. unsupported feature reached)
stix.ValidationErrora register_type hook raised

All subclass stix.StixError. Hooks run at parse_bundle time; raising any exception inside one rejects the object.

Notes

  • Type stubs + py.typed are included — IDEs and mypy see precise signatures.
  • Requires Python ≥ 3.8 (abi3 wheel).

Java

Package io.github.benjaminsmall.stix (JNI via jni-rs). Deep structure arrives as Jackson Map<String, Object>.

Install

Maven Central: not yet published — the Java binding is currently source-only. The coordinate below is the planned artifact.

// Gradle (Kotlin DSL)
implementation("io.github.benjaminsmall:stix:0.1.0")

From source instead: cd bindings/java && gradle test builds the native library (cargo) and runs JUnit. Tests load it from rust/target/release via java.library.path.

Worked example

import io.github.benjaminsmall.stix.*;
import java.util.Map;

try (Engine engine = new Engine()) {

    // 1. parse a pattern; the AST is a Map
    try (Pattern pattern = engine.parsePattern("[ipv4-addr:value = '198.51.100.5']")) {
        Map<String, Object> ast = pattern.ast();

        // 2. import a bundle; iterate its objects
        try (Bundle bundle = engine.parseBundle(json)) {
            System.out.println(bundle.objectCount());
            for (Map<String, Object> obj : bundle) System.out.println(obj.get("type"));
            bundle.object(99);   // Optional.empty() when out of range

            // 3. match — hit and miss
            MatchResult result = engine.matchBundle(pattern, bundle);
            System.out.println(result.matched() + " " + result.observations());
        }
    }

    // 4. custom type with a computed property
    engine.registerType("x-acme-widget", obj -> {
        long score = ((Number) obj.getOrDefault("risk_score", 0)).longValue();
        obj.put("risk_band", score > 80 ? "high" : "low");
        return obj;
    });
    // [x-acme-widget:risk_band = 'high'] now matches enriched bundles
}

Errors

ExceptionThrown by
ParseExceptioninvalid pattern syntax
ModelExceptioninvalid JSON / not a bundle
MatchExceptionmatching failure
ValidationExceptiona registerType hook threw

All extend StixException (unchecked, carries a code()).

Notes

  • Engine, Pattern, and Bundle hold native handles: use try-with-resources; a java.lang.ref.Cleaner frees anything not explicitly closed.
  • Hooks run at parseBundle time, applied Java-side (Jackson) — no JNI callbacks.
  • Bundling the native library into the jar per-platform is a publish-time follow-up.

TypeScript (Node)

@stix-rust/node — a native Node addon (napi-rs). Deep structure arrives as plain JS objects. If you need the browser, use the wasm package; the API is identical.

Install

npm install @stix-rust/node

From source instead: cd bindings/typescript-node && npm install && npm run build

Worked example

import { Engine, ParseError, ValidationError } from "@stix-rust/node";

const engine = new Engine();

// 1. parse a pattern; the AST is a plain object
const pattern = engine.parsePattern("[ipv4-addr:value = '198.51.100.5']");
console.log(pattern.ast.expression);

// 2. import a bundle; iterate its objects
const bundle = engine.parseBundle(json);
console.log(bundle.objectCount(), [...bundle].map((o) => o.type));
bundle.object(99); // undefined when out of range

// 3. match — hit and miss
const result = engine.matchBundle(pattern, bundle);
console.log(result.matched, result.observations);
const miss = engine.parsePattern("[ipv4-addr:value = '203.0.113.9']");
console.assert(!engine.matchBundle(miss, bundle).matched);

// 4. custom type with a computed property
engine.registerType("x-acme-widget", (obj) => ({
  ...obj,
  risk_band: obj.risk_score > 80 ? "high" : "low",
}));
const banded = engine.parseBundle(widgetBundleJson);
const hit = engine.parsePattern("[x-acme-widget:risk_band = 'high']");
console.assert(engine.matchBundle(hit, banded).matched);

Errors

Error classThrown by
ParseErrorinvalid pattern syntax
ModelErrorinvalid JSON / not a bundle
MatchErrormatching failure
ValidationErrora registerType hook threw

All extend StixError (which carries a .code). Hooks run at parseBundle time; throwing inside one rejects the object.

Notes

  • Prebuilt binaries per platform ship with the published package; from source, the build compiles the addon for your machine.
  • Full TypeScript types included; Node ≥ 18.

TypeScript (wasm)

@stix-rust/wasm — a portable WebAssembly build (wasm-bindgen) that runs in Node and the browser. The API is identical to @stix-rust/node; on the web target the module is initialized asynchronously first.

Install

npm install @stix-rust/wasm

From source instead: cd bindings/typescript-wasm && npm install && npm run build (Node target; npm run build:web produces the browser build.)

Worked example

import { Engine, ParseError, ValidationError } from "@stix-rust/wasm";

const engine = new Engine();

// 1. parse a pattern; the AST is a plain object
const pattern = engine.parsePattern("[ipv4-addr:value = '198.51.100.5']");
console.log(pattern.ast.expression);

// 2. import a bundle; iterate its objects
const bundle = engine.parseBundle(json);
console.log(bundle.objectCount(), [...bundle].map((o) => o.type));
bundle.object(99); // undefined when out of range

// 3. match — hit and miss
const result = engine.matchBundle(pattern, bundle);
console.log(result.matched, result.observations);
const miss = engine.parsePattern("[ipv4-addr:value = '203.0.113.9']");
console.assert(!engine.matchBundle(miss, bundle).matched);

// 4. custom type with a computed property
engine.registerType("x-acme-widget", (obj) => ({
  ...obj,
  risk_band: obj.risk_score > 80 ? "high" : "low",
}));
const banded = engine.parseBundle(widgetBundleJson);
const hit = engine.parsePattern("[x-acme-widget:risk_band = 'high']");
console.assert(engine.matchBundle(hit, banded).matched);

Errors

Error classThrown by
ParseErrorinvalid pattern syntax
ModelErrorinvalid JSON / not a bundle
MatchErrormatching failure
ValidationErrora registerType hook threw

All extend StixError (which carries a .code). Hooks run at parseBundle time; throwing inside one rejects the object.

Notes

  • Browser use: build with the web target and initialize the module before use (per wasm-pack’s --target web conventions); after init the API above is the same.
  • Everything runs synchronously and single-threaded inside the wasm module — fine for the parse/import/match workload.

Architecture

Crate graph

Five focused crates with acyclic edges; each lower crate is usable standalone. Four bindings wrap one shared FFI facade:

graph TD
    subgraph core [Rust core]
        P[stix-pattern<br/>lexer + parser] --> U[stix<br/>umbrella]
        M[stix-model<br/>objects, bundles, registry] --> U
        P --> X[stix-matcher<br/>engine]
        M --> X
        X --> U
        U --> F[stix-ffi<br/>facade]
    end
    subgraph bindings [Language bindings]
        F --> PY[python<br/>PyO3]
        F --> JV[java<br/>jni-rs]
        F --> TN[ts-node<br/>napi-rs]
        F --> TW[ts-wasm<br/>wasm-bindgen]
    end

Data flow

flowchart LR
    PS[pattern string] -->|stix-pattern::parse| AST[Pattern AST]
    BJ[bundle JSON] -->|ModelRegistry::parse_bundle| B[Bundle]
    B --> OS[ObjectStore<br/>id → object]
    AST --> MX{{stix-matcher}}
    B --> MX
    OS -->|deref _ref paths| MX
    MX --> MR[MatchResult<br/>matched + observation indices]

Custom-type hooks run inside parse_bundle (once per object, synchronously); their output is stored as data, so nothing re-enters host-language code during matching.

One match, step by step

Pattern: [network-traffic:src_ref.value = '198.51.100.5'] against a bundle containing an ipv4-addr, a network-traffic whose src_ref points at it, and an observed-data referencing both.

  1. Observations. match_bundle finds the observed-data SDO → one observation containing the two SCOs, and builds an ObjectStore over the bundle.
  2. Candidates. The expression references one type, network-traffic; the observation has one candidate → one binding to try.
  3. Path resolution. src_ref resolves on the bound object to the string "ipv4-addr--a1"; because the path continues (.value), the matcher treats it as an id, dereferences it through the store, and reads value off the ipv4-addr"198.51.100.5".
  4. Operator. = compares the resolved value with the literal → true.
  5. Result. The observation satisfies the block → MatchResult with matched = true and that observation’s index.

The FFI facade

stix-ffi is a pure-Rust crate (no FFI macros) that every binding wraps: an Engine handle owning the registry, opaque Pattern/Bundle handles, a plain MatchOutcome, and a flat FfiError { code, message } each language maps onto its own exception hierarchy. Deep structure crosses as JSON; each binding converts it to native objects at its edge. This keeps all four bindings thin and behaviorally identical.

How the repo is run

The repository is organized into agent-owned areas (core, one per binding) with an ownership map and issue workflow in AGENTS.md; design specs and implementation plans live under docs/superpowers/.

Limitations & Caveats

The sharp edges, in one place. Read this before production use.

  1. FOLLOWEDBY is not matched. It parses, but evaluation returns an explicit Unsupported error. Status: planned.

  2. Temporal qualifiers are not matched. WITHIN, REPEATS, and START..STOP parse but return Unsupported at match time. Status: planned.

  3. Timestamps compare as strings. RFC3339 values are not parsed into instants: 2020-01-01T00:00:00Z2020-01-01T00:00:00.000Z even though they denote the same moment. Workaround: normalize timestamp formats on ingest (a custom-type hook is a good place).

  4. Binding enumeration, not full constraint search. Within an observation the matcher binds one object per referenced type. This gives correct “same object” semantics for the overwhelming majority of patterns, but exotic patterns needing several objects of the same type simultaneously may differ from the MITRE reference. Status: full binding-set semantics are future work.

  5. Reference paths need an ObjectStore. A path through src_ref (or any _ref) resolves via the store; without one it resolves to nothing — a non-match, not an error. match_bundle/match_scos build the store for you.

  6. Typed objects synthesize properties. What property() returns is not necessarily what the JSON contained (that’s a feature — see Custom Object Types) — but don’t assume a 1:1 mapping.

  7. [*] expands, per-element. x:list[*] = 'v' passes if any element matches; there is no cross-step backtracking beyond that expansion.

  8. Integers and floats are distinct in storage, and comparisons promote numerically — but a pattern’s 5 and data’s "5" (string) never match.

  9. LIKE is anchored; MATCHES is not. LIKE must cover the whole value; MATCHES searches anywhere in it. An invalid regex never matches (no error).

  10. ISSUBSET/ISSUPERSET are IP/CIDR-only. IPv4 or IPv6, never mixed families; unparseable input never matches. There is no generic string-set containment.

Also worth knowing: custom-type hooks run only at parse/import time — if you mutate an engine’s registrations, previously parsed bundles are unaffected.

API Reference

The full rustdoc API reference is published alongside this guide:

It is rebuilt from main on every merge. Every public item carries documentation — enforced by the missing_docs lint across all five crates.

Contributing

Issues and pull requests are welcome. The project keeps a disciplined loop: design specs and implementation plans live under docs/superpowers/, and the repository is divided into agent-owned areas described in AGENTS.md — file issues with an area:* label and keep PRs within one area.

Dev commands

Rust core (crates/):

cargo test                                          # all suites
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all
cargo doc --workspace --no-deps                     # API docs

Bindings (each is excluded from the root workspace and builds standalone):

cd bindings/python          && maturin develop && python -m pytest -q
cd bindings/typescript-node && npm install && npm test
cd bindings/typescript-wasm && npm install && npm test
cd bindings/java            && gradle test

This site:

cargo install mdbook mdbook-mermaid
mdbook serve docs/book      # live-reload preview at localhost:3000

Ground rules

  • Keep cargo test green and clippy clean (-D warnings, including missing_docs — every public item is documented).
  • Changes confined to one area per PR; cross-area work is split.
  • Interface changes update the area README and this book in the same PR.