diff --git a/src/filter.rs b/src/filter.rs index 87388c04..0d0811f2 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -402,6 +402,36 @@ impl ConnectionFilter { return true; } } + ApplicationProtocol::Smtp(info) => { + if match_text("smtp", fv) { + return true; + } + if let Some(ref cmd) = info.command + && match_text(cmd, fv) + { + return true; + } + if let Some(ref sender) = info.sender + && match_text(sender, fv) + { + return true; + } + if let Some(ref recipient) = info.recipient + && match_text(recipient, fv) + { + return true; + } + if let Some(ref sw) = info.server_software + && match_text(sw, fv) + { + return true; + } + if let Some(code) = info.response_code + && match_text(&code.to_string(), 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/mod.rs b/src/network/dpi/mod.rs index d2eb422f..1f6354b1 100644 --- a/src/network/dpi/mod.rs +++ b/src/network/dpi/mod.rs @@ -13,6 +13,7 @@ mod mqtt; mod netbios; mod ntp; mod quic; +mod smtp; mod snmp; mod ssdp; mod ssh; @@ -24,6 +25,10 @@ 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_SMTP: u16 = 25; +const PORT_SMTPS: u16 = 465; +const PORT_SMTP_SUBMISSION: u16 = 587; +const PORT_SMTP_ALT: u16 = 2525; const PORT_DHCP_SERVER: u16 = 67; const PORT_DHCP_CLIENT: u16 = 68; const PORT_NTP: u16 = 123; @@ -101,6 +106,25 @@ pub fn analyze_tcp_packet( }); } + // 6. Check for SMTP (ports 25 / 465 / 587 / 2525 or known verb / 3-digit + // response prefix). Runs after HTTP so HTTP can claim its own payloads + // cheaply — SMTP verbs do not overlap with HTTP methods. + if (local_port == PORT_SMTP + || remote_port == PORT_SMTP + || local_port == PORT_SMTPS + || remote_port == PORT_SMTPS + || local_port == PORT_SMTP_SUBMISSION + || remote_port == PORT_SMTP_SUBMISSION + || local_port == PORT_SMTP_ALT + || remote_port == PORT_SMTP_ALT + || smtp::is_smtp(payload)) + && let Some(smtp_result) = smtp::analyze_smtp(payload) + { + return Some(DpiResult { + application: ApplicationProtocol::Smtp(smtp_result), + }); + } + // More protocols here... None diff --git a/src/network/dpi/smtp.rs b/src/network/dpi/smtp.rs new file mode 100644 index 00000000..beebd375 --- /dev/null +++ b/src/network/dpi/smtp.rs @@ -0,0 +1,267 @@ +//! SMTP (Simple Mail Transfer Protocol) Deep Packet Inspection +//! +//! Parses the plaintext SMTP control channel (RFC 5321, RFC 1869 ESMTP, +//! RFC 3207 STARTTLS, RFC 4954 AUTH). Detection is keyed off ports 25, 587, +//! 465, and 2525 plus a cheap start-line signature so non-standard SMTP +//! ports are still caught. Once STARTTLS upgrades the channel, subsequent +//! bytes are TLS and will be claimed by the HTTPS/TLS analyzer instead. + +use crate::network::types::{SmtpInfo, SmtpMessageType}; + +/// Maximum bytes scanned for the signature check. +const MAX_SNIFF_BYTES: usize = 1024; + +/// RFC 5321 / 1869 / 3207 / 4954 verbs. Matched case-insensitively against the +/// first whitespace-delimited token on the first line of the payload. +const SMTP_COMMANDS: &[&str] = &[ + "HELO", "EHLO", "MAIL", "RCPT", "DATA", "RSET", "NOOP", "QUIT", "VRFY", "EXPN", "HELP", + "STARTTLS", "AUTH", "BDAT", "ETRN", +]; + +/// Cheap heuristic: returns `true` when the payload's first line plausibly +/// belongs to an SMTP exchange. Distinct from FTP only in the verb set. +pub fn is_smtp(payload: &[u8]) -> bool { + let line = first_line(payload); + if line.is_empty() { + return false; + } + if is_response_prefix(line) { + return true; + } + let upper = first_token_upper(line); + !upper.is_empty() && SMTP_COMMANDS.iter().any(|cmd| cmd.as_bytes() == upper) +} + +/// Parse an SMTP payload. Returns `None` when the payload does not look like +/// SMTP. +pub fn analyze_smtp(payload: &[u8]) -> Option { + let line = first_line(payload); + if line.is_empty() { + return None; + } + + if is_response_prefix(line) { + let code = std::str::from_utf8(&line[0..3]).ok()?.parse::().ok()?; + let message = std::str::from_utf8(&line[4..]) + .ok() + .map(|s| s.trim().to_string()); + // RFC 5321 §4.2: 220 service-ready greeting carries the host/software + // banner; 250 EHLO replies often echo the host name and capabilities. + let server_software = if code == 220 || code == 250 { + message.as_deref().map(extract_software_token) + } else { + None + }; + return Some(SmtpInfo { + message_type: SmtpMessageType::Response, + command: None, + args: None, + response_code: Some(code), + response_message: message, + sender: None, + recipient: None, + server_software, + }); + } + + let upper = first_token_upper(line); + if upper.is_empty() { + return None; + } + if !SMTP_COMMANDS.iter().any(|cmd| cmd.as_bytes() == upper) { + return None; + } + let command = std::str::from_utf8(&upper).ok()?.to_string(); + let args = std::str::from_utf8(line) + .ok() + .map(|s| s.trim()) + .and_then(|s| { + s.split_once(char::is_whitespace) + .map(|(_, rest)| rest.trim()) + }) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + // RFC 5321 §4.1.1.2 / §4.1.1.3: `MAIL FROM:` and `RCPT TO:` + // carry the envelope sender/recipient. Pull the address out of the + // angle brackets for the connection-table column. + let sender = if command == "MAIL" { + args.as_deref().and_then(extract_envelope_address) + } else { + None + }; + let recipient = if command == "RCPT" { + args.as_deref().and_then(extract_envelope_address) + } else { + None + }; + + Some(SmtpInfo { + message_type: SmtpMessageType::Request, + command: Some(command), + args, + response_code: None, + response_message: None, + sender, + recipient, + server_software: None, + }) +} + +fn is_response_prefix(line: &[u8]) -> bool { + line.len() >= 4 + && line[0].is_ascii_digit() + && line[1].is_ascii_digit() + && line[2].is_ascii_digit() + && (line[3] == b' ' || line[3] == b'-') +} + +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, + } +} + +fn first_token_upper(line: &[u8]) -> Vec { + let token_end = line + .iter() + .position(|&b| b == b' ' || b == b'\t' || b == b'\r' || b == b':') + .unwrap_or(line.len()); + let token = &line[..token_end]; + if token.is_empty() || token.len() > 9 { + // SMTP verbs top out at "STARTTLS" (8 chars); cap at 9 to keep + // upper-casing cheap on hostile traffic. + 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_envelope_address(args: &str) -> Option { + // Accept "FROM:", "FROM: ", or bare "". + let after_colon = args.split_once(':').map(|(_, rest)| rest).unwrap_or(args); + let trimmed = after_colon.trim(); + let inner = trimmed + .strip_prefix('<') + .and_then(|s| s.strip_suffix('>')) + .unwrap_or(trimmed); + if inner.is_empty() { + None + } else { + Some(inner.to_string()) + } +} + +fn extract_software_token(message: &str) -> String { + // Greeting banners are free-form. Heuristic: skip the host name (first + // token) when present, then return the next token that contains a letter. + // Falls back to the first alphabetic token, then the full message. + let mut tokens = message + .split_whitespace() + .map(|t| t.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '.')); + let first = tokens.next(); + for token in tokens { + if token.chars().any(|c| c.is_ascii_alphabetic()) && !token.eq_ignore_ascii_case("ESMTP") { + return token.to_string(); + } + } + first.map(|s| s.to_string()).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_server_greeting() { + let payload = b"220 mail.example.com ESMTP Postfix\r\n"; + assert!(is_smtp(payload)); + let info = analyze_smtp(payload).expect("should parse"); + assert!(matches!(info.message_type, SmtpMessageType::Response)); + assert_eq!(info.response_code, Some(220)); + assert_eq!(info.server_software.as_deref(), Some("Postfix")); + } + + #[test] + fn detects_ehlo_continuation_response() { + let payload = b"250-mail.example.com Hello\r\n250-SIZE 14680064\r\n250 STARTTLS\r\n"; + let info = analyze_smtp(payload).expect("should parse"); + assert_eq!(info.response_code, Some(250)); + } + + #[test] + fn parses_ehlo_request() { + let payload = b"EHLO client.example.org\r\n"; + let info = analyze_smtp(payload).expect("should parse"); + assert_eq!(info.command.as_deref(), Some("EHLO")); + assert_eq!(info.args.as_deref(), Some("client.example.org")); + } + + #[test] + fn parses_mail_from_envelope() { + let payload = b"MAIL FROM:\r\n"; + let info = analyze_smtp(payload).expect("should parse"); + assert_eq!(info.command.as_deref(), Some("MAIL")); + assert_eq!(info.sender.as_deref(), Some("alice@example.com")); + } + + #[test] + fn parses_rcpt_to_envelope() { + let payload = b"RCPT TO: \r\n"; + let info = analyze_smtp(payload).expect("should parse"); + assert_eq!(info.command.as_deref(), Some("RCPT")); + assert_eq!(info.recipient.as_deref(), Some("bob@example.org")); + } + + #[test] + fn parses_starttls() { + let payload = b"STARTTLS\r\n"; + let info = analyze_smtp(payload).expect("should parse"); + assert_eq!(info.command.as_deref(), Some("STARTTLS")); + } + + #[test] + fn parses_data_response() { + let payload = b"354 End data with .\r\n"; + let info = analyze_smtp(payload).expect("should parse"); + assert_eq!(info.response_code, Some(354)); + // 354 is not a banner — should not extract software. + assert!(info.server_software.is_none()); + } + + #[test] + fn parses_lowercase_command() { + let payload = b"quit\r\n"; + let info = analyze_smtp(payload).expect("should parse"); + assert_eq!(info.command.as_deref(), Some("QUIT")); + } + + #[test] + fn rejects_http_request() { + let payload = b"GET /mail HTTP/1.1\r\nHost: example.com\r\n\r\n"; + assert!(!is_smtp(payload)); + assert!(analyze_smtp(payload).is_none()); + } + + #[test] + fn rejects_unknown_verb() { + assert!(!is_smtp(b"WOOSH foo\r\n")); + assert!(analyze_smtp(b"WOOSH foo\r\n").is_none()); + } + + #[test] + fn rejects_short_payload() { + assert!(!is_smtp(b"220")); + assert!(analyze_smtp(b"22").is_none()); + } +} diff --git a/src/network/merge.rs b/src/network/merge.rs index 8b22fc03..3a4c997a 100644 --- a/src/network/merge.rs +++ b/src/network/merge.rs @@ -7,7 +7,7 @@ use crate::network::dpi::{DpiResult, is_partial_sni, try_extract_tls_from_reasse use crate::network::parser::{ParsedPacket, TcpFlags}; use crate::network::types::{ ApplicationProtocol, Connection, DnsInfo, DpiInfo, HttpInfo, HttpsInfo, MqttInfo, - ProtocolState, QuicConnectionState, QuicInfo, SshInfo, TcpState, + ProtocolState, QuicConnectionState, QuicInfo, SmtpInfo, SshInfo, TcpState, }; /// Get the priority of a QUIC connection state for proper state progression @@ -494,6 +494,11 @@ fn merge_dpi_info(conn: &mut Connection, dpi_result: &DpiResult) { merge_mqtt_info(old_info, new_info); } + // SMTP - dialog state evolves across commands/responses + (ApplicationProtocol::Smtp(old_info), ApplicationProtocol::Smtp(new_info)) => { + merge_smtp_info(old_info, new_info); + } + _ => { // Keep existing protocol } @@ -858,6 +863,41 @@ fn merge_ssh_info(old_info: &mut SshInfo, new_info: &SshInfo) { } } +/// Merge SMTP information across packets in the same control connection. +/// +/// Identity-like fields (`sender`, `recipient`, `server_software`) are +/// first-wins. Dialog state (`message_type`, `command`, `args`, +/// `response_code`, `response_message`) is latest-wins so the connection +/// table column reflects the most recent exchange. +fn merge_smtp_info(old_info: &mut SmtpInfo, new_info: &SmtpInfo) { + if old_info.sender.is_none() && new_info.sender.is_some() { + old_info.sender.clone_from(&new_info.sender); + } + if old_info.recipient.is_none() && new_info.recipient.is_some() { + old_info.recipient.clone_from(&new_info.recipient); + } + 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.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.response_code.is_some() { + old_info.response_code = new_info.response_code; + } + 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..a0fa83ce 100644 --- a/src/network/types.rs +++ b/src/network/types.rs @@ -160,6 +160,31 @@ impl std::fmt::Display for ApplicationProtocol { write!(f, "MQTT {}", info.packet_type) } } + ApplicationProtocol::Smtp(info) => match info.message_type { + SmtpMessageType::Request => { + if let Some(cmd) = &info.command { + if let Some(envelope) = info.sender.as_deref().or(info.recipient.as_deref()) + { + write!(f, "SMTP {} <{}>", cmd, envelope) + } else if let Some(args) = &info.args { + write!(f, "SMTP {} {}", cmd, args) + } else { + write!(f, "SMTP {}", cmd) + } + } else { + write!(f, "SMTP") + } + } + SmtpMessageType::Response => { + if let (Some(code), Some(sw)) = (info.response_code, &info.server_software) { + write!(f, "SMTP {} ({})", code, sw) + } else if let Some(code) = info.response_code { + write!(f, "SMTP {}", code) + } else { + write!(f, "SMTP") + } + } + }, } } } @@ -358,6 +383,7 @@ pub enum ApplicationProtocol { BitTorrent(BitTorrentInfo), Stun(StunInfo), Mqtt(MqttInfo), + Smtp(SmtpInfo), } impl ApplicationProtocol { @@ -375,6 +401,7 @@ impl ApplicationProtocol { ApplicationProtocol::NetBios(_) => "NetBIOS", ApplicationProtocol::Ntp(_) => "NTP", ApplicationProtocol::Quic(_) => "QUIC", + ApplicationProtocol::Smtp(_) => "SMTP", ApplicationProtocol::Snmp(_) => "SNMP", ApplicationProtocol::Ssh(_) => "SSH", ApplicationProtocol::Ssdp(_) => "SSDP", @@ -383,6 +410,34 @@ impl ApplicationProtocol { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SmtpMessageType { + Request, + Response, +} + +impl fmt::Display for SmtpMessageType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + SmtpMessageType::Request => "SMTP_REQUEST", + SmtpMessageType::Response => "SMTP_RESPONSE", + }; + write!(f, "{}", name) + } +} + +#[derive(Debug, Clone)] +pub struct SmtpInfo { + pub message_type: SmtpMessageType, + pub command: Option, + pub args: Option, + pub response_code: Option, + pub response_message: Option, + pub sender: Option, + pub recipient: Option, + pub server_software: Option, +} + #[derive(Debug, Clone)] pub struct HttpInfo { pub version: HttpVersion, @@ -1478,7 +1533,8 @@ impl AppProtocolDistribution { | ApplicationProtocol::NetBios(_) | ApplicationProtocol::BitTorrent(_) | ApplicationProtocol::Stun(_) - | ApplicationProtocol::Mqtt(_) => dist.other_count += 1, + | ApplicationProtocol::Mqtt(_) + | ApplicationProtocol::Smtp(_) => dist.other_count += 1, } } else { dist.other_count += 1; @@ -1995,6 +2051,7 @@ impl Connection { Cow::Owned(format!("STUN_{}", info.message_class)) } ApplicationProtocol::Mqtt(_) => Cow::Borrowed("MQTT_UDP"), + ApplicationProtocol::Smtp(_) => Cow::Borrowed("SMTP_UDP"), } } else { // Regular UDP without DPI classification @@ -2113,6 +2170,7 @@ impl Connection { ApplicationProtocol::BitTorrent(_) => Duration::from_secs(60), ApplicationProtocol::Stun(_) => Duration::from_secs(30), ApplicationProtocol::Mqtt(_) => Duration::from_secs(120), + ApplicationProtocol::Smtp(_) => Duration::from_secs(120), } } else { // Regular UDP without DPI classification diff --git a/src/ui.rs b/src/ui.rs index caebf678..8a27ffa7 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -3922,6 +3922,78 @@ fn draw_connection_details( ); } } + crate::network::types::ApplicationProtocol::Smtp(info) => { + push_detail_field( + &mut details_text, + &mut detail_fields, + "Message Type", + info.message_type.to_string(), + 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(code) = info.response_code { + push_detail_field( + &mut details_text, + &mut detail_fields, + "Response Code", + code.to_string(), + 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(sender) = &info.sender { + push_detail_field( + &mut details_text, + &mut detail_fields, + "Envelope Sender", + sender.clone(), + label_style, + ); + } + if let Some(recipient) = &info.recipient { + push_detail_field( + &mut details_text, + &mut detail_fields, + "Envelope Recipient", + recipient.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,