Behavioral detectors
Sigma answers "does this event match a known pattern". The behavioral
layer answers a different question: "is this entity behaving like
itself". It runs in waked on the edge, after the C hot
path has parsed, enriched and scored, and it works on entity history
rather than on single events.
Sixteen detectors run per analytics cycle, in three groups that differ by what they consume. Twelve read one entity at a time. Three read the signals the first twelve produced. One is keyed on destinations instead of sources. Getting that structure right matters, because it is what lets a detector fire on something no single entity record contains.
How they run
Group 1: per-entity
Registered in _setup_detectors() and called through
process_entity(), once per entity per cycle. Thresholds are
BehavioralConfig defaults; a profile can override them per
entity class.
| Detector | What it detects | Key thresholds | MITRE |
|---|---|---|---|
| profile | Entity-class profiling with a dynamic whitelist, adjusting thresholds per profile. An analyst profile:xxx annotation overrides auto-classification. | profile_auto_classify | TA0006, T1110 |
| first-seen | Brand-new entities, or ones returning after a prolonged absence. | firstseen_return_days=7 | TA0001, TA0043 |
| temporal | Activity landing in a historically quiet hour, against a 24-slot hourly profile built from 1h buckets. | temporal_quiet_ratio=0.10temporal_min_days=3 | TA0005 |
| velocity | Any metric exceeding mean + k·sigma against recent 1h-bucket history. | velocity_k=3.0velocity_min_windows=6 | multiple |
| lateral-movement | Fan-out to many new destinations, brute-force then success then spread, and credential pivot across the entity graph. | lateral_fanout_threshold=5 | TA0007, TA0008, T1021, T1046, T1078 |
| data-staging | Slow exfiltration: monotonically rising egress, egress concentrated on one destination, or a normally inbound entity reversing direction. | staging_growth_factor=2.0staging_trend_ratio=0.65staging_min_windows=6 | TA0010, T1041, T1048 |
| geo-travel | One entity seen in two locations too far apart for the elapsed time. | geo_max_speed_kmh=900.0 | TA0001, T1078 |
| dns-tunneling | Oversized queries and an inverted out/in ratio on UDP/53 and TCP/853. DoT is reported separately as dot_tunneling, since DoT leaving a non-resolver host is itself odd. | avg bytes_out/msg_count > 200dns_tunnel_whitelist | TA0010, T1071.004, T1048.003 |
| icmp-tunneling | Oversized ICMP payloads (average above 200 bytes against a normal ~84) and asymmetric request/reply ratios. | ratio > 2.0, proto=2 flows | TA0010, T1095, T1048.003 |
| cross-category | Patterns across outcome categories from per-category counters: auth failures plus app activity, network denies plus app success, auth failures plus network activity. Parser-agnostic. | per-category counters | TA0005, TA0006, TA0008 |
| credential-pivot | Auth failures in recent history followed by a success in the current window. The C-side CREDENTIAL deposit needs both in the same window; this catches brute force that spans hours. | entity history | TA0006, T1078, T1110 |
| slow-scan | Distributed reconnaissance: roughly one host per hour over days, never crossing a per-window threshold but accumulating unique destinations. | slow_scan_dst_threshold=50slow_scan_min_days=3slow_scan_max_rate=5.0 | TA0043, T1046, T1595 |
Why slow-scan needs its own detector
Velocity is a per-window test. An attacker who stays under it every single window is invisible to it, however long they keep going. The two detectors read the same traffic and disagree, which is the point.
slow_scan_max_rate=5.0 is an upper bound rather than a
floor: a genuinely slow scan is defined partly by being quiet.
Illustrative, representative shape.
Group 2: correlation over accumulated signals
These three are held as separate attributes on the engine rather than in
self._detectors. Their process_entity returns
nothing; the work happens once the per-entity pass has produced its
signals, through correlate() for attack-chain and sequence
and detect() for role-incongruence. They read the signal
set and write back into it.
- role-incongruence
- Runs after every other detector and deliberately before
profile suppression is applied, so behaviour forbidden for a role is
promoted rather than silenced. The principle is that a declared
identity is ground truth for suppression, so
behaviour incongruent with that identity is a
detection, not a reason to reclassify the asset.
Any signal falling in the role's
profile_forbiddenset is promoted to a high-severityrole_incongruencesignal. It also synthesises host-compromise observations (shellcode_on_host,edr_alert) from entity counters, so infra-role compromise is caught even when no signature fired. It emits signals only and owns no scoring. - attack-chain
- Groups signals by entity and maps their MITRE tactics onto known kill-chain patterns, emitting when several phases are observed. Matching is order-agnostic: it looks for a set of tactics, not a sequence.
- sequence
- The order-sensitive counterpart, and the reason both exist. Three
distinct shapes: temporal ordering, where the order
itself is the signal rather than co-occurrence;
absence, where an entity with a learned periodic
cadence stops; and cross-entity
handoff, an ordered pass from one entity to another
(A scans, B receives, B pivots). Tunables:
sequence_window_h=6,sequence_min_steps=2,sequence_absence_gap_mult=3.0,sequence_handoff_window_s=3600.
Group 3: destination-keyed
destination is the odd one out and deliberately not in
_setup_detectors(). It runs through
process_dst_entity() and operates on destination-keyed
entities rather than source entities, which lets it see patterns that
are invisible from any single source:
shared_target: three or more distinct sources converging on one destination that is registering failures. Not auth-only: the same shape covers network denies, app errors, EDR, cloud and mail blocks.compromise_handoff: the sources that failed auth are not the sources that then succeeded, which is what a credential being passed between hosts looks like from the target's side.
Beaconing runs in C
Beaconing is detected by the mmsigma correlation engine in
the C hot path, not by the behavioral layer. It runs at line rate,
resolves sub-hour cadence, and has a bucketed long-horizon mode. The
fire is projected into a beaconing behavioral signal
carrying TA0011 with T1071 and T1571, so downstream it looks like any
other signal.
The practical consequence: beaconing coverage depends on a Sigma base rule feeding the correlation. It is a rule-set property, not an always-on detector, which is why it is listed here and not in the table above.
Known gaps
Publishing a detector list without its gaps would be marketing. Two are documented in the source:
- DoH payload is not covered
- DNS over HTTPS on port 443 is indistinguishable from ordinary HTTPS
at the flow-statistics layer that
dns-tunnelingworks on. Plain DNS and DoT are covered. A separatebeaconingcorrelation can still see a client that re-resolves the same public DoH resolver on a cadence. That is not a look inside the HTTPS body. - Beaconing depends on a base rule
- Beaconing fires only where a Sigma base rule feeds the correlation. An edge without one has no beacon coverage, and nothing in the behavioral engine reports that absence.
The console's detection health check covers the related failure mode for the Sigma layer: rules that are compiled but never fire, and rules that fire on everything.
Output
Every detector emits the same BehavioralSignal shape:
detector name, entity key, signal type, confidence, severity,
description, a detector-specific evidence dictionary, and
its MITRE tactic and technique. Signals persist to
EntityStore.behavioral_signals and surface on the console's
signals page. Retention is signal_retention_h=168 (7 days),
or critical_retention_h=720 (30 days) for high and critical
severity, with an exponential decay_half_life_h=24.0.