I spent a week making Secrust faster, got the realistic workload up 301%, then set parallelism to four and watched it drop 23% below two threads. That result changed how I think about the engine more than any of the optimisations did.

This is a walk through the decisions rather than a feature list. What the thing is for, what I chose, what I measured, what I got wrong, and what I have deliberately not built.

The problem it answers

The signal in security telemetry lives in correlation. Many authentication failures, then a success, for the same principal is an account takeover. No individual event in that sequence is suspicious, so a per-event detector either misses it or fires on every failed login in your estate.

The tooling that does correlation properly — event time, keyed state, windows — mostly assumes you can run a cluster. Apache Flink is the reference implementation of those ideas and it is genuinely excellent, and it also brings a JobManager, TaskManagers, a JVM heap, and an operations story.

So the question I actually wanted answered was narrow:

Secrust is that experiment, shipped. Write Sigma rules, feed it OCSF events, get alerts. No cluster, no JVM, no brokers.

What it does

Four detection strategies, which between them cover the shapes security rules actually take:

  • value match — stateless, one event, one predicate
  • counter — N matching events within a duration, optionally grouped by a field
  • window — tumbling windows with a count or distinct-count trigger
  • correlation — several base rules firing for the same key within a timespan, optionally in a declared order

The demo replays a seeded attack through a five-file rule pack covering all four:

git clone https://github.com/BlueSquadron/Secrust.git
cd Secrust
./scripts/demo.sh
Terminal output from the Secrust demo showing eight compiled rules across value-match, counter, correlation and window strategies, the derived worker layout, and a replay of 27 OCSF events.
Eight compiled rules and the worker layout the engine chose for them. Nothing on this screen was configured by hand — which is the next section.
Terminal output listing the seven alerts Secrust raised, in event-time order, including the lateral movement correlation alert.
Seven alerts from 27 events. lateral_movement is the one that justifies the whole project.

The decision the engine makes for you

There is no thread count to configure, and no partition key to declare. Both are derived from the compiled rule set at build time.

The rule set feeds analyze_rules at build time, which produces three destinations: inline evaluation on the caller's thread, a keyed pool per group-by field, and a singleton pool for ungrouped counters, windows and correlations.
Two deprecated builder methods survive from before this decision. They are no-ops now, and the docs say so rather than pretending they were never there.

This is the design choice I would defend hardest, and the reason is not ergonomics.

A counter grouped by user.name is only correct if every event for a given user reaches the worker holding that user’s state. Hash-partitioning by the group-by field is what makes that true. If partitioning is a configuration option, then a user can configure a wrong answer — silently, with no error, producing counts that are simply too low.

So it is not a knob. The rules already state which fields group state, and the engine reads the partition plan off them. Value-match rules need no state at all, so they are evaluated inline on the submitting thread and never enter a pool. Ungrouped counters, windows and correlations cannot be partitioned by key, so they share one worker.

That last sentence is where the interesting result comes from.

Then I added cores

Throughput against parallelism for five workloads. The mixed workload peaks at two threads with plus eighteen percent, then falls to minus forty-three percent at eight threads.
Two threads is the peak for every keyed workload. Four is worse than one for the mixed case, and eight is considerably worse.

The mixed workload — value match plus counters plus a window, which is what a real rule pack looks like — gains 18% going from one thread to two, then loses 23% at four and 43% at eight.

The cause is the singleton pool. Windows and correlations have no key to partition on, so they sit on a single worker by necessity. Past two threads the additional workers are not sharing that load; they are adding routing, channel and cache-coherence cost around a serial section that cannot move.

Amdahl, arriving on schedule.

Terminal output of the Secrust throughput benchmark over 100,000 events with a mixed rule set.
Re-run it on your own hardware. The numbers in the repo are from an M-series laptop, and the docs say so.

Where the effort went

Eight passes over the hot path. This is the part I would want to read about someone else’s engine, so here it is with the numbers that justified each one.

Eight optimisation stages with throughput on the mixed workload, from 116K events per second to 465K. Stages two through five deliver the gains; stages six through eight are flat or slightly negative.
Five of eight passes moved the number. I kept the other three, and the reason is in the paragraph below.

The wins came from removing work rather than from adding cleverness. Replacing a deep clone per worker pool with an Arc refcount bump was worth 64% on its own. Moving stateless evaluation onto the caller’s thread — no channel, no worker — was worth another 32%. Dropping a full OCSF semantic validator in favour of schema-only parsing was worth 49%, and it is the one I would flag to a reviewer, because it is a capability trade rather than a pure optimisation: Secrust now parses the base fields and does not check OCSF semantics. That is documented, not hidden.

