mmsigma

An rsyslog message-modification module that evaluates Sigma rules against every parsed log event, on the machine that collected the log. It does not interpret YAML at runtime. A Python compiler (pySigma) turns a rule corpus into a compact binary; a C matcher loads that artifact zero-copy and annotates $!sigma.

Correlation is the same C engine, in the same action. Commutative windows (event_count, value_count, temporal, sum/avg) merge across collocated peers by hashing each (corr_id, group) onto one owner. The module does not open sockets. Transport is ordinary rsyslog output (omfwd / omrelp). On MarlinOS the owner ring is a static list, named by the device overlay.

mmsigma is a stage of the WAKE pipeline. It also stands on its own: the matcher core is libsigma, the Advens Sigma engine, with no dependency on rsyslog or liblognorm, and the same engine drives a CLI and the test harness.

Why match on the collector

The usual path ships every line, indexes it, translates Sigma into the SIEM query language, then waits. Detection latency becomes pipeline latency, WAN bandwidth becomes a prerequisite, and the collector pays interpreter overhead on every event for work that was fixed when the ruleset was built.

On the collector the parsed event is already in memory. Compile once, match per event, ship the verdict. Forwarding raw logs is then a policy choice, not a requirement.

Compiler split

Slow, expressive, and trusting on the management host. Fast, minimal, and paranoid on every collector.

  offline (Python / pySigma)              online (C, each collector)
  --------------------------              -------------------------
  Sigma YAML corpus                       rsyslog
    |                                       |
    v                                       +-- mmnormalize + TurboVM
  taxonomy → ECS fields                     +-- mmsigma
  required-literal CNF                            1. category bucket
  rules.sigmac  ---- binary artifact ------→      2. Teddy SIMD prefilter
  rules.corr.json -- correlations ---------→      3. RPN verify + memo
  rules.sigmac.manifest.json -- ledger ----→      correlation windows
                                                  annotate $!sigma
                                                  emit $!sigma_contribution
                                                    |
                                                    v
                                              hashed omfwd to owner

The compiler accepts a large untrusted corpus and degrades per rule (a rule that fails to compile is recorded and skipped). The loader accepts nothing it cannot fully validate: magic, version, sizes, CRC32C, every internal reference in range. A truncated file mid-reload keeps the previous generation.

Three tiers, each with a soundness contract

1. Logsource / category buckets
A rule is narrowed into a category only when the compiler is confident. An unconstrained rule joins the always-verify set. A wrongly-bucketed rule would be a silent miss; a conservative extra verify is the accepted cost.
2. Required-literal CNF + Teddy SIMD prefilter
Compile-time extraction of needles the event must contain if the rule is to match. The scan is field-scoped text, SSE 4.2 / NEON substring, two-byte fingerprint. A prefilter hit is a candidate, never a decision. The prefilter can only over-select.
3. RPN verify with field memoization
Each surviving rule’s condition is a postfix program over selections. Field lookup runs at most once per distinct field per event. Operators cover EQ / contains / startswith / endswith, numeric compares, CIDR, PCRE2 regex, exists. Negation is boolean. Fieldless keywords compile to event.original (the raw line).

Every stage is checked against a linear-scan oracle: the narrowed hit-set must equal the exhaustive hit-set for every test event.

Bounded correlation

Sigma correlation primitives run next to the matcher, not inside it. State is operator-tunable and strictly bounded, so worst-case memory is never a function of adversary-controlled cardinality. Windows default to parsed event time (@timestamp, then aliases, then the syslog header).

