Secrust: When Adding Cores Made It Slower
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
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.
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
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.
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.
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.
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.
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.