A reusable, pure-Rust toolkit for working with STIX 2.1 — the OASIS standard for representing cyber threat intelligence. stix-rust lets you:
- Parse STIX patterning-language patterns into a typed, inspectable AST.
- Import STIX objects (SDOs, SCOs, SROs) and bundles into a strongly-typed-yet-flexible object model.
- Match patterns against a set of observed objects.
Reusability is the guiding principle: the crates have clean, minimal dependency edges so you can pull in only what you need, and Python, Java, and TypeScript bindings are built on those seams.
- What is STIX?
- Project status
- Workspace layout
- Installation
- Quick start
- Supported pattern grammar
- Architecture & design
- Development
- Roadmap
- Contributing
- License
STIX (Structured Threat Information eXpression) is a standardized JSON language for describing cyber threat intelligence — indicators, malware, threat actors, observed data, and the relationships between them. Two pieces matter most here:
-
STIX objects — JSON documents like an
ipv4-addr, afile, or anobserved-dataSDO, usually delivered together in a bundle. -
The STIX patterning language — a query-like syntax used inside
indicatorobjects to describe what to look for, e.g.:[ipv4-addr:value = '198.51.100.1' OR domain-name:value = 'evil.example']
stix-rust parses those patterns and (soon) evaluates them against observed objects to answer: "does this threat intelligence match what we saw?"
The Rust core, the FFI facade, and all four language bindings are complete and merged.
| Crate | Purpose | Status |
|---|---|---|
stix-pattern |
Lexer + parser for the patterning language → AST | ✅ Available |
stix-model |
Object model: values, objects, bundles, object store | ✅ Available |
stix-matcher |
Match a pattern AST against observed objects | ✅ Available |
stix |
Umbrella crate re-exporting everything + entry points | ✅ Available |
stix-ffi |
FFI-friendly facade wrapped by the language bindings | ✅ Available |
0.1.x: published on crates.io, PyPI, and npm. APIs may still evolve before 1.0.
stix-rust/
├── crates/
│ ├── stix-pattern/ # lexer + recursive-descent parser → pattern AST
│ ├── stix-model/ # StixValue, ObjectView, typed/generic objects, Bundle, ObjectStore
│ ├── stix-matcher/ # matching engine
│ ├── stix/ # umbrella crate (package `stix-rust`, lib `stix`)
│ └── stix-ffi/ # FFI facade wrapped by the bindings
├── bindings/ # python, java, typescript-node, typescript-wasm
├── docs/book/ # the documentation site (mdBook)
├── docs/superpowers/ # design specs & implementation plans
├── AGENTS.md # area ownership & agent workflow
└── RELEASING.md # release runbook
Dependency edges are deliberately minimal so each crate is independently usable:
stix-pattern→serdeonlystix-model→serde/serde_jsononly (notably notstix-pattern)stix-matcher→stix-pattern+stix-modelstix(umbrella) → all three;stix-ffi→stix
The Rust crates are the core. Language bindings live under
bindings/ — each has self-contained docs you can link to directly:
| Interface | Toolchain | Docs | Status |
|---|---|---|---|
| Python | PyO3 + maturin | bindings/python |
✅ available |
| Java | jni-rs | bindings/java |
✅ available |
| TypeScript (Node) | napi-rs | bindings/typescript-node |
✅ available |
| TypeScript (wasm) | wasm-bindgen | bindings/typescript-wasm |
✅ available |
Contributor and agent conventions are documented in AGENTS.md.
The stix-rust umbrella crate re-exports everything, so it's usually all you need:
cargo add stix-rust # Rust
pip install stix-rust # Python
npm install @stix-rust/node # or @stix-rust/wasmIndividual crates are also published, if you only need part of the toolkit:
cargo add stix-pattern # just the pattern parser
cargo add stix-model # just the object model
cargo add stix-matcher # parser + model + matching engineFrom source (git or path dependency)
[dependencies]
# by git
stix = { git = "https://github.com/benjamin-small/stix-rust", package = "stix-rust" }
stix-pattern = { git = "https://github.com/benjamin-small/stix-rust" }
# or, with the repo cloned, by path
stix-pattern = { path = "../stix-rust/crates/stix-pattern" }use stix_pattern::parse;
fn main() {
let pattern = parse("[ipv4-addr:value = '198.51.100.1']").unwrap();
// The result is a typed AST you can walk or serialize.
let json = serde_json::to_string_pretty(&pattern).unwrap();
println!("{json}");
}The parser handles the full patterning grammar — comparison operators, boolean
logic, observation operators (AND / OR / FOLLOWEDBY), and the
WITHIN / REPEATS / START..STOP qualifiers — and reports precise errors with
byte spans:
use stix_pattern::parse;
let err = parse("[ipv4-addr:value = ]").unwrap_err();
println!("{err}"); // parse error at bytes 19..20: expected a literal valueuse stix_model::{Bundle, ObjectStore, ObjectView, StixObject, TypedObject};
let raw = r#"{
"type": "bundle",
"id": "bundle--a1",
"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": 5,
"object_refs": ["ipv4-addr--a1"] }
]
}"#;
// Parse the bundle and index it by id.
let bundle = Bundle::from_json_str(raw).unwrap();
let store = ObjectStore::from_bundle(&bundle);
// Recognized types deserialize into typed structs; everything else stays generic.
for obj in &bundle.objects {
if let StixObject::Typed(TypedObject::ObservedData(od)) = obj {
println!("observed {} object(s) at {}", od.number_observed, od.first_observed);
// Resolve the referenced cyber-observable through the store.
for id in od.sco_ids() {
let sco = store.get(id).unwrap();
// `ObjectView` gives uniform access to any object, typed or generic.
println!(" {} = {:?}", sco.type_().unwrap(), sco.property("value"));
}
}
}The matcher accepts several entry points (observations, observed-data SDOs, a
whole bundle, or a flat SCO list), all reducing to the same engine:
use stix::{parse, matcher::match_bundle};
use stix::model::Bundle;
let bundle = Bundle::from_json_str(raw).unwrap();
let pattern = parse("[ipv4-addr:value = '198.51.100.5']").unwrap();
let result = match_bundle(&pattern, &bundle).unwrap();
assert!(result.is_match());Supported today: object-path resolution (including _ref/_refs dereferencing
through the bundle), every comparison operator, boolean logic with correct
"same object" binding within an observation, and observation-level AND/OR.
FOLLOWEDBY and the temporal qualifiers parse but return
MatchError::Unsupported rather than silently passing.
stix-pattern parses the complete STIX 2.1 patterning grammar:
| Construct | Examples |
|---|---|
| Comparison operators | = != < <= > >= IN LIKE MATCHES ISSUBSET ISSUPERSET EXISTS |
| Negation | file:name NOT = 'x' |
| Boolean (comparison) | [a = 1 AND b = 2], [a = 1 OR b = 2], grouping with ( ) |
| Object paths | file:hashes.'SHA-256', network-traffic:protocols[0], x:list[*] |
| Typed literals | t'2020-01-01T00:00:00Z' (timestamp), b'aGk=' (binary), h'cafe' (hex) |
| Observation operators | [a] AND [b], [a] OR [b], [a] FOLLOWEDBY [b] |
| Qualifiers | WITHIN 60 SECONDS, REPEATS 5 TIMES, START t'…' STOP t'…' |
Note on matching scope: the parser understands the entire grammar today. The matcher implements single-observation comparison + boolean matching and observation-level
AND/OR;FOLLOWEDBYsequencing and temporal qualifiers are parsed but return an explicitMatchError::Unsupportedresult rather than silently passing, until their matching semantics land.
A few principles shape the codebase:
- Hybrid object model. Common types (currently
observed-data) deserialize into typed Rust structs; any other or custom type falls back to a generic value bag. Both implement a singleObjectViewtrait, so consumers and the matcher get one uniform accessor (id(),type_(),property(name)) regardless of the backing representation. Custom and unknown properties are always preserved. - Version seam. A
SpecVersionenum and internal trait boundaries leave room to add STIX 2.0 (and future versions) without churning the public API. The library targets 2.1 today. - No silent failure. Invalid patterns fail at parse with a precise span; model errors are distinct from "no match"; unimplemented matching features report themselves explicitly.
- Bindings-ready. Pure Rust, no parser-generator or C dependencies, and a
serde-serializable AST — so Python (PyO3) and TypeScript (wasm/napi) bindings can be added cleanly in a later phase.
The full design rationale lives in
docs/superpowers/specs/, and the task-by-task
implementation plans in docs/superpowers/plans/.
Custom and unknown STIX types already parse and match out of the box (they become a
generic value-backed object). When you want typed access, validation, or
computed properties, register them with a ModelRegistry.
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)),
// computed property, resolved on demand
"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).unwrap();
// Typed access after parsing:
if let Some(w) = bundle.objects[0].downcast_ref::<AcmeWidget>() { /* w.risk_score */ }A runnable version lives in crates/stix/examples/custom_model.rs
— cargo run -p stix-rust --example custom_model.
For validation or computed properties without a struct, register a
Value -> Result<Value> hook. It runs once per object at import; the result is
stored as data, so matching stays callback-free:
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)
});You don't define a Rust struct from a binding. Typed access is native to the host
language (define a TS interface/Python class over the parsed object), and custom
types match with no registration at all. For validation or computed properties you
register the same import-time hook — a host function the core invokes once per
object at parse time:
stix.registerType("x-acme-widget", {
normalize(obj) {
if (!obj.risk_score) throw new Error("missing risk_score");
return { ...obj, risk_band: obj.risk_score > 80 ? "high" : "low" };
},
});Requires a recent stable Rust toolchain (developed against Rust 1.95).
# Build everything
cargo build
# Run the full test suite (unit + integration + doc tests)
cargo test
# Test a single crate
cargo test -p stix-pattern
cargo test -p stix-model
# Lint (the project keeps clippy clean with warnings denied)
cargo clippy --all-targets -- -D warnings
# Format
cargo fmt --allThe crates are developed test-first; stix-pattern additionally ships a
conformance corpus of valid/invalid patterns under
crates/stix-pattern/tests/fixtures/.
See docs/testing.md for the test scope and current measured coverage status. Runtime and release configuration are described in docs/configuration.md.
-
stix-pattern— lexer, full-grammar parser, serde AST, conformance corpus -
stix-model—StixValue,ObjectView, typed/generic objects,Bundle,ObjectStore,SpecVersion -
stix-matcher— object-path resolution, all comparison operators, boolean logic, multiple entry points (observations / observed-data / bundle / SCO list) -
stix— umbrella crate with high-level entry points -
FOLLOWEDBYsequencing + temporal-qualifier matching - STIX 2.0 support via the version seam
- Consumer-injectable custom typed models (registry)
- Python (PyO3), Java (jni-rs), and TypeScript (napi + wasm) bindings
Issues and pull requests are welcome. This is an early-stage project; if you're
planning a larger change, opening an issue first to discuss direction is
appreciated. Please keep cargo test green and cargo clippy --all-targets -- -D warnings clean.
The workspace manifests declare MIT OR Apache-2.0. See docs/licensing.md for the repository-wide licensing record.
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.