Skip to content
sloth wiki-sync edited this page Oct 2, 2026 · 2 revisions

Alerts

Summary: A small dedup-by-key ring runs every poll. Each rule scans current state, builds a stable key, and either bumps an existing alert or appends a fresh one. New keys get a JSONL alert line and (when --pcap-dir is set) a per-alert pcap dump. Everything that happens to an alert after creation — escalation, changed evidence, expiry — rides the alert.* incident-lifecycle records added in #98.

Sources: docs/views/alerts.md, docs/views/dns.md, docs/views/connections.md, docs/views/deauth.md.

Last updated: 2026-09-23.


Engine

  • File: src/alerts.c.
  • Dedup key examples: scan:<ip>, threat-d:<domain>, threat-ip:<ip>:<port>.
  • New-key path: JSONL alert line + alert.create + (optional) pcap dump of the matching packets via src/alert_pcap.c. See pcap-export.
  • c clears all alerts and resets dedup state; future hits re-arm. Every open incident is resolved with reason: "cleared" first.

Incident lifecycle (#98)

One continuous run of a dedup key is an incident: it opens with alert.create, carries one incident_id through every alert.escalate / alert.update, and closes with exactly one alert.resolve. Before #98 only the create was visible downstream — a WARN→CRIT escalation updated the ring in place and emitted nothing.

  • Material change only. Any severity move emits (never throttled); a changed detail emits at most once per ALERT_UPDATE_MIN_S (60 s). An evaluation that re-renders identical evidence emits nothing, so a retained condition held across 100 polls is one create and silence.
  • Resolve after ALERT_RESOLVE_AFTER_S (300 s) in which no rule re-asserted the key — the last evaluation, not the last observation. Reasons: expired, evicted, cleared. A key that fires again afterwards opens a new incident with a new id.
  • Counters. count / evaluations are rule ticks; observations only moves when the evidence does. Timestamps split the same way (first_detected / last_evaluated vs first_observed / last_observed).
  • Durations run on CLOCK_MONOTONIC via the #88 seam in src/flood_window.c; exported timestamps stay wall clock.

Wire format and the full field table: jsonl-schema.

Severity vs confidence (#89)

Severity answers how bad is this if it is real. Confidence answers how likely is it to be real. They are separate fields on the alert and they move independently: a CRIT at 20 % and a WARN at 25 % are both meaningful findings and neither one dominates the other.

Most rules omit confidence. They assert a condition they observed directly — a flood counted, a cleartext credential seen — and have nothing to qualify. Rules that infer from circumstantial RF report both numbers: EVIL_TWIN is the first, reported as suspected impersonation with a percentage rather than as an established fact; KARMA_AP is the second (#90) — a bare SSID-count candidate is WARN, uncorroborated, and escalates to CRIT only when PNL overlap, a shared-victim deauth-then-lure chain, or a verified tool signature match ties the pattern to that specific BSSID rather than coincidence.

Collapsing the two axes is what let an uncorroborated same-SSID coincidence page at the same volume as an observed attack, and it pushed the detector toward suppressing weak candidates outright to keep the noise down. Splitting them means a weak candidate can be shown as weak instead of being hidden.

Nothing observed over the air is a trust anchor

EVIL_TWIN's same-security branch used to drop a pair outright on two grounds, and #89 removed both:

  • A matching vendor OUI. Three bytes of a frame the attacker writes. Every rogue-AP tool can set them to the target's prefix.
  • An 802.11k neighbour report from either side. An unauthenticated management frame. An attacker advertises the AP it is impersonating and the finding disappeared — the suppression handed the adversary an off switch.

Both are now weighted context that lowers confidence, and a neighbour claim may demote a severity resting only on soft signals — never one backed by a hard signal (attacker-tool OUI, a BTM steer at the pair), or naming your target becomes a severity lever.

What replaces them is a requirement for positive evidence: with none, the rule is silent. A legitimate single-vendor multi-BSSID deployment stays quiet because there is nothing to report, not because an observed value was read as proof of ownership. The same reasoning removed RSSI as the tie-breaker for which half of a pair is the impostor — see docs/views/twins.md.

...except the one the operator wrote down

Slice 2 of #89 added the anchor that leaves: the approved inventory (--inventory, full page at inventory). It is the only trust input in this family that does not arrive over the air, so it is the only one allowed to settle a pair either way:

  • a BSSID that is not approved for an SSID the file declares is +50 positive evidence and hard — which is what keeps a spoofed neighbour claim from erasing it, and what makes the same-OUI clone in this issue's regression list visible at all;
  • a pair whose both halves are approved is not a candidate, whatever the radios look like to each other. That is the mixed-vendor deployment case, where a differing OUI and contradicting vendor-IE hashes are the strongest observed signals here and both are simply wrong.

That second one is a sole suppressor, deliberately. It is not the pre-#89 behaviour renamed: what #89 removed were suppressors sourced from frames the attacker writes. The test is not "does anything suppress" but "can the adversary reach the input". With no inventory configured nothing above applies and the heuristics are exactly as slice 1 left them — an operator who never writes a file must not silently lose detection.

Corroboration before intent (#94)

The same doctrine reaches the rules whose finding is about a person's behaviour rather than a protocol state. MY_NET_RECON is the case an external CISO/GRC review raised: it fires when a client's PNL names a designated SSID and no association to that network was observed, and that precondition is satisfied by at least four innocent situations — a returning employee, a device roaming the operator's own APs, a capture that never saw the association, and a handset probing with a rotating address while associated under its per-network one.

Calling that reconnaissance names an intent the evidence does not carry. Since #94:

  • Uncorroborated it is LOW at 25 % confidence, and the detail reports the observation (probed for designated network … uncorroborated, benign explanations include a returning device or an unobserved association). The word reconnaissance does not appear.
  • WARN and the reconnaissance framing require positive corroboration — sustained probing (≥ 600 s span and ≥ 20 probes) or a PNL naming two or more designated networks. The detail names which corroborator fired, so the operator weighs the evidence rather than the label.
  • Two things deliberately do not corroborate. A randomised MAC is default behaviour on every current handset, so it describes the phone population. And the absence of an observed association is not evidence of anything — an incomplete capture is precisely the benign case this rule has to respect. Absence of evidence never corroborates.
  • Three exonerations: association to a designated BSSID/SSID, the operator's --known-mac roster, and association by a seqnum-correlated sibling address. The last one closes the randomised-probe / real-association case, where matching on the exact MAC accused a device that had been sitting on the network all along. For an exoneration any reported correlation counts — the safe error is to stay quiet, so it does not wait for the strong score a positive claim would need.

Records are not conclusions about people

Two families here produce records that can be read as identifying an individual, and both are bounded in writing rather than left to the reader: MY_NET_RECON above, and the seqnum correlation that feeds it (mac-randomisation).

For both: a MAC address can qualify as personal data (UK ICO guidance on Wi-Fi location analytics), and hashing a MAC does not make longitudinal tracking anonymous. These records alone must not be used for personnel action, physical identification of an individual, or automated containment. Sloth is the eyes, not the hands — MISSION §2.5 puts the consequences on the operator, which only works if sloth is honest about what it actually saw.

Canonical pair keys

A finding about a pair keys on the pair, not on the SSID: <rule_id>:<bssid_lo>:<bssid_hi>:<site>:<security_profile>, BSSIDs in byte order so (A,B) and (B,A) are one incident. twin:<ssid> and twin-fp:<ssid> collapsed every pair under one name into a single engine record, and later evaluations overwrote the earlier pair's detail — two rogues on one SSID read as one.

site is the operator's label for where the sensor is. Since #89 slice 2 it comes from --site or the inventory file's site field and from nothing else — never from the uplink association, an observed SSID, or any captured frame. Two reasons: unauthenticated RF must not become a trust input, and a site derived from the uplink would re-key on every roam, fragmenting one physical impersonator into several incidents and opening a fresh alert.create for each instead of escalating the one already open. Unset stays the empty string and the key shape does not change. See inventory.

Severity tiers

Three tiers, yellow → orange → red, with cross-panel coloring (see ip-palette):

Tier Hue Meaning
LOW yellow Recon / suspicious-but-passive (port scan, etc.)
WARN orange Clearly malicious, not yet active exploitation
CRIT red Active attack or IOC hit

Rules (current)

The full table lives in docs/views/alerts.md; the headline rules per tier:

  • LOW: PORT_SCAN, NXDOMAIN_BURST, PROBE_FLOOD
  • WARN: DEAUTH_FLOOD, BEACONING, DGA_DOMAIN, WEAK_TLS
  • CRIT: THREAT_DOMAIN, THREAT_IP, ARP_SPOOF, ROGUE_DHCP, EVIL_TWIN, KARMA_AP, DNS_TUNNEL, ATTACK_TOOL_UA, ATTACK_PATH, WPS_PIN_BRUTE, WPS_PBC_RACE

Tunable thresholds (#82)

Almost every threshold in the engine is a #define and nothing else — KARMA_SSID_THRESH, ASSOC_FLOOD_THRESH, the flood windows. The three WPS rules are the exception: the owner's decision of 2026-09-30 was to ship the issue's proposed numbers each behind a config knob, so each has a #define default in src/alerts.h and a CLI override, in the shape seqnum_corr_set_retain_secs() / db_set_retain_days() already use. There is no configuration-file format in sloth and this did not add one.

Rule Default Flag
WPS_PIN_BRUTE 5 cycles / 60 s --wps-pin-brute-cycles N
WPS_LOCKOUT_CYCLING 2 cycles / 1 h --wps-lockout-cycles N
WPS_PBC_RACE fires above 2 / 120 s --wps-pbc-concurrent N

Only the counts are tunable. The windows are not operator policy: 60 s is the rate the brute-force threshold is defined over, one hour is the period the lockout sawtooth is stated in, and 120 s is the PBC walk time the WSC protocol fixes. A knob that moved the walk time would be measuring something the protocol does not do.

Every threshold is a floor — the rule fires at or above the count — so raising one can only quiet sloth and can never invent a finding. A value of zero or less is rejected rather than read as "disabled": a floor of zero fires on every observation, which is the opposite of what the operator typed. See cli-reference.

Cross-panel coloring

Any alert with a concrete match_ip adds that IP to the TUI alert-hot list at the rule's severity for ALERT_HOT_TTL_S (1h). Every panel that renders the IP (connections, top-hosts, packets, hostname rows) paints it in the matching tier colour. Promotion only — a later LOW does not demote an earlier CRIT on the same IP within the TTL window.

How rules find each other

  • DNS rules read s->dns_log[] (see dns.md).
  • Connection rules read s->conns[] (see connections.md).
  • Deauth rule reads the (BSSID, victim) aggregates s->deauth_victims[], not the per-stream rows s->deauth_events[] (#88; see deauth.md).
  • Threat-intel matching uses the embedded lists in src/threat_intel.c. Those lists are synthetic demo data, not a threat feed, so THREAT_DOMAIN / THREAT_IP fire on nothing real until an operator replaces them — see threat-intel.
  • Beaconing math lives in src/beacon_detect.c — see beacon-detection.

Adding a rule

Per CLAUDE.md "How to add an alert rule":

  1. Add ALERT_TYPE_<NAME> to the enum in include/sloth.h.
  2. Write rule_<name>(state, now) in src/alerts.c that calls fire(...).
  3. Wire the call from alerts_update().
  4. If the alert has a known target IP, pass match_ip + match_port to fire() so per-alert pcap works.
  5. Add a row to the rule table in docs/views/alerts.md.
  6. Test: seed state that should trigger, run alerts_update, assert find_alert(type) >= 0.

Footer enrichment

The selected alert's footer shows RIR region (from the /8 table) and embedded hosting-org lookup (src/ip_owner.c). Useful for triage when the rule fires on an unfamiliar IP.

Related pages

Clone this wiki locally