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

execution model waked/behavioral.py
The three groups of behavioral detectors and what each consumes Entity records feed twelve per-entity detectors, whose signals collect in a signal set. Three correlation detectors read that signal set and emit further signals back into it. A separate destination-keyed detector reads destination entities. In parallel, the C mmsigma beaconing correlation feeds a promoted beaconing signal directly into the same set. mmsigma beaconing C, line rate promoted to a beaconing signal entity records group 1 · process_entity() · 12 profile first-seen temporal velocity lateral-movement data-staging geo-travel dns-tunneling icmp-tunneling cross-category credential-pivot slow-scan one entity at a time signal set group 2 · correlate() · 3 role-incongruence before suppression attack-chain order-agnostic tactics sequence ordered / absence / handoff reads signals, emits signals dst entities destination-keyed destination group 3 · 1 EntityStore /signals
Group 2 both reads from and writes back to the signal set, which is why an attack chain can be built from detectors that never saw each other's entities. Beaconing arrives from the C correlation into the same signal set, rather than from a detector in group 1.

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.

DetectorWhat it detectsKey thresholdsMITRE
profileEntity-class profiling with a dynamic whitelist, adjusting thresholds per profile. An analyst profile:xxx annotation overrides auto-classification.profile_auto_classifyTA0006, T1110
first-seenBrand-new entities, or ones returning after a prolonged absence.firstseen_return_days=7TA0001, TA0043
temporalActivity landing in a historically quiet hour, against a 24-slot hourly profile built from 1h buckets.temporal_quiet_ratio=0.10
temporal_min_days=3
TA0005
velocityAny metric exceeding mean + k·sigma against recent 1h-bucket history.velocity_k=3.0
velocity_min_windows=6
multiple
lateral-movementFan-out to many new destinations, brute-force then success then spread, and credential pivot across the entity graph.lateral_fanout_threshold=5TA0007, TA0008, T1021, T1046, T1078
data-stagingSlow exfiltration: monotonically rising egress, egress concentrated on one destination, or a normally inbound entity reversing direction.staging_growth_factor=2.0
staging_trend_ratio=0.65
staging_min_windows=6
TA0010, T1041, T1048
geo-travelOne entity seen in two locations too far apart for the elapsed time.geo_max_speed_kmh=900.0TA0001, T1078
dns-tunnelingOversized 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 > 200
dns_tunnel_whitelist
TA0010, T1071.004, T1048.003
icmp-tunnelingOversized ICMP payloads (average above 200 bytes against a normal ~84) and asymmetric request/reply ratios.ratio > 2.0, proto=2 flowsTA0010, T1095, T1048.003
cross-categoryPatterns 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 countersTA0005, TA0006, TA0008
credential-pivotAuth 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 historyTA0006, T1078, T1110
slow-scanDistributed reconnaissance: roughly one host per hour over days, never crossing a per-window threshold but accumulating unique destinations.slow_scan_dst_threshold=50
slow_scan_min_days=3
slow_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 vs per-window rate same traffic, two tests
A slow scan staying under the per-window velocity threshold while accumulating unique destinations Fourteen hourly windows. In the top chart every window's message rate stays well under the velocity threshold, so the velocity detector never fires. In the bottom chart the count of unique destinations accumulates across the same windows and crosses the slow-scan threshold of 50, firing that detector instead. per-window test velocity msgs / window velocity threshold: mean + 3σ Never fires. Every window is under the line. cumulative test slow-scan unique destinations slow_scan_dst_threshold = 50 crosses at window 12 slow scan detected
The rate never moves; only the accumulation does. This is why 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_forbidden set is promoted to a high-severity role_incongruence signal. 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.
Absence detection is the one worth dwelling on. Every other detector fires on something happening. This one fires on something that reliably happened having stopped, which is how you notice an implant being cleaned up or a heartbeat being cut.

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:

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-tunneling works on. Plain DNS and DoT are covered. A separate beaconing correlation 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.