Skip to content

Commit 46037ca

Browse files
authored
feat(alerts): SSID impostor detection from existing neighbor scans (#1358) (#1393)
An unknown BSSID advertising a fleet SSID is the classic evil-twin signature. No new collection needed: passive neighbor scans already land in wifi_scans, and fleet BSSIDs/SSIDs already come from the agent registry (#1087) plus SSH-polled radios. - New AgentRegistry.LocalSsids() (OwnBssids + dawn/usteer local APs). - Live.EvalSsidImpostors(): one bounded query over the last 6h of wifi_scans. A candidate confirms only with >=2 real scans (distinct timestamps, so a frozen re-pushed payload does not count) separated by >=20 min dwell, and auto-resolves after 2h unseen. Alert id is stable per BSSID; tracking lives in memory on purpose - a restarted server re-alerts if the impostor is still around (security over re-notice). - Category clients, not signal: signal defaults to LevelNone and a security alert must not be invisible on stock config. - Wired after SaveScan in the agent ingest handler (the exact moment the evidence changes); i18n es/en (title, description with vars, hint). Tests: single sighting does not alert, dwell confirms once, own/foreign BSSID ignored, frozen payload does not confirm, auto-resolve, LocalSsids composition. Closes #1358
1 parent 53b001b commit 46037ca

8 files changed

Lines changed: 453 additions & 0 deletions

File tree

‎app/public/locales/en/translation.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
"high-temperature": "Improve ventilation and keep the router away from heat sources.",
8989
"port-flapping": "Check the cable or switch port: an unstable link is usually the culprit.",
9090
"unknown-device": "If you do not recognize it, block it or mark it as trusted in Settings.",
91+
"wifi-ssid-impostor": "An unknown radio is broadcasting your network name. Check the area around the hearing AP; if you do not recognize the BSSID, treat it as a rogue AP.",
9192
"uplink-switched": "Check the line that dropped: the router goes back to it on its own once it recovers.",
9293
"wan-down": "Check the modem/ONT and contact your ISP if the outage persists.",
9394
"wan-slow": "Restart the router and the modem/ONT and run the test again; if it stays low, contact your ISP (line degradation or congestion in your area).",
@@ -218,6 +219,10 @@
218219
"description": "{{mac}} connected to {{router}}",
219220
"descriptionWhere": "{{mac}} · {{ip}} connected to {{where}}"
220221
},
222+
"wifi-ssid-impostor": {
223+
"title": "SSID impostor detected",
224+
"description": "{{bssid}} advertises {{ssid}} (heard by {{router}}, {{signal}} dBm)"
225+
},
221226
"uplink-restored": {
222227
"title": "Internet is back on {{to}}",
223228
"description": "{{router}} moved the traffic back to {{to}}"

‎app/public/locales/es/translation.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
"high-temperature": "Mejora la ventilación y aleja el router de fuentes de calor.",
8989
"port-flapping": "Revisa el cable o el puerto del switch: un enlace inestable suele ser el culpable.",
9090
"unknown-device": "Si no lo reconoces, bloquéalo o márcalo como de confianza en Ajustes.",
91+
"wifi-ssid-impostor": "Una radio desconocida está emitiendo el nombre de tu red. Mira qué hay cerca del AP que la oyó; si no reconoces el BSSID, trátala como un AP malicioso.",
9192
"uplink-switched": "Revisa la línea que se ha caído: el router volverá a ella solo cuando se recupere.",
9293
"wan-down": "Comprueba el módem/ONT y contacta con tu operador si la caída persiste.",
9394
"wan-slow": "Reinicia el router y el módem/ONT y repite el test; si sigue bajo, contacta con tu operador (degrada la línea o hay saturación en tu área).",
@@ -218,6 +219,10 @@
218219
"description": "{{mac}} se ha conectado a {{router}}",
219220
"descriptionWhere": "{{mac}} · {{ip}} se ha conectado a {{where}}"
220221
},
222+
"wifi-ssid-impostor": {
223+
"title": "SSID impostor detectado",
224+
"description": "{{bssid}} anuncia {{ssid}} (oído por {{router}}, {{signal}} dBm)"
225+
},
221226
"uplink-restored": {
222227
"title": "Internet vuelve por {{to}}",
223228
"description": "{{router}} ha devuelto el tráfico a {{to}}"

‎server-go/internal/adapters/agent.go‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ package adapters
1212

1313
import (
1414
"fmt"
15+
"sort"
1516
"strings"
1617
"sync"
1718
"time"
@@ -201,6 +202,57 @@ func (r *AgentRegistry) LocalBssids() []string {
201202
return out
202203
}
203204

205+
// LocalSsids devuelve los SSID que los agentes reportan como propios
206+
// (#1358): OwnBssids con SSID no vacío + SSIDs con al menos un AP local en
207+
// dawn/usteer. Es la lista "auto-aprobada" para la detección de SSID
208+
// impostor: cualquier BSSID ajeno anunciando uno de estos nombres es
209+
// sospechoso. Dedup, ordenado para salida estable.
210+
func (r *AgentRegistry) LocalSsids() []string {
211+
r.mu.Lock()
212+
defer r.mu.Unlock()
213+
seen := map[string]bool{}
214+
var out []string
215+
add := func(ssid string) {
216+
if ssid == "" || seen[ssid] {
217+
return
218+
}
219+
seen[ssid] = true
220+
out = append(out, ssid)
221+
}
222+
for _, st := range r.states {
223+
if st.Payload == nil {
224+
continue
225+
}
226+
if w := st.Payload.Data.Wireless; w != nil {
227+
for _, ob := range w.OwnBssids {
228+
add(ob.SSID)
229+
}
230+
}
231+
if st.Payload.Data.Dawn != nil {
232+
for name, ssid := range st.Payload.Data.Dawn.SSIDs {
233+
for _, ap := range ssid.APs {
234+
if ap.Local {
235+
add(name)
236+
break
237+
}
238+
}
239+
}
240+
}
241+
if st.Payload.Data.Usteer != nil {
242+
for name, ssid := range st.Payload.Data.Usteer.SSIDs {
243+
for _, ap := range ssid.APs {
244+
if ap.Local {
245+
add(name)
246+
break
247+
}
248+
}
249+
}
250+
}
251+
}
252+
sort.Strings(out)
253+
return out
254+
}
255+
204256
// Fresh devuelve el último payload si está dentro de su ventana de
205257
// frescura (TTL base o 3x cadencia declarada, #288).
206258
func (r *AgentRegistry) Fresh(slug string) (*probe.Payload, bool) {
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package adapters
2+
3+
// impostor.go — alerta wifi-ssid-impostor (#1358): un BSSID que NO es de la
4+
// flota anunciando un SSID que SÍ es de la flota es la firma clásica de evil
5+
// twin. No hace falta ninguna recolección nueva: los scans pasivos de
6+
// vecinos ya caen en wifi_scans (Fase 18/#452) y los BSSIDs/SSIDs propios
7+
// ya los recopila el registry de agentes (#1087) más las radios sondeadas
8+
// por SSH (iwinfo).
9+
//
10+
// Anti-transitorio: un sighting suelto no alerta (un hotspot de paso con
11+
// nombre parecido, un vecino que puso tu SSID un rato). El BSSID candidato
12+
// tiene que aparecer en >=2 scans REALES (timestamps distintos en
13+
// wifi_scans, no el mismo payload recongelado) separados al menos
14+
// impostorDwell, con el último sighting reciente. Auto-resolve cuando lleva
15+
// impostorGone sin verse.
16+
//
17+
// La lista de aprobados es la propia flota (zero-config): no hay nada que
18+
// configurar para el caso común.
19+
20+
import (
21+
"fmt"
22+
"strings"
23+
"time"
24+
25+
"github.com/gnacho/netpulse/server-go/internal/alerts"
26+
)
27+
28+
const (
29+
// impostorDwell: separación mínima entre el primer y el último sighting
30+
// para confirmar el impostor (con el scan típico de 30 min, el segundo
31+
// scan real llega ~30 min después del primero).
32+
impostorDwell = 20 * time.Minute
33+
// impostorRecent: el último sighting debe ser más reciente que esto
34+
// para que la alerta siga considerándose activa (2x el scan interval
35+
// más holgado que solemos ver, 1h).
36+
impostorRecent = 2 * time.Hour
37+
// impostorWindow: sightings más viejos que esto no cuentan.
38+
impostorWindow = 6 * time.Hour
39+
)
40+
41+
// EvalSsidImpostors: una pasada sobre wifi_scans recientes. La llama el
42+
// handler de ingesta del agente tras guardar un scan nuevo (momento exacto
43+
// en que la evidencia cambia). Barata: una query acotada por ventana.
44+
func (l *Live) EvalSsidImpostors() {
45+
if l.db == nil || l.agents == nil || l.engine == nil {
46+
return
47+
}
48+
now := time.Now()
49+
50+
ownBssids := map[string]bool{}
51+
for _, b := range l.agents.LocalBssids() {
52+
ownBssids[b] = true
53+
}
54+
// Las radios sondeadas por SSH (iwinfo) también son propias: sin esto un
55+
// router sin agente anunciando el SSID de la flota sería falso positivo.
56+
// Fuente: la caché de extras del último sondeo (radios con BSSID real).
57+
l.mu.Lock()
58+
for _, ex := range l.extrasCache {
59+
for _, r := range ex.radios {
60+
if r.BSSID != "" {
61+
ownBssids[strings.ToUpper(r.BSSID)] = true
62+
}
63+
}
64+
}
65+
l.mu.Unlock()
66+
fleetSsids := map[string]bool{}
67+
for _, s := range l.agents.LocalSsids() {
68+
fleetSsids[s] = true
69+
}
70+
if len(fleetSsids) == 0 {
71+
return // sin flota wifi conocida no hay nada que suplantar
72+
}
73+
74+
rows, err := l.db.Query(
75+
`SELECT bssid, ssid, router_id, signal_dbm, ts FROM wifi_scans WHERE ts >= ?`,
76+
now.Add(-impostorWindow).Unix())
77+
if err != nil {
78+
return
79+
}
80+
type sighting struct {
81+
ssid string
82+
routerID string
83+
signal int
84+
tss map[int64]bool // timestamps distintos (scans reales)
85+
minTs int64
86+
maxTs int64
87+
}
88+
byBssid := map[string]*sighting{}
89+
for rows.Next() {
90+
var bssid, ssid, routerID string
91+
var signal, ts int64
92+
if rows.Scan(&bssid, &ssid, &routerID, &signal, &ts) != nil {
93+
continue
94+
}
95+
bssid = strings.ToUpper(strings.TrimSpace(bssid))
96+
if bssid == "" || !fleetSsids[ssid] || ownBssids[bssid] {
97+
continue
98+
}
99+
s := byBssid[bssid]
100+
if s == nil {
101+
// Inicializar con los datos de ESTE row: sin ORDER BY la BD
102+
// puede devolver primero el sighting más reciente, y si el
103+
// primero procesado no deja router/señal, la alerta saldría
104+
// con esos campos vacíos.
105+
s = &sighting{ssid: ssid, routerID: routerID, signal: int(signal), minTs: ts, maxTs: ts, tss: map[int64]bool{ts: true}}
106+
byBssid[bssid] = s
107+
continue
108+
}
109+
s.tss[ts] = true
110+
if ts < s.minTs {
111+
s.minTs = ts
112+
}
113+
if ts > s.maxTs {
114+
s.maxTs = ts
115+
s.routerID = routerID
116+
s.signal = int(signal)
117+
}
118+
}
119+
rows.Close()
120+
121+
l.mu.Lock()
122+
defer l.mu.Unlock()
123+
// Nuevos confirmados: >=2 scans reales separados >= dwell, último reciente.
124+
for bssid, s := range byBssid {
125+
confirmed := len(s.tss) >= 2 &&
126+
time.Duration(s.maxTs-s.minTs)*time.Second >= impostorDwell &&
127+
time.Unix(s.maxTs, 0).After(now.Add(-impostorRecent))
128+
if !confirmed {
129+
continue
130+
}
131+
alertID := "alert-ssid-impostor-" + bssid
132+
if _, ok := l.impostorActive[bssid]; ok {
133+
continue // ya emitida; el engine la conserva activa
134+
}
135+
routerName := s.routerID
136+
if rn := l.routerDisplayName(s.routerID); rn != "" {
137+
routerName = rn
138+
}
139+
l.engine.Emit(AlertEvent{
140+
ID: alertID,
141+
// CatClients y no CatSignal: signal está en LevelNone por defecto
142+
// (alertas de cobertura) y una alerta de SEGURIDAD no puede
143+
// quedar invisible en la config stock (#1358).
144+
Category: alerts.CatClients,
145+
Urgent: true,
146+
Severity: "critical",
147+
Title: "SSID impostor detected",
148+
Description: fmt.Sprintf("%s advertises %s (heard by %s, %d dBm)", bssid, s.ssid, routerName, s.signal),
149+
Hint: alerts.HintFor(alerts.HintSsidImpostor),
150+
Type: alerts.HintSsidImpostor,
151+
Vars: map[string]string{
152+
"bssid": bssid,
153+
"ssid": s.ssid,
154+
"router": routerName,
155+
"signal": fmt.Sprintf("%d", s.signal),
156+
},
157+
RouterID: s.routerID,
158+
})
159+
l.impostorActive[bssid] = alertID
160+
}
161+
// Auto-resolve: el BSSID alertado lleva impostorRecent sin aparecer en
162+
// ningún scan (se apagó o se fue). Si vuelve, la gracia empieza de cero.
163+
for bssid, alertID := range l.impostorActive {
164+
s := byBssid[bssid]
165+
if s == nil || time.Unix(s.maxTs, 0).Before(now.Add(-impostorRecent)) {
166+
l.engine.Resolve(alertID)
167+
delete(l.impostorActive, bssid)
168+
}
169+
}
170+
}

0 commit comments

Comments
 (0)