Passes six, seven and eight are flat or slightly negative. I kept them anyway. A custom Deserialize that avoids serde’s flatten buffering, match-in-place filtering with zero allocation, and borrowing field values through a Cow are all correct-by-construction improvements that removed allocation churn; the eighth added multi-filter AND logic and Sigma’s case-insensitive matching semantics, which is a correctness fix that happened to cost a little throughput.

Then I profiled where the remaining time goes, which is the least glamorous and most useful thing in the whole campaign.

Per-event cost breakdown. On the stateless path JSON parsing is 0.90 of 1.20 microseconds. On the stateful path it is 0.90 of 2.15 microseconds, alongside counter, window, tick, fan-out, key extraction and alert costs.
Every remaining micro-optimisation is competing for the quarter of the budget that is not the parser.

JSON parsing is 75% of the stateless per-event cost and 42% of the stateful one. Which means the next real win is not a faster evaluator — it is an API that lets a caller hand over an already-parsed event and skip the parse entirely. On paper that takes stateless throughput from ~835K to somewhere north of 5M events per second.

I have not shipped it. It widens the public API surface for a benefit that only applies to callers who construct events programmatically rather than receiving JSON, and I would rather add that when someone asks for it with a workload attached than guess at the signature now.

What I decided not to build

The repo carries a document comparing Secrust to Flink feature by feature, including everything Flink has that Secrust does not. Writing that was uncomfortable and it is the most useful page in the project.

The significant absences, in the order I would fix them:

  • Sliding windows — designed, not implemented. Tumbling windows are epoch-aligned, so a burst straddling a boundary is counted as two.
  • Watermarks and late-data handling — without them, out-of-order events can land in the wrong window or be missed.
  • Checkpointing — if the process dies, in-flight counter and window state is gone. Delivery is at-most-once, not at-least-once.
  • Hot rule reload — rules are loaded at engine construction. A detection team should not have to restart to deploy a rule, and today they do.

Each of those is a real limitation of the current design, and together they draw the boundary of the thesis. So I will say the unfashionable thing plainly: if your volume genuinely exceeds one large machine, or your correlation state has to survive restarts you do not control, run Flink. That sentence is in the repository too. A project that cannot name its own exit criteria is asking you to find them yourself, in production.

The sharp edges, written down

There is a gotchas.md in the repo listing behaviour that surprises people on their first day. Every item links to the code that causes it, and the file exists because of one decision worth explaining.

The worst of them: a Sigma rule with no timeframe is translated into one independent value-match rule per field, not into a single AND-ed condition. So this —

detection:
    selection:
        class_uid: 4003
        query.hostname|endswith: '.xyz'
    condition: selection

— becomes class_uid = 4003 or hostname ends with .xyz, and each half alerts on its own. A benign lookup fires the rule because the class matched. That is not what anyone writing that YAML intends.

The right fix is to AND the fields in the stateless path. That is a translator change with a blast radius across every existing rule, so for now the behaviour is documented, the workaround is given (add a timeframe, which routes through the counter path where filters are AND-ed), and it is listed as a good first issue.

Two more from the same file, because they are the kind of thing only the author knows: count() > 5 fires on the fifth event, not the sixth, because the condition parser reads the number and ignores the operator. And a rule referenced by a temporal correlation must be both stateful and ungrouped — value-match alerts never reach the correlator because they are raised inline, and grouped counters are hash-partitioned away from it.

Where the alerts go

Secrust emits; something has to receive.

Sources feed an OCSF normalisation stage, then Secrust for Sigma correlation, whose alerts feed Watari for case management.
One schema at the edge is what lets the same rule evaluate events from anywhere, and lets the alerts land somewhere that never heard of the producer.

The alerts Secrust produces are OCSF-shaped, which means Watari ingests them with no adapter between the two. That composition is not a coincidence — it is the reason both projects standardise on one schema at the edge rather than parsing per source.

Three integration surfaces exist: a Rust API, a C FFI shared library, and a gRPC server with a streaming alert subscription. The FFI is there so the engine can be embedded from Python, Go or C without a JVM-style bridge.

When not to use it

Rust 1.70+, MIT, and honestly: alpha-adjacent. It is v1.0.0 in the sense that the API is stable enough to build against, not in the sense that it has survived a year of production.

Use it if you want sub-millisecond correlation in about 10 MB of memory, starting in under 10 milliseconds, embedded in something. Do not use it if you need exactly-once semantics, crash recovery, or distributed scale.

The contributions I most want are detection rules that exercise correlation rather than single-event matching, and a report from anyone who runs it against real volume and watches it fall over. That second one is worth more than a thousand stars, and I have very few of either.