wifiscan 0.4.0

Wireless network scanner TUI with monitor mode, handshake capture, deauth, and evil twin
Documentation
use std::collections::HashMap;
use std::time::Instant;

use crate::types::ApMap;

// ── Alert Types ─────────────────────────────────────────────────────────────

#[derive(Debug, Clone)]
pub enum AlertKind {
    EvilTwin { essid: String, bssid1: String, bssid2: String },
    DeauthFlood { bssid: String, essid: String },
    NewAP { bssid: String, essid: String },
    OpenNetwork { bssid: String, essid: String },
    WepNetwork { bssid: String, essid: String },
    ChannelOverlap { channel: u8, count: usize },
    HandshakeCaptured { bssid: String, essid: String },
}

#[derive(Debug, Clone)]
pub struct Alert {
    pub kind: AlertKind,
    pub timestamp: Instant,
    pub message: String,
    pub dismissed: bool,
}

impl Alert {
    pub fn new(kind: AlertKind) -> Self {
        let message = match &kind {
            AlertKind::EvilTwin { essid, bssid1, bssid2 } =>
                format!("Evil twin: '{}' seen on {} and {}", essid, bssid1, bssid2),
            AlertKind::DeauthFlood { essid, .. } =>
                format!("Deauth flood detected targeting '{}'", essid),
            AlertKind::NewAP { essid, bssid } =>
                format!("New AP: '{}' ({})", essid, bssid),
            AlertKind::OpenNetwork { essid, bssid } =>
                format!("Open network: '{}' ({})", essid, bssid),
            AlertKind::WepNetwork { essid, bssid } =>
                format!("WEP network: '{}' ({}) - trivially crackable", essid, bssid),
            AlertKind::ChannelOverlap { channel, count } =>
                format!("Channel {} congested: {} APs", channel, count),
            AlertKind::HandshakeCaptured { essid, bssid } =>
                format!("Handshake captured: '{}' ({})", essid, bssid),
        };
        Self { kind, timestamp: Instant::now(), message, dismissed: false }
    }

    pub fn age_secs(&self) -> u64 {
        self.timestamp.elapsed().as_secs()
    }
}

// ── Alert Engine ────────────────────────────────────────────────────────────

pub struct AlertEngine {
    pub alerts: Vec<Alert>,
    known_bssids: HashMap<String, Instant>,
    evil_twin_seen: HashMap<String, Vec<String>>,  // essid -> vec of bssids
    max_alerts: usize,
}

impl AlertEngine {
    pub fn new() -> Self {
        Self {
            alerts: Vec::new(),
            known_bssids: HashMap::new(),
            evil_twin_seen: HashMap::new(),
            max_alerts: 200,
        }
    }

    pub fn active_alerts(&self) -> Vec<&Alert> {
        self.alerts.iter()
            .filter(|a| !a.dismissed && a.age_secs() < 300)
            .collect()
    }

    pub fn dismiss_all(&mut self) {
        for a in &mut self.alerts {
            a.dismissed = true;
        }
    }

    fn push(&mut self, alert: Alert) {
        self.alerts.push(alert);
        if self.alerts.len() > self.max_alerts {
            self.alerts.remove(0);
        }
    }

    /// Run detection passes against current AP state. Call periodically from the UI loop.
    pub fn scan(&mut self, ap_map: &ApMap) {
        let map = ap_map.lock().unwrap();

        // Track new APs
        for (bssid, ap) in map.iter() {
            if !self.known_bssids.contains_key(bssid) {
                self.known_bssids.insert(bssid.clone(), Instant::now());

                if !ap.essid.is_empty() && ap.essid != "<hidden>" {
                    // New AP alert (only after initial scan period)
                    if self.known_bssids.len() > 5 {
                        self.push(Alert::new(AlertKind::NewAP {
                            bssid: bssid.clone(),
                            essid: ap.essid.clone(),
                        }));
                    }

                    // Open network warning
                    if ap.encryption.display == "Open" {
                        self.push(Alert::new(AlertKind::OpenNetwork {
                            bssid: bssid.clone(),
                            essid: ap.essid.clone(),
                        }));
                    }

                    // WEP warning
                    if ap.encryption.display.starts_with("WEP") {
                        self.push(Alert::new(AlertKind::WepNetwork {
                            bssid: bssid.clone(),
                            essid: ap.essid.clone(),
                        }));
                    }
                }
            }
        }

        // Evil twin detection: same ESSID, different BSSID, different encryption
        self.evil_twin_seen.clear();
        for (bssid, ap) in map.iter() {
            if ap.essid.is_empty() || ap.essid == "<hidden>" { continue; }
            self.evil_twin_seen
                .entry(ap.essid.clone())
                .or_default()
                .push(bssid.clone());
        }

        let mut twin_alerts: Vec<Alert> = Vec::new();
        for (essid, bssids) in &self.evil_twin_seen {
            if bssids.len() < 2 { continue; }

            let mut enc_set: HashMap<String, Vec<String>> = HashMap::new();
            for bssid in bssids {
                if let Some(ap) = map.get(bssid) {
                    enc_set.entry(ap.encryption.display.clone())
                        .or_default()
                        .push(bssid.clone());
                }
            }
            if enc_set.len() > 1 {
                let bssid_list: Vec<&String> = bssids.iter().take(2).collect();
                let already_alerted = self.alerts.iter().any(|a| {
                    matches!(&a.kind, AlertKind::EvilTwin { essid: e, .. } if e == essid)
                        && a.age_secs() < 300
                });
                if !already_alerted {
                    twin_alerts.push(Alert::new(AlertKind::EvilTwin {
                        essid: essid.clone(),
                        bssid1: bssid_list[0].clone(),
                        bssid2: bssid_list[1].clone(),
                    }));
                }
            }
        }
        for alert in twin_alerts {
            self.push(alert);
        }

        // Channel congestion
        let mut channel_counts: HashMap<u8, usize> = HashMap::new();
        for ap in map.values() {
            if ap.channel > 0 && ap.age_secs() < 60 {
                *channel_counts.entry(ap.channel).or_default() += 1;
            }
        }
        for (&ch, &count) in &channel_counts {
            if count >= 15 {
                let already = self.alerts.iter().any(|a| {
                    matches!(&a.kind, AlertKind::ChannelOverlap { channel, .. } if *channel == ch)
                        && a.age_secs() < 120
                });
                if !already {
                    self.push(Alert::new(AlertKind::ChannelOverlap { channel: ch, count }));
                }
            }
        }
    }

    /// Call when a handshake is captured
    pub fn handshake_captured(&mut self, bssid: &str, essid: &str) {
        self.push(Alert::new(AlertKind::HandshakeCaptured {
            bssid: bssid.to_string(),
            essid: essid.to_string(),
        }));
    }
}

impl Default for AlertEngine {
    fn default() -> Self {
        Self::new()
    }
}