PrimitiveMeaningAcross peers
event_count≥ N base-rule matches per group in a windowCOUNT merge
value_count≥ N distinct values of a field per groupDISTINCT merge
temporalbase rules A, B, C all occur (any order, or SEP #198)SEEN merge
value_sum / value_avgSUM or AVG of a numeric fieldSUM merge
value_percentile / medianexact Pk from the live ringlocal only
temporal_orderedA then B then Clocal only
beaconingperiodic arrivals (vendor extension)local only

Correlations whose base rule did not compile land in the dropped-rules ledger, which is persisted next to the artifact rather than counted and thrown away: a corpus you cannot see is a corpus you cannot distribute. The intent is that no rule disappears silently, and the compiler is built around it, though the property is only as good as its last audit.

Mesh pattern

One engine, two jobs. Match and local apply stay on the node that saw the log. Commutative partials for a group are applied on exactly one peer: the owner. That is not gossip. It is consistent hashing onto a ring the operator declares in the overlay.

mesh pattern owner = djb2(corr_id TAB group) % peercount
mmsigma contribution mesh among four edges Four edges. Three are named in the operator-configured owner ring; edge-3 is not. Edge-0 owns the group brute / 203.0.113.9. Edge-1 and edge-2 send COUNT, SUM, SEEN, DISTINCT partials to edge-0 over hashed omfwd. temporal_ordered stays local on every node. Pheromone gossip is a separate plane and does not carry Sigma contributions. configured peer ring pheromone $!sigma_contribution COUNT · SUM · SEEN · DISTINCT local only 0 edge-0 OWNER 1 edge-1 emit 2 edge-2 emit 3 edge-3 not in ring FLEET · who may join peer set 0 1 2 3 not in the ring this group corr_id=brute group=203.0.113.9 djb2(id TAB group) % 3 → 0
Same (corr_id, group) always lands on the same owner. The ring is what the overlay declares, not what is reachable: edge-3 is in the Fleet inventory but outside this ring. Pheromone gossip (PlumTree) is a different plane: it carries scored entities, not Sigma partials. Illustrative, RFC 5737 documentation IPs.
01 control

Fleet publishes the set

Who may be a Sigma peer is a control-plane fact. Zero-config on the edge: the overlay names the ring, the node never edits it.

02 liveness

SWIM is a separate plane

immesh carries membership and pheromone. It does not drive this ring: the module never consults liveness. Rebalancing a dead member is an overlay change.

03 reduce

Owner applies, others emit

Non-owners skip local apply for that group and attach $!sigma_contribution. The owner ingests JSON on the same mmsigma action so there is one engine process.

What travels

A contribution is a small JSON object (or an array of them): kind, corr_id, event-time ts, resolved group values, and the partial (count / numeric / seen_mask / value), emitted as an array with one entry per matching correlation rule. omfwd pool.policy="hash" routes it on the same djb2 key; that pooling mode is a MarlinOS-carried rsyslog patch pending upstream, not stock behaviour. It will not be PlumTree-gossiped.

A fire that ingested a remote partial sets $!sigma_correlation.correlations[].remote = true, marking it as cross-instance evidence rather than something one node saw end to end. The flag is written today but not yet read back, so nothing downstream ranks on it.

When a ring member dies

The ring is static, so this is the sharp edge. The module never consults liveness: it hashes onto peercount whatever the state of the peers. What happens next is worth knowing before you size a ring.

Non-owners skip local apply for the groups that hash to the dead index. omfwd with pool.policy="hash" may then linear probe to the next connected target, so the TCP session lands somewhere, but that is transport and not ownership. Ingest recomputes the owner and refuses any contribution this node does not own, counting it on corr.contrib.drop. Applying a mis-routed partial would split-brain the group when the real owner came back, so it is dropped instead.

The result is that a dead owner parks its share of the groups rather than corrupting them: those correlations stop advancing until the ring is rewritten, and everything else is unaffected. Losing a window is recoverable, and a group with two divergent partial states is not, so the trade is deliberate.

Making the ring track liveness is a control-plane job: something outside the module has to watch membership and re-render both peercount and the omfwd target list. Putting that inside mmsigma would cost the property that makes it portable, which is that it opens no sockets.

What it will not do

In WAKE, and without it

On a MarlinOS edge, per-source rulesets run mmenrich then mmwake. mmsigma runs after that, once, in the shared logstore ruleset. Parsed fields come from TurboVM. Matches feed entity scoring and optional OCSF export to a downstream SIEM. The pheromone mesh is the scoring gossip plane; this page is the Sigma reduce plane.

Proposed rsyslog contribution (contrib/mmsigma). Until that lands upstream, it ships in the MarlinOS rsyslog port. The upstream module does not require SWIM, Fleet, or .prev. Those are the WAKE wrapper. TAC is not on this plane.