-
Notifications
You must be signed in to change notification settings - Fork 1
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.
- File:
src/alerts.c. - Dedup key examples:
scan:<ip>,threat-d:<domain>,threat-ip:<ip>:<port>. - New-key path: JSONL
alertline +alert.create+ (optional) pcap dump of the matching packets viasrc/alert_pcap.c. See pcap-export. -
cclears all alerts and resets dedup state; future hits re-arm. Every open incident is resolved withreason: "cleared"first.
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
detailemits at most once perALERT_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/evaluationsare rule ticks;observationsonly moves when the evidence does. Timestamps split the same way (first_detected/last_evaluatedvsfirst_observed/last_observed). - Durations run on
CLOCK_MONOTONICvia the #88 seam insrc/flood_window.c; exported timestamps stay wall clock.
Wire format and the full field table: jsonl-schema.
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.
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.
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
+50positive 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.
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-macroster, 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.
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.
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.
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 |
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
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.
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.
- DNS rules read
s->dns_log[](seedns.md). - Connection rules read
s->conns[](seeconnections.md). - Deauth rule reads the
(BSSID, victim)aggregatess->deauth_victims[], not the per-stream rowss->deauth_events[](#88; seedeauth.md). - Threat-intel matching uses the embedded lists in
src/threat_intel.c. Those lists are synthetic demo data, not a threat feed, soTHREAT_DOMAIN/THREAT_IPfire on nothing real until an operator replaces them — see threat-intel. - Beaconing math lives in
src/beacon_detect.c— see beacon-detection.
Per CLAUDE.md "How to add an alert rule":
- Add
ALERT_TYPE_<NAME>to the enum ininclude/sloth.h. - Write
rule_<name>(state, now)insrc/alerts.cthat callsfire(...). - Wire the call from
alerts_update(). - If the alert has a known target IP, pass
match_ip+match_porttofire()so per-alert pcap works. - Add a row to the rule table in
docs/views/alerts.md. - Test: seed state that should trigger, run
alerts_update, assertfind_alert(type) >= 0.
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.
- threat-intel — IOC list format, suffix matching, swap-in.
- beacon-detection — periodicity math.
-
pcap-export —
--pcap-dirmechanics. - attack-map — protocol → threat table.
Mirrored from docs/wiki/ on main by .github/scripts/wiki_sync.sh. Edit there, not here — hand edits to this wiki are overwritten on the next push.
Read this first — the complete reference
- what-sloth-does
- how-wifi-works
- monitor-mode
- where-exploits-happen
- wifi-sigint-techniques
- cli-reference
- wifi-state-of-the-art
Start here
Engines
WiFi SIGINT
- wifi-sigint
- non-ip-sensors
- mac-randomisation
- evil-twin-reproducer
- btm-abuse
- action-frames
- research-corpus
- captive-portal
- fragattacks
- tool-fingerprints
- enterprise-rogue
- ipv6-ndp
- smb-snoop
- kerberos-snoop
- ldap-snoop
- bgp-snoop
- ssh-snoop
- rdp-snoop
- snmp-snoop
- mqtt-snoop
UI and infrastructure
- ip-palette
- platform-vtable
- version-checkin
- manifest-format
- pcap-export
- jsonl-schema
- data-socket-exposure
- sqlite-schema
- ring-buffers
Factory infrastructure
Reference
Source material
Maintenance