diff --git a/src/filter.rs b/src/filter.rs index 87388c04..04f5f22e 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -402,6 +402,31 @@ impl ConnectionFilter { return true; } } + ApplicationProtocol::Imap(info) => { + if match_text("imap", fv) { + return true; + } + if let Some(ref cmd) = info.command + && match_text(cmd, fv) + { + return true; + } + if let Some(ref user) = info.username + && match_text(user, fv) + { + return true; + } + if let Some(ref status) = info.status + && match_text(status, fv) + { + return true; + } + if let Some(ref sw) = info.server_software + && match_text(sw, fv) + { + return true; + } + } ApplicationProtocol::Mdns(info) => { if let Some(ref query_name) = info.query_name && match_text(query_name, fv) diff --git a/src/network/dpi/imap.rs b/src/network/dpi/imap.rs new file mode 100644 index 00000000..5c2d510b --- /dev/null +++ b/src/network/dpi/imap.rs @@ -0,0 +1,424 @@ +//! IMAP (Internet Message Access Protocol) Deep Packet Inspection +//! +//! Parses the plaintext IMAP4rev1 control channel (RFC 3501, RFC 7888 LITERAL+, +//! RFC 2595 STARTTLS, RFC 4978 COMPRESS). Detection is keyed off port 143 plus +//! a cheap start-line signature; port 993 (IMAPS) is TLS-wrapped and is +//! claimed by the HTTPS/TLS analyzer instead. +//! +//! Client lines are tag-prefixed (` COMMAND `), server lines are +//! either tagged (` OK/NO/BAD …`), untagged (`* …`), or continuations +//! (`+ …`). This module recognises all three. + +use crate::network::types::{ImapInfo, ImapMessageType}; + +/// Maximum bytes scanned for the signature check. +const MAX_SNIFF_BYTES: usize = 1024; + +/// RFC 3501 / 7888 / 2595 / 4978 / 5161 verbs. Matched case-insensitively +/// against the command token (the second whitespace-delimited field of a +/// client request line). +const IMAP_COMMANDS: &[&str] = &[ + // RFC 3501 — any-state + "CAPABILITY", + "NOOP", + "LOGOUT", + // RFC 3501 — not-authenticated + "AUTHENTICATE", + "LOGIN", + "STARTTLS", + // RFC 3501 — authenticated + "SELECT", + "EXAMINE", + "CREATE", + "DELETE", + "RENAME", + "SUBSCRIBE", + "UNSUBSCRIBE", + "LIST", + "LSUB", + "STATUS", + "APPEND", + // RFC 3501 — selected + "CHECK", + "CLOSE", + "EXPUNGE", + "SEARCH", + "FETCH", + "STORE", + "COPY", + "UID", + // RFC 2971 / 5161 / 2177 + "ID", + "ENABLE", + "IDLE", + "DONE", + // RFC 4978 + "COMPRESS", +]; + +/// Returns `true` when the first line of the payload looks like an IMAP +/// frame. Used for signature-based detection on non-standard ports. +pub fn is_imap(payload: &[u8]) -> bool { + let line = first_line(payload); + if line.is_empty() { + return false; + } + // Server untagged response (`* OK ...`, `* PREAUTH ...`, `* CAPABILITY …`). + if line.starts_with(b"* ") { + return true; + } + // Continuation (`+ `). + if line.starts_with(b"+ ") || line == b"+" { + return true; + } + // Tagged frame: ` ` where `` is OK/NO/BAD/BYE/PREAUTH + // (server) or a known IMAP command (client). + let (tag, rest) = match split_tag(line) { + Some(v) => v, + None => return false, + }; + if tag.is_empty() { + return false; + } + let token_upper = first_token_upper(rest); + if token_upper.is_empty() { + return false; + } + matches!( + token_upper.as_slice(), + b"OK" | b"NO" | b"BAD" | b"BYE" | b"PREAUTH" + ) || IMAP_COMMANDS + .iter() + .any(|cmd| cmd.as_bytes() == token_upper) +} + +/// Parse an IMAP payload. Returns `None` when the payload does not look like +/// IMAP. +pub fn analyze_imap(payload: &[u8]) -> Option { + let line = first_line(payload); + if line.is_empty() { + return None; + } + + // Untagged response (`* …`). + if let Some(rest) = line.strip_prefix(b"* ") { + let status_upper = first_token_upper(rest); + let status = std::str::from_utf8(&status_upper) + .ok() + .map(|s| s.to_string()); + let message = std::str::from_utf8(rest).ok().map(|s| s.trim().to_string()); + // RFC 3501 §7.1.1 / §11: the `* OK` / `* PREAUTH` greeting carries + // the server software banner. + let server_software = match status.as_deref() { + Some("OK") | Some("PREAUTH") => message.as_deref().map(extract_software_token), + _ => None, + }; + return Some(ImapInfo { + message_type: ImapMessageType::UntaggedResponse, + tag: None, + command: None, + args: None, + status, + response_message: message, + username: None, + server_software, + }); + } + + // Continuation response (`+ `). + if line == b"+" || line.starts_with(b"+ ") { + let message = std::str::from_utf8(&line[1..]) + .ok() + .map(|s| s.trim().to_string()); + return Some(ImapInfo { + message_type: ImapMessageType::Continuation, + tag: None, + command: None, + args: None, + status: None, + response_message: message, + username: None, + server_software: None, + }); + } + + // Tagged frame. + let (tag, rest) = split_tag(line)?; + if tag.is_empty() { + return None; + } + let tag_str = std::str::from_utf8(tag).ok()?.to_string(); + let token_upper = first_token_upper(rest); + if token_upper.is_empty() { + return None; + } + + // Tagged response: ` OK/NO/BAD ...`. + if matches!(token_upper.as_slice(), b"OK" | b"NO" | b"BAD" | b"BYE") { + let status = std::str::from_utf8(&token_upper) + .ok() + .map(|s| s.to_string()); + let message = std::str::from_utf8(rest).ok().and_then(|s| { + s.split_once(char::is_whitespace) + .map(|(_, rest)| rest.trim().to_string()) + }); + return Some(ImapInfo { + message_type: ImapMessageType::TaggedResponse, + tag: Some(tag_str), + command: None, + args: None, + status, + response_message: message, + username: None, + server_software: None, + }); + } + + // Tagged request: ` COMMAND `. + if !IMAP_COMMANDS + .iter() + .any(|cmd| cmd.as_bytes() == token_upper) + { + return None; + } + let command = std::str::from_utf8(&token_upper).ok()?.to_string(); + let args = std::str::from_utf8(rest).ok().and_then(|s| { + s.split_once(char::is_whitespace) + .map(|(_, rest)| rest.trim().to_string()) + .filter(|v| !v.is_empty()) + }); + // RFC 3501 §6.2.3: `LOGIN ` puts the username in the first + // argument (plaintext until STARTTLS, hence already exposed on the wire). + let username = if command == "LOGIN" { + args.as_deref() + .and_then(|s| s.split_whitespace().next()) + .map(extract_quoted) + } else { + None + }; + + Some(ImapInfo { + message_type: ImapMessageType::Request, + tag: Some(tag_str), + command: Some(command), + args, + status: None, + response_message: None, + username, + server_software: None, + }) +} + +fn first_line(payload: &[u8]) -> &[u8] { + let sniff = &payload[..payload.len().min(MAX_SNIFF_BYTES)]; + match sniff.iter().position(|&b| b == b'\n') { + Some(end) => { + if end > 0 && sniff[end - 1] == b'\r' { + &sniff[..end - 1] + } else { + &sniff[..end] + } + } + None => sniff, + } +} + +/// Split ` ` once. RFC 3501 §2.2.1 says tags are non-empty +/// printable ASCII excluding space, control, `(`, `)`, `{`, `*`, `%`, `\`, +/// `"`. We enforce a tight length cap to keep this cheap on hostile traffic. +fn split_tag(line: &[u8]) -> Option<(&[u8], &[u8])> { + let space = line.iter().position(|&b| b == b' ')?; + let tag = &line[..space]; + if tag.is_empty() || tag.len() > 32 { + return None; + } + if !tag.iter().all(|&b| { + b.is_ascii_graphic() && !matches!(b, b'(' | b')' | b'{' | b'%' | b'*' | b'\\' | b'"') + }) { + return None; + } + Some((tag, &line[space + 1..])) +} + +fn first_token_upper(line: &[u8]) -> Vec { + let token_end = line + .iter() + .position(|&b| b == b' ' || b == b'\t' || b == b'\r') + .unwrap_or(line.len()); + let token = &line[..token_end]; + if token.is_empty() || token.len() > 16 { + return Vec::new(); + } + if !token.iter().all(|b| b.is_ascii_alphabetic()) { + return Vec::new(); + } + token.iter().map(|b| b.to_ascii_uppercase()).collect() +} + +fn extract_software_token(message: &str) -> String { + // `OK [CAPABILITY ...] Server ready` — the first token is the status + // word (OK / PREAUTH / BYE). Walk past it, skip any bracketed + // capability list, and return the next plain word containing a letter. + let mut inside_bracket = false; + let mut current = String::new(); + let mut seen_first = false; + let mut best = String::new(); + for c in message.chars() { + match c { + '[' => inside_bracket = true, + ']' => inside_bracket = false, + _ if inside_bracket => {} + c if c.is_whitespace() => { + if !current.is_empty() { + if seen_first { + if best.is_empty() && is_software_candidate(¤t) { + best = current.clone(); + } + } else { + seen_first = true; + } + current.clear(); + } + } + _ => current.push(c), + } + } + if best.is_empty() && !current.is_empty() && seen_first && is_software_candidate(¤t) { + best = current; + } + if best.is_empty() { + message.trim().to_string() + } else { + best + } +} + +fn is_software_candidate(token: &str) -> bool { + let trimmed = token.trim_matches(|c: char| !c.is_ascii_alphanumeric()); + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("IMAP4rev1") { + return false; + } + trimmed.chars().any(|c| c.is_ascii_alphabetic()) +} + +fn extract_quoted(token: &str) -> String { + token.trim_matches('"').trim_matches('\'').to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_server_greeting() { + let payload = b"* OK [CAPABILITY IMAP4rev1 LITERAL+ SASL-IR] Dovecot ready.\r\n"; + assert!(is_imap(payload)); + let info = analyze_imap(payload).expect("should parse"); + assert!(matches!( + info.message_type, + ImapMessageType::UntaggedResponse + )); + assert_eq!(info.status.as_deref(), Some("OK")); + assert_eq!(info.server_software.as_deref(), Some("Dovecot")); + } + + #[test] + fn detects_preauth_greeting() { + let payload = b"* PREAUTH IMAP4rev1 server ready\r\n"; + let info = analyze_imap(payload).expect("should parse"); + assert_eq!(info.status.as_deref(), Some("PREAUTH")); + } + + #[test] + fn parses_login_request_with_username() { + let payload = b"a001 LOGIN alice secret\r\n"; + let info = analyze_imap(payload).expect("should parse"); + assert!(matches!(info.message_type, ImapMessageType::Request)); + assert_eq!(info.tag.as_deref(), Some("a001")); + assert_eq!(info.command.as_deref(), Some("LOGIN")); + assert_eq!(info.username.as_deref(), Some("alice")); + } + + #[test] + fn parses_login_with_quoted_username() { + let payload = b"a001 LOGIN \"alice@example.com\" secret\r\n"; + let info = analyze_imap(payload).expect("should parse"); + assert_eq!(info.username.as_deref(), Some("alice@example.com")); + } + + #[test] + fn parses_select_request() { + let payload = b"a002 SELECT INBOX\r\n"; + let info = analyze_imap(payload).expect("should parse"); + assert_eq!(info.command.as_deref(), Some("SELECT")); + assert_eq!(info.args.as_deref(), Some("INBOX")); + assert!(info.username.is_none()); + } + + #[test] + fn parses_uid_fetch_request() { + let payload = b"a003 UID FETCH 1:* (FLAGS RFC822.SIZE)\r\n"; + let info = analyze_imap(payload).expect("should parse"); + assert_eq!(info.command.as_deref(), Some("UID")); + assert_eq!(info.args.as_deref(), Some("FETCH 1:* (FLAGS RFC822.SIZE)")); + } + + #[test] + fn parses_tagged_response() { + let payload = b"a002 OK [READ-WRITE] SELECT completed\r\n"; + let info = analyze_imap(payload).expect("should parse"); + assert!(matches!(info.message_type, ImapMessageType::TaggedResponse)); + assert_eq!(info.tag.as_deref(), Some("a002")); + assert_eq!(info.status.as_deref(), Some("OK")); + } + + #[test] + fn parses_no_response() { + let payload = b"a004 NO [AUTHENTICATIONFAILED] Authentication failed.\r\n"; + let info = analyze_imap(payload).expect("should parse"); + assert_eq!(info.status.as_deref(), Some("NO")); + } + + #[test] + fn parses_untagged_data() { + let payload = b"* 23 EXISTS\r\n"; + let info = analyze_imap(payload).expect("should parse"); + assert!(matches!( + info.message_type, + ImapMessageType::UntaggedResponse + )); + // "23" is not alphabetic so status_upper is empty — that's fine. + assert!(info.status.is_none() || info.status.as_deref() == Some("")); + } + + #[test] + fn parses_continuation() { + let payload = b"+ Ready for additional command text\r\n"; + let info = analyze_imap(payload).expect("should parse"); + assert!(matches!(info.message_type, ImapMessageType::Continuation)); + } + + #[test] + fn rejects_http_request() { + let payload = b"GET /mail HTTP/1.1\r\nHost: example.com\r\n\r\n"; + assert!(!is_imap(payload)); + assert!(analyze_imap(payload).is_none()); + } + + #[test] + fn rejects_bare_tag_without_command() { + assert!(!is_imap(b"a001\r\n")); + } + + #[test] + fn rejects_unknown_command() { + assert!(!is_imap(b"a001 FOOBAR baz\r\n")); + } + + #[test] + fn parses_starttls() { + let payload = b"a005 STARTTLS\r\n"; + let info = analyze_imap(payload).expect("should parse"); + assert_eq!(info.command.as_deref(), Some("STARTTLS")); + } +} diff --git a/src/network/dpi/mod.rs b/src/network/dpi/mod.rs index d2eb422f..0a2e864d 100644 --- a/src/network/dpi/mod.rs +++ b/src/network/dpi/mod.rs @@ -7,6 +7,7 @@ mod dhcp; mod dns; mod http; mod https; +mod imap; mod llmnr; mod mdns; mod mqtt; @@ -24,6 +25,7 @@ pub use quic::{is_partial_sni, try_extract_tls_from_reassembler}; // Well-known port numbers used for DPI protocol detection. const PORT_SSH: u16 = 22; const PORT_DNS: u16 = 53; +const PORT_IMAP: u16 = 143; const PORT_DHCP_SERVER: u16 = 67; const PORT_DHCP_CLIENT: u16 = 68; const PORT_NTP: u16 = 123; @@ -101,6 +103,16 @@ pub fn analyze_tcp_packet( }); } + // 6. Check for IMAP (port 143 or untagged/tagged frame signature). Port + // 993 (IMAPS) is TLS-wrapped — claimed by the HTTPS analyzer instead. + if (local_port == PORT_IMAP || remote_port == PORT_IMAP || imap::is_imap(payload)) + && let Some(imap_result) = imap::analyze_imap(payload) + { + return Some(DpiResult { + application: ApplicationProtocol::Imap(imap_result), + }); + } + // More protocols here... None diff --git a/src/network/merge.rs b/src/network/merge.rs index 8b22fc03..e8040691 100644 --- a/src/network/merge.rs +++ b/src/network/merge.rs @@ -6,7 +6,7 @@ use std::time::{Instant, SystemTime}; use crate::network::dpi::{DpiResult, is_partial_sni, try_extract_tls_from_reassembler}; use crate::network::parser::{ParsedPacket, TcpFlags}; use crate::network::types::{ - ApplicationProtocol, Connection, DnsInfo, DpiInfo, HttpInfo, HttpsInfo, MqttInfo, + ApplicationProtocol, Connection, DnsInfo, DpiInfo, HttpInfo, HttpsInfo, ImapInfo, MqttInfo, ProtocolState, QuicConnectionState, QuicInfo, SshInfo, TcpState, }; @@ -494,6 +494,11 @@ fn merge_dpi_info(conn: &mut Connection, dpi_result: &DpiResult) { merge_mqtt_info(old_info, new_info); } + // IMAP - dialog state evolves across tagged exchanges + (ApplicationProtocol::Imap(old_info), ApplicationProtocol::Imap(new_info)) => { + merge_imap_info(old_info, new_info); + } + _ => { // Keep existing protocol } @@ -858,6 +863,40 @@ fn merge_ssh_info(old_info: &mut SshInfo, new_info: &SshInfo) { } } +/// Merge IMAP information across packets in the same control connection. +/// +/// Identity-like fields (`username`, `server_software`) are first-wins. +/// Dialog state (`message_type`, `tag`, `command`, `args`, `status`, +/// `response_message`) is latest-wins. +fn merge_imap_info(old_info: &mut ImapInfo, new_info: &ImapInfo) { + if old_info.username.is_none() && new_info.username.is_some() { + old_info.username.clone_from(&new_info.username); + } + if old_info.server_software.is_none() && new_info.server_software.is_some() { + old_info + .server_software + .clone_from(&new_info.server_software); + } + old_info.message_type = new_info.message_type; + if new_info.tag.is_some() { + old_info.tag.clone_from(&new_info.tag); + } + if new_info.command.is_some() { + old_info.command.clone_from(&new_info.command); + } + if new_info.args.is_some() { + old_info.args.clone_from(&new_info.args); + } + if new_info.status.is_some() { + old_info.status.clone_from(&new_info.status); + } + if new_info.response_message.is_some() { + old_info + .response_message + .clone_from(&new_info.response_message); + } +} + /// Merge MQTT information fn merge_mqtt_info(old_info: &mut MqttInfo, new_info: &MqttInfo) { if old_info.version.is_none() && new_info.version.is_some() { diff --git a/src/network/types.rs b/src/network/types.rs index d943d226..5d46e95c 100644 --- a/src/network/types.rs +++ b/src/network/types.rs @@ -160,6 +160,34 @@ impl std::fmt::Display for ApplicationProtocol { write!(f, "MQTT {}", info.packet_type) } } + ApplicationProtocol::Imap(info) => match info.message_type { + ImapMessageType::Request => { + if let (Some(cmd), Some(user)) = (&info.command, &info.username) { + write!(f, "IMAP {} ({})", cmd, user) + } else if let Some(cmd) = &info.command { + write!(f, "IMAP {}", cmd) + } else { + write!(f, "IMAP") + } + } + ImapMessageType::UntaggedResponse => { + if let (Some(status), Some(sw)) = (&info.status, &info.server_software) { + write!(f, "IMAP {} ({})", status, sw) + } else if let Some(status) = &info.status { + write!(f, "IMAP {}", status) + } else { + write!(f, "IMAP") + } + } + ImapMessageType::TaggedResponse => { + if let Some(status) = &info.status { + write!(f, "IMAP {}", status) + } else { + write!(f, "IMAP") + } + } + ImapMessageType::Continuation => write!(f, "IMAP +"), + }, } } } @@ -358,6 +386,7 @@ pub enum ApplicationProtocol { BitTorrent(BitTorrentInfo), Stun(StunInfo), Mqtt(MqttInfo), + Imap(ImapInfo), } impl ApplicationProtocol { @@ -369,6 +398,7 @@ impl ApplicationProtocol { ApplicationProtocol::Dns(_) => "DNS", ApplicationProtocol::Http(_) => "HTTP", ApplicationProtocol::Https(_) => "HTTPS", + ApplicationProtocol::Imap(_) => "IMAP", ApplicationProtocol::Llmnr(_) => "LLMNR", ApplicationProtocol::Mdns(_) => "mDNS", ApplicationProtocol::Mqtt(_) => "MQTT", @@ -383,6 +413,41 @@ impl ApplicationProtocol { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImapMessageType { + Request, + /// Tagged completion response (` OK/NO/BAD/BYE …`). + TaggedResponse, + /// Untagged status or data response (`* OK …`, `* 23 EXISTS`, …). + UntaggedResponse, + /// Command continuation request (`+ `). + Continuation, +} + +impl fmt::Display for ImapMessageType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + ImapMessageType::Request => "IMAP_REQUEST", + ImapMessageType::TaggedResponse => "IMAP_TAGGED_RESPONSE", + ImapMessageType::UntaggedResponse => "IMAP_UNTAGGED_RESPONSE", + ImapMessageType::Continuation => "IMAP_CONTINUATION", + }; + write!(f, "{}", name) + } +} + +#[derive(Debug, Clone)] +pub struct ImapInfo { + pub message_type: ImapMessageType, + pub tag: Option, + pub command: Option, + pub args: Option, + pub status: Option, + pub response_message: Option, + pub username: Option, + pub server_software: Option, +} + #[derive(Debug, Clone)] pub struct HttpInfo { pub version: HttpVersion, @@ -1478,7 +1543,8 @@ impl AppProtocolDistribution { | ApplicationProtocol::NetBios(_) | ApplicationProtocol::BitTorrent(_) | ApplicationProtocol::Stun(_) - | ApplicationProtocol::Mqtt(_) => dist.other_count += 1, + | ApplicationProtocol::Mqtt(_) + | ApplicationProtocol::Imap(_) => dist.other_count += 1, } } else { dist.other_count += 1; @@ -1995,6 +2061,7 @@ impl Connection { Cow::Owned(format!("STUN_{}", info.message_class)) } ApplicationProtocol::Mqtt(_) => Cow::Borrowed("MQTT_UDP"), + ApplicationProtocol::Imap(_) => Cow::Borrowed("IMAP_UDP"), } } else { // Regular UDP without DPI classification @@ -2113,6 +2180,7 @@ impl Connection { ApplicationProtocol::BitTorrent(_) => Duration::from_secs(60), ApplicationProtocol::Stun(_) => Duration::from_secs(30), ApplicationProtocol::Mqtt(_) => Duration::from_secs(120), + ApplicationProtocol::Imap(_) => Duration::from_secs(1800), } } else { // Regular UDP without DPI classification diff --git a/src/ui.rs b/src/ui.rs index caebf678..95914ba3 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -3922,6 +3922,78 @@ fn draw_connection_details( ); } } + crate::network::types::ApplicationProtocol::Imap(info) => { + push_detail_field( + &mut details_text, + &mut detail_fields, + "Message Type", + info.message_type.to_string(), + label_style, + ); + if let Some(tag) = &info.tag { + push_detail_field( + &mut details_text, + &mut detail_fields, + "Tag", + tag.clone(), + label_style, + ); + } + if let Some(cmd) = &info.command { + push_detail_field( + &mut details_text, + &mut detail_fields, + "Command", + cmd.clone(), + label_style, + ); + } + if let Some(args) = &info.args { + push_detail_field( + &mut details_text, + &mut detail_fields, + "Arguments", + args.clone(), + label_style, + ); + } + if let Some(status) = &info.status { + push_detail_field( + &mut details_text, + &mut detail_fields, + "Status", + status.clone(), + label_style, + ); + } + if let Some(message) = &info.response_message { + push_detail_field( + &mut details_text, + &mut detail_fields, + "Response", + message.clone(), + label_style, + ); + } + if let Some(user) = &info.username { + push_detail_field( + &mut details_text, + &mut detail_fields, + "Username", + user.clone(), + label_style, + ); + } + if let Some(sw) = &info.server_software { + push_detail_field( + &mut details_text, + &mut detail_fields, + "Server Software", + sw.clone(), + label_style, + ); + } + } crate::network::types::ApplicationProtocol::Mqtt(info) => { push_detail_field( &mut details_text,