From c9abb48fec5a606f28470cb51fb5f3e08640449a Mon Sep 17 00:00:00 2001 From: 0xghost42 Date: Wed, 13 May 2026 16:34:27 +0530 Subject: [PATCH] feat(dpi): add SMTP protocol detection and metadata extraction Adds best-effort Deep Packet Inspection for the plaintext SMTP control channel (RFC 5321, RFC 1869 ESMTP, RFC 3207 STARTTLS, RFC 4954 AUTH). Once STARTTLS upgrades the channel, subsequent bytes are TLS and will be claimed by the HTTPS/TLS analyzer instead. Detection: ports 25 / 465 / 587 / 2525 plus a cheap signature so non-standard SMTP ports are still caught. Server response is a 3-digit reply code (optionally with '-' continuation marker); client request is one of HELO/EHLO/MAIL/RCPT/DATA/RSET/NOOP/QUIT/VRFY/EXPN/HELP/STARTTLS/AUTH/BDAT/ETRN. Metadata: command + args, response code + message, envelope sender from MAIL FROM:, envelope recipient from RCPT TO:, server software extracted from 220/250 greetings (e.g. Postfix). Display formatter shows 'SMTP MAIL ' for envelope hops and 'SMTP 220 (Postfix)' for greetings. Per-flow merge keeps identity fields first-wins and dialog state latest-wins. Tests: 11 unit tests covering greeting parse, EHLO multi-line, MAIL FROM / RCPT TO envelope extraction, STARTTLS, 354 DATA prompt, lowercase verb, HTTP rejection, unknown verb rejection, short-payload rejection. cargo test --all-features: 335 passed. cargo clippy --all-features --all-targets -- -D warnings: clean. cargo fmt --all -- --check: clean. Tracks ROADMAP DPI Enhancements (SMTP). --- src/filter.rs | 30 +++++ src/network/dpi/mod.rs | 24 ++++ src/network/dpi/smtp.rs | 267 ++++++++++++++++++++++++++++++++++++++++ src/network/merge.rs | 42 ++++++- src/network/types.rs | 60 ++++++++- src/ui.rs | 72 +++++++++++ 6 files changed, 493 insertions(+), 2 deletions(-) create mode 100644 src/network/dpi/smtp.rs 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,